Compare commits
158 Commits
experiment
...
v13.0.0-84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6593412a2 | ||
|
|
d31bf36531 | ||
|
|
a485f550db | ||
|
|
0610b16227 | ||
|
|
72e470c5f0 | ||
|
|
4d12a02e2f | ||
|
|
4a7d6f0a2d | ||
|
|
c80f446b5f | ||
|
|
81a529d8dc | ||
|
|
4f0ab78914 | ||
|
|
8c36f67f0b | ||
|
|
77687d94e6 | ||
|
|
4644511303 | ||
|
|
20005eecdb | ||
|
|
c9dda245bf | ||
|
|
1417470156 | ||
|
|
584e5dfd40 | ||
|
|
805acbb9f5 | ||
|
|
32c4c09072 | ||
|
|
8c5a06bbf8 | ||
|
|
a336cc167c | ||
|
|
21d86cd2be | ||
|
|
1d0f9faa91 | ||
|
|
45237571b7 | ||
|
|
bb6f6cd141 | ||
|
|
729c1f16b8 | ||
|
|
b6059704aa | ||
|
|
fa3c92f44c | ||
|
|
cd82de7742 | ||
|
|
07a6a0044b | ||
|
|
4582832a71 | ||
|
|
07ac1d03e3 | ||
|
|
cbcf1facb8 | ||
|
|
31ff7ac78c | ||
|
|
ed3b31e58f | ||
|
|
759ecb21f7 | ||
|
|
9c29d820c8 | ||
|
|
2ef11a5344 | ||
|
|
9fe47e98d5 | ||
|
|
654510f3ff | ||
|
|
52ec698635 | ||
|
|
1b06f59d1c | ||
|
|
12bcc4d080 | ||
|
|
e1a9f314a7 | ||
|
|
7a111de186 | ||
|
|
90b3fa9dd9 | ||
|
|
c635963747 | ||
|
|
1b17b5e400 | ||
|
|
61d9d96d15 | ||
|
|
7d0c048708 | ||
|
|
8a7416ad50 | ||
|
|
e56899a02c | ||
|
|
30bf3742c9 | ||
|
|
8dbd2c4696 | ||
|
|
6578727c9c | ||
|
|
92ca001cdc | ||
|
|
415de1cc4c | ||
|
|
e23582b1cd | ||
|
|
73c28952c2 | ||
|
|
1bc1e88d6a | ||
|
|
c188f813a4 | ||
|
|
ff981a8697 | ||
|
|
d9ab593b07 | ||
|
|
293527e62b | ||
|
|
5a42a94cf4 | ||
|
|
040808300c | ||
|
|
57975d409e | ||
|
|
306b2c64f3 | ||
|
|
585265e9a5 | ||
|
|
777ae9503a | ||
|
|
4c1798e5fa | ||
|
|
f4d85e2a3e | ||
|
|
a0f0c9c377 | ||
|
|
95ec2a435a | ||
|
|
da9836fe59 | ||
|
|
3a7411f9e8 | ||
|
|
39cee7c6e7 | ||
|
|
0a5753c191 | ||
|
|
76b7d0b651 | ||
|
|
99e3e95a00 | ||
|
|
93ee4ee287 | ||
|
|
c5cc403a29 | ||
|
|
75f4a0a5f0 | ||
|
|
591df5c00a | ||
|
|
f6b4819ae3 | ||
|
|
d483d9cc83 | ||
|
|
453407b93d | ||
|
|
e699f92333 | ||
|
|
6ff47719ef | ||
|
|
3a0694c35c | ||
|
|
74e5243742 | ||
|
|
dcf43b9797 | ||
|
|
77e479c03b | ||
|
|
ec58a99748 | ||
|
|
f1eb66655b | ||
|
|
7f4ae9fe14 | ||
|
|
c0ba56a21f | ||
|
|
4063e28b5e | ||
|
|
b6f7cd7869 | ||
|
|
1a79e429ed | ||
|
|
04066a5678 | ||
|
|
e09ef15349 | ||
|
|
3d70eee959 | ||
|
|
582095e5a3 | ||
|
|
c9ea3a412e | ||
|
|
a2c51c36e9 | ||
|
|
ab3dba5b06 | ||
|
|
3ddff186c2 | ||
|
|
9bd199a6e7 | ||
|
|
01d0825ae6 | ||
|
|
e2f98525d2 | ||
|
|
70a0a03130 | ||
|
|
656d85c62e | ||
|
|
e168dd48fb | ||
|
|
12d43199d5 | ||
|
|
539fa8b21d | ||
|
|
f572f94586 | ||
|
|
c12d00b227 | ||
|
|
e4a5f2caec | ||
|
|
9f9f465238 | ||
|
|
8450ff86d7 | ||
|
|
70139262c5 | ||
|
|
9c0da271eb | ||
|
|
ade3e1949d | ||
|
|
eec63a008f | ||
|
|
52abcdd043 | ||
|
|
f94653424a | ||
|
|
d67a794e2c | ||
|
|
60318083a6 | ||
|
|
7607070452 | ||
|
|
28fb7b6e9c | ||
|
|
aafe15757f | ||
|
|
31d6ef6296 | ||
|
|
32b8fac37f | ||
|
|
e8060de914 | ||
|
|
22b036527c | ||
|
|
feb1e030d7 | ||
|
|
bd271e3952 | ||
|
|
df80938190 | ||
|
|
67bbc0a3fe | ||
|
|
e1ece6dc66 | ||
|
|
fe038822a3 | ||
|
|
dece14486c | ||
|
|
2daffbc2ca | ||
|
|
4c01a34d09 | ||
|
|
3b08267daa | ||
|
|
b98ebddb69 | ||
|
|
9d5bf50676 | ||
|
|
c0972f8158 | ||
|
|
548125a944 | ||
|
|
a7b124ca6e | ||
|
|
4022374620 | ||
|
|
860e4d7af6 | ||
|
|
6376d69b58 | ||
|
|
5cf6f45f19 | ||
|
|
967903673b | ||
|
|
db5312443e | ||
|
|
dbda07424b |
12
.babelrc
12
.babelrc
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
["latest", {
|
||||
"es2015": {
|
||||
"modules": false
|
||||
}
|
||||
}]
|
||||
],
|
||||
"plugins": [
|
||||
"external-helpers"
|
||||
]
|
||||
}
|
||||
10
.esdoc.json
10
.esdoc.json
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"source": "./src",
|
||||
"destination": "./docs",
|
||||
"plugins": [{
|
||||
"name": "esdoc-standard-plugin",
|
||||
"option": {
|
||||
"accessor": {"access": ["public"], "autoPrivate": true}
|
||||
}
|
||||
}]
|
||||
}
|
||||
14
.flowconfig
14
.flowconfig
@@ -1,14 +0,0 @@
|
||||
[ignore]
|
||||
.*/node_modules/.*
|
||||
.*/dist/.*
|
||||
.*/build/.*
|
||||
|
||||
[include]
|
||||
./src/
|
||||
./tests-lib/
|
||||
./test/
|
||||
|
||||
[libs]
|
||||
./declarations/
|
||||
|
||||
[options]
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,7 +1,4 @@
|
||||
node_modules
|
||||
bower_components
|
||||
docs
|
||||
/y.*
|
||||
/examples/yjs-dist.js*
|
||||
dist
|
||||
.vscode
|
||||
.yjsPersisted
|
||||
docs
|
||||
|
||||
50
.jsdoc.json
Normal file
50
.jsdoc.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"sourceType": "module",
|
||||
"tags": {
|
||||
"allowUnknownTags": true,
|
||||
"dictionaries": ["jsdoc"]
|
||||
},
|
||||
"source": {
|
||||
"include": ["./src"],
|
||||
"includePattern": ".js$"
|
||||
},
|
||||
"plugins": [
|
||||
"plugins/markdown"
|
||||
],
|
||||
"templates": {
|
||||
"referenceTitle": "Yjs",
|
||||
"disableSort": false,
|
||||
"useCollapsibles": true,
|
||||
"collapse": true,
|
||||
"resources": {
|
||||
"y-js.org": "yjs.website"
|
||||
},
|
||||
"logo": {
|
||||
"url": "https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png",
|
||||
"width": "162px",
|
||||
"height": "162px",
|
||||
"link": "/"
|
||||
},
|
||||
"tabNames": {
|
||||
"api": "API",
|
||||
"tutorials": "Examples"
|
||||
},
|
||||
"footerText": "Shared Editing",
|
||||
"css": [
|
||||
"./style.css"
|
||||
],
|
||||
"default": {
|
||||
"staticFiles": {
|
||||
"include": ["examples/"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"opts": {
|
||||
"destination": "./docs/",
|
||||
"encoding": "utf8",
|
||||
"private": false,
|
||||
"recurse": true,
|
||||
"template": "./node_modules/tui-jsdoc-template",
|
||||
"tutorials": "./examples"
|
||||
}
|
||||
}
|
||||
17
README.md
17
README.md
@@ -1,11 +1,13 @@
|
||||
|
||||
# 
|
||||
# 
|
||||
|
||||
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.v13.md) for the upcoming release :warning:
|
||||
|
||||
### Extensions
|
||||
Yjs only knows how to resolve conflicts on shared data. You have to choose a ..
|
||||
* *Connector* - a communication protocol that propagates changes to the clients
|
||||
@@ -66,7 +68,7 @@ missing modules.
|
||||
|
||||
### CDN
|
||||
```
|
||||
<script src="https://cdn.jsdelivr.net/npm/yjs@12/src/y.js"></script>
|
||||
<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>
|
||||
@@ -131,7 +133,7 @@ Here is a simple example of a shared textarea
|
||||
// name: 'xmpp'
|
||||
room: 'my-room' // clients connecting to the same room share data
|
||||
},
|
||||
sourceDir: '/bower_components', // location of the y-* modules (browser only)
|
||||
sourceDir: './bower_components', // location of the y-* modules (browser only)
|
||||
share: {
|
||||
textarea: 'Text' // y.share.textarea is of type y-text
|
||||
}
|
||||
@@ -248,7 +250,7 @@ The promise returns an instance of Y. We denote it with a lower case `y`.
|
||||
* y-websockets-client aways waits to sync with the server
|
||||
* y.connector.disconnect()
|
||||
* Force to disconnect this instance from the other instances
|
||||
* y.connector.reconnect()
|
||||
* y.connector.connect()
|
||||
* Try to reconnect to the other instances (needs to be supported by the
|
||||
connector)
|
||||
* Not supported by y-xmpp
|
||||
@@ -294,12 +296,5 @@ DEBUG_COLORS=0 DEBUG=y* node app.js > log
|
||||
localStorage.debug = 'y*'
|
||||
```
|
||||
|
||||
## Contribution
|
||||
I created this framework during my bachelor thesis at the chair of computer
|
||||
science 5 [(i5)](http://dbis.rwth-aachen.de/cms), RWTH University. Since
|
||||
December 2014 I'm working on Yjs as a part of my student worker job at the i5.
|
||||
|
||||
## License
|
||||
Yjs is licensed under the [MIT License](./LICENSE).
|
||||
|
||||
<yjs@dbis.rwth-aachen.de>
|
||||
|
||||
557
README.v13.md
Normal file
557
README.v13.md
Normal file
@@ -0,0 +1,557 @@
|
||||
# 
|
||||
> A CRDT framework with a powerful abstraction of shared data
|
||||
|
||||
Yjs is a [CRDT implementation](#Yjs-CRDT-Algorithm) that exposes its internal data structure as *shared types*. Shared types are common data types like `Map` or `Array` with superpowers: changes are automatically distributed to other peers and merged without merge conflicts.
|
||||
|
||||
Yjs is **network agnostic** (p2p!), supports many existing **rich text editors**, **offline editing**, **version snapshots**, **undo/redo** and **shared cursors**. It scales well with an unlimited number of users and is well suited for even large documents.
|
||||
|
||||
* Chat: [https://gitter.im/y-js/yjs](https://gitter.im/y-js/yjs)
|
||||
* Demos: [https://github.com/y-js/yjs-demos](https://github.com/y-js/yjs-demos)
|
||||
* Benchmarks: [https://github.com/dmonad/crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks)
|
||||
|
||||
# Table of Contents
|
||||
|
||||
* [Overview](#Overview)
|
||||
* [Bindings](#Bindings)
|
||||
* [Providers](#Providers)
|
||||
* [Getting Started](#Getting-Started)
|
||||
* [API](#API)
|
||||
* [Shared Types](#Shared-Types)
|
||||
* [Y.Doc](#Y.Doc)
|
||||
* [Document Updates](#Document-Updates)
|
||||
* [Relative Positions](#Relative-Positions)
|
||||
* [Miscellaneous](#Miscellaneous)
|
||||
* [Typescript Declarations](#Typescript-Declarations)
|
||||
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
|
||||
* [Evaluation](#Evaluation)
|
||||
* [Existing shared editing libraries](#Exisisting-Javascript-Libraries)
|
||||
* [CRDT Algorithms](#CRDT-Algorithms)
|
||||
* [Comparison of CRDT with OT](#Comparing-CRDT-with-OT)
|
||||
* [Comparison of CRDT Algorithms](#Comparing-CRDT-Algorithms)
|
||||
* [Comparison of Yjs with other Implementations](#Comparing-Yjs-with-other-Implementations)
|
||||
* [License and Author](#License-and-Author)
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
This repository contains a collection of shared types that can be observed for changes and manipulated concurrently. Network functionality and two-way-bindings are implemented in separate modules.
|
||||
|
||||
### Bindings
|
||||
|
||||
| Name | Cursors | Binding | Demo |
|
||||
|---|:-:|---|---|
|
||||
| [ProseMirror](https://prosemirror.net/) | ✔ | [y-prosemirror](http://github.com/y-js/y-prosemirror) | [link](https://yjs.website/tutorial-prosemirror.html) |
|
||||
| [Quill](https://quilljs.com/) | | [y-quill](http://github.com/y-js/y-quill) | [link](https://yjs.website/tutorial-quill.html) |
|
||||
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/y-js/y-codemirror) | [link](https://yjs.website/tutorial-codemirror.html) |
|
||||
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/y-js/y-ace) | [link]() |
|
||||
| [Monaco](https://microsoft.github.io/monaco-editor/) | | [y-monaco](http://github.com/y-js/y-monaco) | [link]() |
|
||||
|
||||
|
||||
### 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('http://localhost:1234', 'roomname')
|
||||
// sync all document updates through the websocket connection
|
||||
provider.sync('doc')
|
||||
|
||||
// Define a shared type on the document.
|
||||
const ytext = doc.getText('my resume')
|
||||
|
||||
// use data bindings to bind types to editors
|
||||
const binding = new TextareaBinding(ytext, document.querySelector('textarea'))
|
||||
```
|
||||
|
||||
**Example: Observe types**
|
||||
|
||||
```js
|
||||
const yarray = doc.getArray('my-array')
|
||||
yarray.observe(event => {
|
||||
console.log('yarray was modified')
|
||||
})
|
||||
// every time a local or remote client modifies yarray, the observer is called
|
||||
yarray.insert(0, ['val']) // => "yarray was modified"
|
||||
```
|
||||
|
||||
**Example: Nest types**
|
||||
|
||||
Remember, shared types are just plain old data types. The only limitation is that a shared type must exist only once in the shared document.
|
||||
|
||||
```js
|
||||
const ymap = doc.getMap('map')
|
||||
const foodArray = new Y.Array()
|
||||
foodArray.insert(0, ['apple', 'banana'])
|
||||
ymap.set('food', foodArray)
|
||||
ymap.get('food') === foodArray // => true
|
||||
ymap.set('fruit', foodArray) // => Error! foodArray is already defined on the shared document
|
||||
```
|
||||
|
||||
Now you understand how types are defined on a shared document. Next you can jump to the [demo repository](https://github.com/y-js/yjs-demos) or continue reading the API docs.
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
```
|
||||
|
||||
### Shared Types
|
||||
|
||||
<details>
|
||||
<summary><b>Y.Array</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A shareable Array-like type that supports efficient insert/delete of elements at any position. Internally it uses a linked list of Arrays that is split when necessary.
|
||||
</p>
|
||||
<pre>const yarray = new Y.Array()</pre>
|
||||
<dl>
|
||||
<b><code>insert(index:number, content:Array<object|string|number|Uint8Array|Y.Type>)</code></b>
|
||||
<dd>
|
||||
Insert content at <var>index</var>. Note that content is an array of elements. I.e. <code>array.insert(0, [1]</code> splices the list and inserts 1 at position 0.
|
||||
</dd>
|
||||
<b><code>push(Array<Object|Array|string|number|Uint8Array|Y.Type>)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>delete(index:number, length:number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>get(index:number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>length:number</code></b>
|
||||
<dd></dd>
|
||||
<b><code>map(function(T, number, YArray):M):Array<M></code></b>
|
||||
<dd></dd>
|
||||
<b><code>toArray():Array<Object|Array|string|number|Uint8Array|Y.Type></code></b>
|
||||
<dd>Copies the content of this YArray to a new Array.</dd>
|
||||
<b><code>toJSON():Array<Object|Array|string|number></code></b>
|
||||
<dd>Copies the content of this YArray to a new Array. It transforms all child types to JSON using their <code>toJSON</code> method.</dd>
|
||||
<b><code>[Symbol.Iterator]</code></b>
|
||||
<dd>
|
||||
Returns an YArray Iterator that contains the values for each index in the array.
|
||||
<pre>for (let value of yarray) { .. }</pre>
|
||||
</dd>
|
||||
<b><code>observe(function(YArrayEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
|
||||
</dd>
|
||||
<b><code>unobserve(function(YArrayEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observe</code> event listener from this type.
|
||||
</dd>
|
||||
<b><code>observeDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
|
||||
</dd>
|
||||
<b><code>unobserveDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observeDeep</code> event listener from this type.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Y.Map</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A shareable Map type.
|
||||
</p>
|
||||
<pre><code>const ymap = new Y.Map()</code></pre>
|
||||
<dl>
|
||||
<b><code>get(key:string):object|string|number|Uint8Array|Y.Type</code></b>
|
||||
<dd></dd>
|
||||
<b><code>set(key:string, value:object|string|number|Uint8Array|Y.Type)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>delete(key:string)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>has(key:string):boolean</code></b>
|
||||
<dd></dd>
|
||||
<b><code>get(index:number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>toJSON():Object<string, Object|Array|string|number></code></b>
|
||||
<dd>Copies the <code>[key,value]</code> pairs of this YMap to a new Object. It transforms all child types to JSON using their <code>toJSON</code> method.</dd>
|
||||
<b><code>[Symbol.Iterator]</code></b>
|
||||
<dd>
|
||||
Returns an Iterator of <code>[key, value]</code> pairs.
|
||||
<pre>for (let [key, value] of ymap) { .. }</pre>
|
||||
</dd>
|
||||
<b><code>entries()</code></b>
|
||||
<dd>
|
||||
Returns an Iterator of <code>[key, value]</code> pairs.
|
||||
</dd>
|
||||
<b><code>values()</code></b>
|
||||
<dd>
|
||||
Returns an Iterator of all values.
|
||||
</dd>
|
||||
<b><code>keys()</code></b>
|
||||
<dd>
|
||||
Returns an Iterator of all keys.
|
||||
</dd>
|
||||
<b><code>observe(function(YMapEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
|
||||
</dd>
|
||||
<b><code>unobserve(function(YMapEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observe</code> event listener from this type.
|
||||
</dd>
|
||||
<b><code>observeDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
|
||||
</dd>
|
||||
<b><code>unobserveDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observeDeep</code> event listener from this type.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Y.Text</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A shareable type that is optimized for shared editing on text. It allows to assign properties to ranges in the text. This makes it possible to implement rich-text bindings to this type.
|
||||
</p>
|
||||
<p>
|
||||
This type can also be transformed to the <a href="https://quilljs.com/docs/delta">delta format</a>. Similarly the YTextEvents compute changes as deltas.
|
||||
</p>
|
||||
<pre>const ytext = new Y.Text()</pre>
|
||||
<dl>
|
||||
<b><code>insert(index:number, content:string, [formattingAttributes:Object<string,string>])</code></b>
|
||||
<dd>
|
||||
Insert a string at <var>index</var> and assign formatting attributes to it.
|
||||
<pre>ytext.insert(0, 'bold text', { bold: true })</pre>
|
||||
</dd>
|
||||
<b><code>delete(index:number, length:number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>format(index:number, length:number, formattingAttributes:Object<string,string>)</code></b>
|
||||
<dd>Assign formatting attributes to a range in the text</dd>
|
||||
<b><code>applyDelta(delta)</code></b>
|
||||
<dd>See <a href="https://quilljs.com/docs/delta/">Quill Delta</a></dd>
|
||||
<b><code>length:number</code></b>
|
||||
<dd></dd>
|
||||
<b><code>toString():string</code></b>
|
||||
<dd>Transforms this type, without formatting options, into a string.</dd>
|
||||
<b><code>toJSON():string</code></b>
|
||||
<dd>See <code>toString</code></dd>
|
||||
<b><code>toDelta():Delta</code></b>
|
||||
<dd>Transforms this type to a <a href="https://quilljs.com/docs/delta/">Quill Delta</a></dd>
|
||||
<b><code>observe(function(YTextEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
|
||||
</dd>
|
||||
<b><code>unobserve(function(YTextEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observe</code> event listener from this type.
|
||||
</dd>
|
||||
<b><code>observeDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
|
||||
</dd>
|
||||
<b><code>unobserveDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observeDeep</code> event listener from this type.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>YXmlFragment</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A container that holds an Array of Y.XmlElements.
|
||||
</p>
|
||||
<pre><code>const yxml = new Y.XmlFragment()</code></pre>
|
||||
<dl>
|
||||
<b><code>insert(index:number, content:Array<Y.XmlElement|Y.XmlText>)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>delete(index:number, length:number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>get(index:number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>length:number</code></b>
|
||||
<dd></dd>
|
||||
<b><code>toArray():Array<Y.XmlElement|Y.XmlText></code></b>
|
||||
<dd>Copies the children to a new Array.</dd>
|
||||
<b><code>toDOM():DocumentFragment</code></b>
|
||||
<dd>Transforms this type and all children to new DOM elements.</dd>
|
||||
<b><code>toString():string</code></b>
|
||||
<dd>Get the XML serialization of all descendants.</dd>
|
||||
<b><code>toJSON():string</code></b>
|
||||
<dd>See <code>toString</code>.</dd>
|
||||
<b><code>observe(function(YXmlEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
|
||||
</dd>
|
||||
<b><code>unobserve(function(YXmlEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observe</code> event listener from this type.
|
||||
</dd>
|
||||
<b><code>observeDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
|
||||
</dd>
|
||||
<b><code>unobserveDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observeDeep</code> event listener from this type.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Y.XmlElement</b></summary>
|
||||
<br>
|
||||
<p>
|
||||
A shareable type that represents an XML Element. It has a <code>nodeName</code>, attributes, and a list of children. But it makes no effort to validate its content and be actually XML compliant.
|
||||
</p>
|
||||
<pre><code>const yxml = new Y.XmlElement()</code></pre>
|
||||
<dl>
|
||||
<b><code>insert(index:number, content:Array<Y.XmlElement|Y.XmlText>)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>delete(index:number, length:number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>get(index:number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>length:number</code></b>
|
||||
<dd></dd>
|
||||
<b><code>setAttribute(attributeName:string, attributeValue:string)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>removeAttribute(attributeName:string)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>getAttribute(attributeName:string):string</code></b>
|
||||
<dd></dd>
|
||||
<b><code>getAttributes(attributeName:string):Object<string,string></code></b>
|
||||
<dd></dd>
|
||||
<b><code>toArray():Array<Y.XmlElement|Y.XmlText></code></b>
|
||||
<dd>Copies the children to a new Array.</dd>
|
||||
<b><code>toDOM():Element</code></b>
|
||||
<dd>Transforms this type and all children to a new DOM element.</dd>
|
||||
<b><code>toString():string</code></b>
|
||||
<dd>Get the XML serialization of all descendants.</dd>
|
||||
<b><code>toJSON():string</code></b>
|
||||
<dd>See <code>toString</code>.</dd>
|
||||
<b><code>observe(function(YXmlEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns.
|
||||
</dd>
|
||||
<b><code>unobserve(function(YXmlEvent, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observe</code> event listener from this type.
|
||||
</dd>
|
||||
<b><code>observeDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Adds an event listener to this type that will be called synchronously every time this type or any of its children is modified. In the case this type is modified in the event listener, the event listener will be called again after the current event listener returns. The event listener receives all Events created by itself or any of its children.
|
||||
</dd>
|
||||
<b><code>unobserveDeep(function(Array<YEvent>, Transaction):void)</code></b>
|
||||
<dd>
|
||||
Removes an <code>observeDeep</code> event listener from this type.
|
||||
</dd>
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
### Y.Doc
|
||||
|
||||
```js
|
||||
const doc = new Y.Doc()
|
||||
```
|
||||
|
||||
<dl>
|
||||
<b><code>clientID</code></b>
|
||||
<dd>A unique id that identifies this client. (readonly)</dd>
|
||||
<b><code>transact(function(Transaction):void [, origin:any])</code></b>
|
||||
<dd>Every change on the shared document happens in a transaction. Observer calls and the <code>update</code> event are called after each transaction. You should <i>bundle</i> changes into a single transaction to reduce the amount of event calls. I.e. <code>doc.transact(() => { yarray.insert(..); ymap.set(..) })</code> triggers a single change event. <br>You can specify an optional <code>origin</code> parameter that is stored on <code>transaction.origin</code> and <code>on('update', (update, origin) => ..)</code>.</dd>
|
||||
<b><code>get(string, Y.[TypeClass]):[Type]</code></b>
|
||||
<dd>Define a shared type.</dd>
|
||||
<b><code>getArray(string):Y.Array</code></b>
|
||||
<dd>Define a shared Y.Array type. Is equivalent to <code>y.get(string, Y.Array)</code>.</dd>
|
||||
<b><code>getMap(string):Y.Map</code></b>
|
||||
<dd>Define a shared Y.Map type. Is equivalent to <code>y.get(string, Y.Map)</code>.</dd>
|
||||
<b><code>getXmlFragment(string):Y.XmlFragment</code></b>
|
||||
<dd>Define a shared Y.XmlFragment type. Is equivalent to <code>y.get(string, Y.XmlFragment)</code>.</dd>
|
||||
<b><code>on(string, function)</code></b>
|
||||
<dd>Register an event listener on the shared type</dd>
|
||||
<b><code>off(string, function)</code></b>
|
||||
<dd>Unregister an event listener from the shared type</dd>
|
||||
</dl>
|
||||
|
||||
|
||||
#### Y.Doc Events
|
||||
<dl>
|
||||
<b><code>on('update', function(updateMessage:Uint8Array, origin:any, Y.Doc):void)</code></b>
|
||||
<dd>Listen to document updates. Document updates must be transmitted to all other peers. You can apply document updates in any order and multiple times.</dd>
|
||||
<b><code>on('beforeTransaction', function(Y.Transaction, Y.Doc):void)</code></b>
|
||||
<dd>Emitted before each transaction.</dd>
|
||||
<b><code>on('afterTransaction', function(Y.Transaction, Y.Doc):void)</code></b>
|
||||
<dd>Emitted after each transaction.</dd>
|
||||
</dl>
|
||||
|
||||
### Document Updates
|
||||
|
||||
Changes on the shared document are encoded into *document updates*. Document updates are *commutative* and *idempotent*. This means that they can be applied in any order and multiple times.
|
||||
|
||||
**Example: Listen to update events and apply them on remote client**
|
||||
```js
|
||||
const doc1 = new Y.Doc()
|
||||
const doc2 = new Y.Doc()
|
||||
|
||||
doc1.on('update', update => {
|
||||
Y.applyUpdate(doc2, update)
|
||||
})
|
||||
|
||||
doc2.on('update', update => {
|
||||
Y.applyUpdate(doc1, update)
|
||||
})
|
||||
|
||||
// All changes are also applied to the other document
|
||||
doc1.getArray('myarray').insert(0, ['Hello doc2, you got this?'])
|
||||
doc2.getArray('myarray').get(0) // => 'Hello doc2, you got this?'
|
||||
```
|
||||
|
||||
Yjs internally maintains a [state vector](#State-Vector) that denotes the next expected clock from each client. In a different interpretation it holds the number of structs created by each client. When two clients sync, you can either exchange the complete document structure or only the differences by sending the state vector to compute the differences.
|
||||
|
||||
**Example: Sync two clients by exchanging the complete document structure**
|
||||
|
||||
```js
|
||||
const state1 = Y.encodeStateAsUpdate(ydoc1)
|
||||
const state2 = Y.encodeStateAsUpdate(ydoc2)
|
||||
Y.applyUpdate(ydoc1, state2)
|
||||
Y.applyUpdate(ydoc2, state1)
|
||||
```
|
||||
|
||||
**Example: Sync two clients by computing the differences**
|
||||
|
||||
This example shows how to sync two clients with the minimal amount of exchanged data by computing only the differences using the state vector of the remote client. Syncing clients using the state vector requires another roundtrip, but can safe a lot of bandwidth.
|
||||
|
||||
```js
|
||||
const stateVector1 = Y.encodeStateVector(ydoc1)
|
||||
const stateVector2 = Y.encodeStateVector(ydoc2)
|
||||
const diff1 = Y.encodeStateAsUpdate(ydoc1, stateVector2)
|
||||
const diff2 = Y.encodeStateAsUpdate(ydoc2, stateVector1)
|
||||
Y.applyUpdate(ydoc1, diff2)
|
||||
Y.applyUpdate(ydoc2, diff1)
|
||||
```
|
||||
|
||||
<dl>
|
||||
<b><code>Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any])</code></b>
|
||||
<dd>Apply a document update on the shared document. Optionally you can specify <code>transactionOrigin</code> that will be stored on <code>transaction.origin</code> and <code>ydoc.on('update', (update, origin) => ..)</code>.</dd>
|
||||
<b><code>Y.encodeStateAsUpdate(Y.Doc, [encodedTargetStateVector:Uint8Array]):Uint8Array</code></b>
|
||||
<dd>Encode the document state as a single update message that can be applied on the remote document. Optionally specify the target state vector to only write the differences to the update message.</dd>
|
||||
<b><code>Y.encodeStateVector(Y.Doc):Uint8Array</code></b>
|
||||
<dd>Computes the state vector and encodes it into an Uint8Array.</dd>
|
||||
</dl>
|
||||
|
||||
### Relative Positions
|
||||
> This API is not stable yet
|
||||
|
||||
This feature is intended for managing selections / cursors. When working with other users that manipulate the shared document, you can't trust that an index position (an integer) will stay at the intended location. A *relative position* is fixated to an element in the shared document and is not affected by remote changes. I.e. given the document `"a|c"`, the relative position is attached to `c`. When a remote user modifies the document by inserting a character before the cursor, the cursor will stay attached to the character `c`. `insert(1, 'x')("a|c") = "ax|c"`. When the *relative position* is set to the end of the document, it will stay attached to the end of the document.
|
||||
|
||||
**Example: Transform to RelativePosition and back**
|
||||
```js
|
||||
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
|
||||
const pos = Y.createAbsolutePositionFromRelativePosition(relPos, doc)
|
||||
pos.type === ytext // => true
|
||||
pos.index === 2 // => true
|
||||
```
|
||||
|
||||
**Example: Send relative position to remote client (json)**
|
||||
```js
|
||||
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
|
||||
const encodedRelPos = JSON.stringify(relPos)
|
||||
// send encodedRelPos to remote client..
|
||||
const parsedRelPos = JSON.parse(encodedRelPos)
|
||||
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
|
||||
pos.type === remoteytext // => true
|
||||
pos.index === 2 // => true
|
||||
```
|
||||
|
||||
**Example: Send relative position to remote client (Uint8Array)**
|
||||
```js
|
||||
const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2)
|
||||
const encodedRelPos = Y.encodeRelativePosition(relPos)
|
||||
// send encodedRelPos to remote client..
|
||||
const parsedRelPos = Y.decodeRelativePosition(encodedRelPos)
|
||||
const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc)
|
||||
pos.type === remoteytext // => true
|
||||
pos.index === 2 // => true
|
||||
```
|
||||
|
||||
<dl>
|
||||
<b><code>Y.createRelativePositionFromTypeIndex(Uint8Array|Y.Type, number)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc)</code></b>
|
||||
<dd></dd>
|
||||
<b><code>Y.encodeRelativePosition(RelativePosition):Uint8Array</code></b>
|
||||
<dd></dd>
|
||||
<b><code>Y.decodeRelativePosition(Uint8Array):RelativePosition</code></b>
|
||||
<dd></dd>
|
||||
</dl>
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
### Typescript Declarations
|
||||
|
||||
Yjs has type descriptions. But until [this ticket](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, this is how you can make use of Yjs type declarations.
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
},
|
||||
"maxNodeModuleJsDepth": 5
|
||||
}
|
||||
```
|
||||
|
||||
## Yjs CRDT Algorithm
|
||||
|
||||
*Conflict-free replicated data types* (CRDT) for collaborative editing are an alternative approach to *operational transformation* (OT). A very simple differenciation between the two approaches is that OT attempts to transform index positions to ensure convergence (all clients end up with the same content), while CRDTs use mathematical models that usually do not involve index transformations, like linked lists. OT is currently the de-facto standard for shared editing on text. OT approaches that support shared editing without a central source of truth (a central server) require too much bookkeeping to be viable in practice. CRDTs are better suited for distributed systems, provide additional guarantees that the document can be synced with remote clients, and do not require a central source of truth.
|
||||
|
||||
Yjs implements a modified version of the algorithm described in [this paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types). I will eventually publish a paper that describes why this approach works so well in practice. Note: Since operations make up the document structure, we prefer the term *struct* now.
|
||||
|
||||
CRDTs suitable for shared text editing suffer from the fact that they only grow in size. There are CRDTs that do not grow in size, but they do not have the characteristics that are benificial for shared text editing (like intention preservation). Yjs implements many improvements to the original algorithm that diminish the trade-off that the document only grows in size. We can't garbage collect deleted structs (tombstones) while ensuring a unique order of the structs. But we can 1. merge preceeding structs into a single struct to reduce the amount of meta information, 2. we can delete content from the struct if it is deleted, and 3. we can garbage collect tombstones if we don't care about the order of the structs anymore (e.g. if the parent was deleted).
|
||||
|
||||
**Examples:**
|
||||
1. If a user inserts elements in sequence, the struct will be merged into a single struct. E.g. `array.insert(0, ['a']), array.insert(0, ['b']);` is first represented as two structs (`[{id: {client, clock: 0}, content: 'a'}, {id: {client, clock: 1}, content: 'b'}`) and then merged into a single struct: `[{id: {client, clock: 0}, content: 'ab'}]`.
|
||||
2. When a struct that contains content (e.g. `ItemString`) is deleted, the struct will be replaced with an `ItemDeleted` that does not contain content anymore.
|
||||
3. When a type is deleted, all child elements are transformed to `GC` structs. A `GC` struct only denotes the existence of a struct and that it is deleted. `GC` structs can always be merged with other `GC` structs if the id's are adjacent.
|
||||
|
||||
Especially when working on structured content (e.g. shared editing on ProseMirror), these improvements yield very good results when [benchmarking](https://github.com/dmonad/crdt-benchmarks) random document edits. In practice they show even better results, because users usually edit text in sequence, resulting in structs that can easily be merged. The benchmarks show that even in the worst case scenario that a user edits text from right to left, Yjs achieves good performance even for huge documents.
|
||||
|
||||
#### State Vector
|
||||
Yjs has the ability to exchange only the differences when syncing two clients. We use lamport timestamps to identify structs and to track in which order a client created them. Each struct has an `struct.id = { client: number, clock: number}` that uniquely identifies a struct. We define the next expected `clock` by each client as the *state vector*. This data structure is similar to the [version vectors](https://en.wikipedia.org/wiki/Version_vector) data structure. But we use state vectors only to describe the state of the local document, so we can compute the missing struct of the remote client. We do not use it to track causality.
|
||||
|
||||
## License and Author
|
||||
|
||||
Yjs and all related projects are [**MIT licensed**](./LICENSE).
|
||||
|
||||
Yjs is based on my research as a student at the [RWTH i5](http://dbis.rwth-aachen.de/). Now I am working on Yjs in my spare time.
|
||||
|
||||
Fund this project by donating on [Patreon](https://www.patreon.com/dmonad) or hiring [me](https://github.com/dmonad) for professional support.
|
||||
1
examples/.gitignore
vendored
Normal file
1
examples/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
@@ -1,33 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style type="text/css" media="screen">
|
||||
#aceContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
.inserted {
|
||||
position:absolute;
|
||||
z-index:20;
|
||||
background-color: #FFC107;
|
||||
}
|
||||
.deleted {
|
||||
position:absolute;
|
||||
z-index:20;
|
||||
background-color: #FFC107;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="aceContainer"></div>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../bower_components/ace-builds/src/ace.js"></script>
|
||||
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,17 +0,0 @@
|
||||
/* global Y, ace */
|
||||
|
||||
let y = new Y('ace-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yAce = y
|
||||
|
||||
// bind the textarea to a shared text element
|
||||
var editor = ace.edit('aceContainer')
|
||||
editor.setTheme('ace/theme/chrome')
|
||||
editor.getSession().setMode('ace/mode/javascript')
|
||||
|
||||
y.define('ace', Y.Text).bindAce(editor)
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "yjs-examples",
|
||||
"version": "0.0.0",
|
||||
"homepage": "y-js.org",
|
||||
"authors": [
|
||||
"Kevin Jahns <kevin.jahns@rwth-aachen.de>"
|
||||
],
|
||||
"description": "Examples for Yjs",
|
||||
"license": "MIT",
|
||||
"ignore": [],
|
||||
"dependencies": {
|
||||
"quill": "^1.0.0-rc.2",
|
||||
"ace": "~1.2.3",
|
||||
"ace-builds": "~1.2.3",
|
||||
"jquery": "~2.2.2",
|
||||
"d3": "^3.5.16",
|
||||
"codemirror": "^5.25.0"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<style>
|
||||
#chat p span {
|
||||
color: blue;
|
||||
}
|
||||
</style>
|
||||
<div id="chat"></div>
|
||||
<form id="chatform">
|
||||
<input name="username" type="text" style="width:15%;">
|
||||
<input name="message" type="text" style="width:60%;">
|
||||
<input type="submit" value="Send">
|
||||
</form>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,65 +0,0 @@
|
||||
/* global Y */
|
||||
|
||||
let y = new Y('chat-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yChat = y
|
||||
|
||||
let chatprotocol = y.define('chatprotocol', Y.Array)
|
||||
|
||||
let chatcontainer = document.querySelector('#chat')
|
||||
|
||||
// This functions inserts a message at the specified position in the DOM
|
||||
function appendMessage (message, position) {
|
||||
var p = document.createElement('p')
|
||||
var uname = document.createElement('span')
|
||||
uname.appendChild(document.createTextNode(message.username + ': '))
|
||||
p.appendChild(uname)
|
||||
p.appendChild(document.createTextNode(message.message))
|
||||
chatcontainer.insertBefore(p, chatcontainer.children[position] || null)
|
||||
}
|
||||
|
||||
// This function makes sure that only 7 messages exist in the chat history.
|
||||
// The rest is deleted
|
||||
function cleanupChat () {
|
||||
if (chatprotocol.length > 7) {
|
||||
chatprotocol.delete(0, chatprotocol.length - 7)
|
||||
}
|
||||
}
|
||||
cleanupChat()
|
||||
|
||||
// Insert the initial content
|
||||
chatprotocol.toArray().forEach(appendMessage)
|
||||
|
||||
// whenever content changes, make sure to reflect the changes in the DOM
|
||||
chatprotocol.observe(function (event) {
|
||||
// concurrent insertions may result in a history > 7, so cleanup here
|
||||
cleanupChat()
|
||||
chatcontainer.innerHTML = ''
|
||||
chatprotocol.toArray().forEach(appendMessage)
|
||||
})
|
||||
document.querySelector('#chatform').onsubmit = function (event) {
|
||||
// the form is submitted
|
||||
var message = {
|
||||
username: this.querySelector('[name=username]').value,
|
||||
message: this.querySelector('[name=message]').value
|
||||
}
|
||||
if (message.username.length > 0 && message.message.length > 0) {
|
||||
if (chatprotocol.length > 6) {
|
||||
// If we are goint to insert the 8th element, make sure to delete first.
|
||||
chatprotocol.delete(0)
|
||||
}
|
||||
// Here we insert a message in the shared chat type.
|
||||
// This will call the observe function (see line 40)
|
||||
// and reflect the change in the DOM
|
||||
chatprotocol.push([message])
|
||||
this.querySelector('[name=message]').value = ''
|
||||
}
|
||||
// Do not send this form!
|
||||
event.preventDefault()
|
||||
return false
|
||||
}
|
||||
70
examples/codemirror.html
Normal file
70
examples/codemirror.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Yjs CodeMirror Example</title>
|
||||
<link rel=stylesheet href="https://codemirror.net/lib/codemirror.css">
|
||||
<style>
|
||||
#container {
|
||||
border: grey;
|
||||
border-style: solid;
|
||||
border-width: thin;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>This example shows how to bind a YText type to <a href="https://codemirror.net/">CodeMirror</a> editor.</p>
|
||||
<p>The content of this editor is shared with every client who visits this domain.</p>
|
||||
<div class="code-html">
|
||||
<style>
|
||||
.remote-caret {
|
||||
position: absolute;
|
||||
border-left: black;
|
||||
border-left-style: solid;
|
||||
border-left-width: 2px;
|
||||
height: 1em;
|
||||
}
|
||||
.remote-caret > div {
|
||||
position: relative;
|
||||
top: -1.05em;
|
||||
font-size: 13px;
|
||||
background-color: rgb(250, 129, 0);
|
||||
font-family: serif;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
line-height: normal;
|
||||
user-select: none;
|
||||
color: white;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
</style>
|
||||
<div id="container"></div>
|
||||
</div>
|
||||
<!-- The actual source file for the following code is found in ./codemirror.js. Run `npm run watch` to compile the files -->
|
||||
<script class="code-js" src="./build/codemirror.js" type="module">
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
import { CodeMirrorBinding } from 'yjs/bindings/codemirror.js'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import CodeMirror from 'codemirror'
|
||||
import 'codemirror/mode/javascript/javascript.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('codemirror')
|
||||
const ytext = ydocument.define('codemirror', Y.Text)
|
||||
|
||||
const editor = new CodeMirror(document.querySelector('#container'), {
|
||||
mode: 'javascript',
|
||||
lineNumbers: true
|
||||
})
|
||||
|
||||
const binding = new CodeMirrorBinding(ytext, editor)
|
||||
|
||||
window.codemirrorExample = {
|
||||
binding, editor, ytext, ydocument
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
22
examples/codemirror.js
Normal file
22
examples/codemirror.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { CodeMirrorBinding } from 'y-codemirror'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import CodeMirror from 'codemirror'
|
||||
import 'codemirror/mode/javascript/javascript.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('codemirror')
|
||||
const ytext = ydocument.getText('codemirror')
|
||||
|
||||
const editor = new CodeMirror(document.querySelector('#container'), {
|
||||
mode: 'javascript',
|
||||
lineNumbers: true
|
||||
})
|
||||
|
||||
const binding = new CodeMirrorBinding(ytext, editor)
|
||||
|
||||
window.codemirrorExample = {
|
||||
binding, editor, ytext, ydocument
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<div id="codeMirrorContainer"></div>
|
||||
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../bower_components/codemirror/lib/codemirror.js"></script>
|
||||
<script src="../bower_components/codemirror/mode/javascript/javascript.js"></script>
|
||||
<link rel="stylesheet" href="../bower_components/codemirror/lib/codemirror.css">
|
||||
<style>
|
||||
.CodeMirror {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
/* global Y, CodeMirror */
|
||||
|
||||
let y = new Y('codemirror-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yCodeMirror = y
|
||||
|
||||
var editor = CodeMirror(document.querySelector('#codeMirrorContainer'), {
|
||||
mode: 'javascript',
|
||||
lineNumbers: true
|
||||
})
|
||||
y.define('codemirror', Y.Text).bindCodeMirror(editor)
|
||||
37
examples/dom.html
Normal file
37
examples/dom.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Yjs Prosemirror Example</title>
|
||||
<link rel=stylesheet href="https://prosemirror.net/css/editor.css">
|
||||
<style>
|
||||
#content {
|
||||
min-height: 500px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>This example shows how to bind a YXmlFragment type to an arbitrary DOM element. We set the DOM element to contenteditable so it basically behaves like a very powerful rich-text editor.</p>
|
||||
<p>The content of this editor is shared with every client who visits this domain.</p>
|
||||
<hr>
|
||||
<div class="code-html">
|
||||
|
||||
<div id="content" contenteditable=""></div>
|
||||
</div>
|
||||
<!-- The actual source file for the following code is found in ./dom.js. Run `npm run watch` to compile the files -->
|
||||
<script class="code-js" src="./build/dom.js" type="module">
|
||||
import * as Y from 'yjs/index.js'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
import { DomBinding } from 'yjs/bindings/dom.js'
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('dom')
|
||||
const type = ydocument.define('xml', Y.XmlFragment)
|
||||
const binding = new DomBinding(type, document.querySelector('#content'), { scrollingElement: document.scrollingElement })
|
||||
|
||||
window.example = {
|
||||
provider, ydocument, type, binding
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
13
examples/dom.js
Normal file
13
examples/dom.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { DomBinding } from 'y-dom'
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('dom')
|
||||
const type = ydocument.define('xml', Y.XmlFragment)
|
||||
const binding = new DomBinding(type, document.querySelector('#content'), { scrollingElement: document.scrollingElement })
|
||||
|
||||
window.example = {
|
||||
provider, ydocument, type, binding
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<style>
|
||||
path {
|
||||
fill: none;
|
||||
stroke: blue;
|
||||
stroke-width: 1px;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
</style>
|
||||
<button type="button" id="clearDrawingCanvas">Clear Drawing</button>
|
||||
<svg id="drawingCanvas" viewbox="0 0 100 100" width="100%"></svg>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../bower_components/d3/d3.min.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,74 +0,0 @@
|
||||
/* globals Y, d3 */
|
||||
|
||||
let y = new Y('drawing-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yDrawing = y
|
||||
var drawing = y.define('drawing', Y.Array)
|
||||
var renderPath = d3.svg.line()
|
||||
.x(function (d) { return d[0] })
|
||||
.y(function (d) { return d[1] })
|
||||
.interpolate('basic')
|
||||
|
||||
var svg = d3.select('#drawingCanvas')
|
||||
.call(d3.behavior.drag()
|
||||
.on('dragstart', dragstart)
|
||||
.on('drag', drag)
|
||||
.on('dragend', dragend))
|
||||
|
||||
// create line from a shared array object and update the line when the array changes
|
||||
function drawLine (yarray) {
|
||||
var line = svg.append('path').datum(yarray.toArray())
|
||||
line.attr('d', renderPath)
|
||||
yarray.observe(function (event) {
|
||||
line.remove()
|
||||
line = svg.append('path').datum(yarray.toArray())
|
||||
line.attr('d', renderPath)
|
||||
})
|
||||
}
|
||||
// call drawLine every time an array is appended
|
||||
drawing.observe(function (event) {
|
||||
event.removedElements.forEach(function () {
|
||||
// if one is deleted, all will be deleted!!
|
||||
svg.selectAll('path').remove()
|
||||
})
|
||||
event.addedElements.forEach(function (path) {
|
||||
drawLine(path)
|
||||
})
|
||||
})
|
||||
// draw all existing content
|
||||
for (var i = 0; i < drawing.length; i++) {
|
||||
drawLine(drawing.get(i))
|
||||
}
|
||||
|
||||
// clear canvas on request
|
||||
document.querySelector('#clearDrawingCanvas').onclick = function () {
|
||||
drawing.delete(0, drawing.length)
|
||||
}
|
||||
|
||||
var sharedLine = null
|
||||
function dragstart () {
|
||||
drawing.insert(drawing.length, [Y.Array])
|
||||
sharedLine = drawing.get(drawing.length - 1)
|
||||
}
|
||||
|
||||
// After one dragged event is recognized, we ignore them for 33ms.
|
||||
var ignoreDrag = null
|
||||
function drag () {
|
||||
if (sharedLine != null && ignoreDrag == null) {
|
||||
ignoreDrag = window.setTimeout(function () {
|
||||
ignoreDrag = null
|
||||
}, 10)
|
||||
sharedLine.push([d3.mouse(this)])
|
||||
}
|
||||
}
|
||||
|
||||
function dragend () {
|
||||
sharedLine = null
|
||||
window.clearTimeout(ignoreDrag)
|
||||
ignoreDrag = null
|
||||
}
|
||||
9
examples/exampleConfig.js
Normal file
9
examples/exampleConfig.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
const isDeployed = location.hostname === 'yjs.website'
|
||||
|
||||
if (!isDeployed) {
|
||||
console.log('%cYjs: Start your local websocket server by running %c`npm run websocket-server`', 'color:blue', 'color: grey; font-weight: bold')
|
||||
}
|
||||
|
||||
export const serverAddress = isDeployed ? 'wss://api.yjs.website' : 'ws://localhost:1234'
|
||||
17
examples/examples.json
Normal file
17
examples/examples.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"codemirror": {
|
||||
"title": "CodeMirror Binding"
|
||||
},
|
||||
"prosemirror": {
|
||||
"title": "ProseMirror Binding"
|
||||
},
|
||||
"textarea": {
|
||||
"title": "Textarea Binding"
|
||||
},
|
||||
"quill": {
|
||||
"title": "Quill Binding"
|
||||
},
|
||||
"dom": {
|
||||
"title": "Dom Binding"
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
</head>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../bower_components/d3/d3.min.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
<style>
|
||||
magic-drawing .drawingCanvas path {
|
||||
fill: none;
|
||||
stroke: blue;
|
||||
stroke-width: 2px;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
magic-drawing .drawingCanvas {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
cursor: default;
|
||||
padding:1px;
|
||||
border:1px solid #021a40;
|
||||
}
|
||||
magic-drawing .clearDrawingButton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
magic-drawing {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body contenteditable="true">
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,134 +0,0 @@
|
||||
/* global Y, d3 */
|
||||
|
||||
const hooks = {
|
||||
'magic-drawing': {
|
||||
fillType: function (dom, type) {
|
||||
initDrawingBindings(type, dom)
|
||||
},
|
||||
createDom: function (type) {
|
||||
const dom = document.createElement('magic-drawing')
|
||||
initDrawingBindings(type, dom)
|
||||
return dom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = function () {
|
||||
window.domBinding = new Y.DomBinding(window.yXmlType, document.body, { hooks })
|
||||
}
|
||||
|
||||
window.addMagicDrawing = function addMagicDrawing () {
|
||||
let mt = document.createElement('magic-drawing')
|
||||
mt.setAttribute('data-yjs-hook', 'magic-drawing')
|
||||
document.body.append(mt)
|
||||
}
|
||||
|
||||
var renderPath = d3.svg.line()
|
||||
.x(function (d) { return d[0] })
|
||||
.y(function (d) { return d[1] })
|
||||
.interpolate('basic')
|
||||
|
||||
function initDrawingBindings (type, dom) {
|
||||
dom.contentEditable = 'false'
|
||||
dom.setAttribute('data-yjs-hook', 'magic-drawing')
|
||||
var drawing = type.get('drawing')
|
||||
if (drawing === undefined) {
|
||||
drawing = type.set('drawing', new Y.Array())
|
||||
}
|
||||
var canvas = dom.querySelector('.drawingCanvas')
|
||||
if (canvas == null) {
|
||||
canvas = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
canvas.setAttribute('class', 'drawingCanvas')
|
||||
canvas.setAttribute('viewbox', '0 0 100 100')
|
||||
dom.insertBefore(canvas, null)
|
||||
}
|
||||
var clearDrawingButton = dom.querySelector('.clearDrawingButton')
|
||||
if (clearDrawingButton == null) {
|
||||
clearDrawingButton = document.createElement('button')
|
||||
clearDrawingButton.setAttribute('type', 'button')
|
||||
clearDrawingButton.setAttribute('class', 'clearDrawingButton')
|
||||
clearDrawingButton.innerText = 'Clear Drawing'
|
||||
dom.insertBefore(clearDrawingButton, null)
|
||||
}
|
||||
var svg = d3.select(canvas)
|
||||
.call(d3.behavior.drag()
|
||||
.on('dragstart', dragstart)
|
||||
.on('drag', drag)
|
||||
.on('dragend', dragend))
|
||||
// create line from a shared array object and update the line when the array changes
|
||||
function drawLine (yarray, svg) {
|
||||
var line = svg.append('path').datum(yarray.toArray())
|
||||
line.attr('d', renderPath)
|
||||
yarray.observe(function (event) {
|
||||
line.remove()
|
||||
line = svg.append('path').datum(yarray.toArray())
|
||||
line.attr('d', renderPath)
|
||||
})
|
||||
}
|
||||
// call drawLine every time an array is appended
|
||||
drawing.observe(function (event) {
|
||||
event.removedElements.forEach(function () {
|
||||
// if one is deleted, all will be deleted!!
|
||||
svg.selectAll('path').remove()
|
||||
})
|
||||
event.addedElements.forEach(function (path) {
|
||||
drawLine(path, svg)
|
||||
})
|
||||
})
|
||||
// draw all existing content
|
||||
for (var i = 0; i < drawing.length; i++) {
|
||||
drawLine(drawing.get(i), svg)
|
||||
}
|
||||
|
||||
// clear canvas on request
|
||||
clearDrawingButton.onclick = function () {
|
||||
drawing.delete(0, drawing.length)
|
||||
}
|
||||
|
||||
var sharedLine = null
|
||||
function dragstart () {
|
||||
drawing.insert(drawing.length, [Y.Array])
|
||||
sharedLine = drawing.get(drawing.length - 1)
|
||||
}
|
||||
|
||||
// After one dragged event is recognized, we ignore them for 33ms.
|
||||
var ignoreDrag = null
|
||||
function drag () {
|
||||
if (sharedLine != null && ignoreDrag == null) {
|
||||
ignoreDrag = window.setTimeout(function () {
|
||||
ignoreDrag = null
|
||||
}, 10)
|
||||
sharedLine.push([d3.mouse(this)])
|
||||
}
|
||||
}
|
||||
|
||||
function dragend () {
|
||||
sharedLine = null
|
||||
window.clearTimeout(ignoreDrag)
|
||||
ignoreDrag = null
|
||||
}
|
||||
}
|
||||
|
||||
let y = new Y('html-editor-drawing-hook-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yXml = y
|
||||
window.yXmlType = y.define('xml', Y.XmlFragment)
|
||||
window.undoManager = new Y.utils.UndoManager(window.yXmlType, {
|
||||
captureTimeout: 500
|
||||
})
|
||||
|
||||
document.onkeydown = function interceptUndoRedo (e) {
|
||||
if (e.keyCode === 90 && e.metaKey) {
|
||||
if (!e.shiftKey) {
|
||||
window.undoManager.undo()
|
||||
} else {
|
||||
window.undoManager.redo()
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
</head>
|
||||
<script src="./index.mjs" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<label for="room">Room: </label>
|
||||
<input type="text" id="room" name="room">
|
||||
<div id="content" contenteditable style="position:absolute;top:35px;left:0;right:0;bottom:0;outline: 0px solid transparent;"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,77 +0,0 @@
|
||||
|
||||
import YWebsocketsConnector from '../../src/Connectors/WebsocketsConnector/WebsocketsConnector.mjs'
|
||||
import Y from '../../src/Y.mjs'
|
||||
import DomBinding from '../../src/Bindings/DomBinding/DomBinding.mjs'
|
||||
import UndoManager from '../../src/Util/UndoManager.mjs'
|
||||
import YXmlFragment from '../../src/Types/YXml/YXmlFragment.mjs'
|
||||
import YXmlText from '../../src/Types/YXml/YXmlText.mjs'
|
||||
import YXmlElement from '../../src/Types/YXml/YXmlElement.mjs'
|
||||
import YIndexdDBPersistence from '../../src/Persistences/IndexedDBPersistence.mjs'
|
||||
|
||||
const connector = new YWebsocketsConnector()
|
||||
const persistence = new YIndexdDBPersistence()
|
||||
|
||||
const roomInput = document.querySelector('#room')
|
||||
|
||||
let currentRoomName = null
|
||||
let y = null
|
||||
let domBinding = null
|
||||
|
||||
function setRoomName (roomName) {
|
||||
if (currentRoomName !== roomName) {
|
||||
console.log(`change room: "${roomName}"`)
|
||||
roomInput.value = roomName
|
||||
currentRoomName = roomName
|
||||
location.hash = '#' + roomName
|
||||
if (y !== null) {
|
||||
domBinding.destroy()
|
||||
}
|
||||
|
||||
const room = connector._rooms.get(roomName)
|
||||
if (room !== undefined) {
|
||||
y = room.y
|
||||
} else {
|
||||
y = new Y(roomName, null, null, { gc: true })
|
||||
persistence.connectY(roomName, y).then(() => {
|
||||
// connect after persisted content was applied to y
|
||||
// If we don't wait for persistence, the other peer will send all data, waisting
|
||||
// network bandwidth..
|
||||
connector.connectY(roomName, y)
|
||||
})
|
||||
window.y = y
|
||||
}
|
||||
|
||||
window.y = y
|
||||
window.yXmlType = y.define('xml', YXmlFragment)
|
||||
|
||||
domBinding = new DomBinding(window.yXmlType, document.querySelector('#content'), { scrollingElement: document.scrollingElement })
|
||||
}
|
||||
}
|
||||
window.setRoomName = setRoomName
|
||||
|
||||
window.createRooms = function (i = 0) {
|
||||
setInterval(function () {
|
||||
setRoomName(i + '')
|
||||
i++
|
||||
const nodes = []
|
||||
for (let j = 0; j < 100; j++) {
|
||||
const node = new YXmlElement('p')
|
||||
node.insert(0, [new YXmlText(`This is the ${i}th paragraph of room ${i}`)])
|
||||
nodes.push(node)
|
||||
}
|
||||
y.share.xml.insert(0, nodes)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
connector.syncPersistence(persistence)
|
||||
|
||||
window.connector = connector
|
||||
window.persistence = persistence
|
||||
|
||||
window.onload = function () {
|
||||
setRoomName((location.hash || '#default').slice(1))
|
||||
roomInput.addEventListener('input', e => {
|
||||
const roomName = e.target.value
|
||||
setRoomName(roomName)
|
||||
})
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<div id="codeMirrorContainer"></div>
|
||||
<script src="../bower_components/codemirror/lib/codemirror.js"></script>
|
||||
<script src="../bower_components/codemirror/mode/javascript/javascript.js"></script>
|
||||
<link rel="stylesheet" href="../bower_components/codemirror/lib/codemirror.css">
|
||||
<style>
|
||||
.CodeMirror {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src='../../../y-indexeddb/y-indexeddb.js'></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,19 +0,0 @@
|
||||
/* global Y, CodeMirror */
|
||||
|
||||
const persistence = new Y.IndexedDB()
|
||||
const connector = {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
room: 'codemirror-example'
|
||||
}
|
||||
}
|
||||
|
||||
const y = new Y('codemirror-example', connector, persistence)
|
||||
window.yCodeMirror = y
|
||||
|
||||
var editor = CodeMirror(document.querySelector('#codeMirrorContainer'), {
|
||||
mode: 'javascript',
|
||||
lineNumbers: true
|
||||
})
|
||||
|
||||
y.define('codemirror', Y.Text).bindCodeMirror(editor)
|
||||
@@ -1,55 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<style>
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-gap: 7px;
|
||||
}
|
||||
.one {
|
||||
grid-column: 1 ;
|
||||
}
|
||||
.two {
|
||||
grid-column: 2;
|
||||
}
|
||||
.three {
|
||||
grid-column: 3;
|
||||
}
|
||||
textarea {
|
||||
width: calc(100% - 10px)
|
||||
}
|
||||
.editor-container {
|
||||
background-color: #4caf50;
|
||||
padding: 4px 5px 10px 5px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.editor-container[disconnected] {
|
||||
background-color: red;
|
||||
}
|
||||
.disconnected-info {
|
||||
display: none;
|
||||
}
|
||||
.editor-container[disconnected] .disconnected-info {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
<div class="wrapper">
|
||||
<div id="container1" class="one editor-container">
|
||||
<h1>Server 1 <span class="disconnected-info">(disconnected)</span></h1>
|
||||
<textarea id="textarea1" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div id="container2" class="two editor-container">
|
||||
<h1>Server 2 <span class="disconnected-info">(disconnected)</span></h1>
|
||||
<textarea id="textarea2" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div id="container3" class="three editor-container">
|
||||
<h1>Server 3 <span class="disconnected-info">(disconnected)</span></h1>
|
||||
<textarea id="textarea3" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,38 +0,0 @@
|
||||
/* global Y */
|
||||
|
||||
function bindYjsInstance (y, suffix) {
|
||||
y.define('textarea', Y.Text).bind(document.getElementById('textarea' + suffix))
|
||||
y.connector.socket.on('connection', function () {
|
||||
document.getElementById('container' + suffix).removeAttribute('disconnected')
|
||||
})
|
||||
y.connector.socket.on('disconnect', function () {
|
||||
document.getElementById('container' + suffix).setAttribute('disconnected', true)
|
||||
})
|
||||
}
|
||||
|
||||
let y1 = new Y('infinite-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
window.y1 = y1
|
||||
bindYjsInstance(y1, '1')
|
||||
|
||||
let y2 = new Y('infinite-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
window.y2 = y2
|
||||
bindYjsInstance(y2, '2')
|
||||
|
||||
let y3 = new Y('infinite-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
window.y3 = y3
|
||||
bindYjsInstance(y1, '3')
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style type="text/css">
|
||||
.draggable {
|
||||
cursor: move;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg id="puzzle-example" width="100%" viewBox="0 0 800 800">
|
||||
<g>
|
||||
<path d="M 311.76636,154.23389 C 312.14136,171.85693 318.14087,184.97998 336.13843,184.23047 C 354.13647,183.48047 351.88647,180.48096 354.88599,178.98096 C 357.8855,177.48096 368.38452,170.35693 380.00806,169.98193 C 424.61841,168.54297 419.78296,223.6001 382.25757,223.6001 C 377.75806,223.6001 363.51001,219.10107 356.38599,211.97656 C 349.26196,204.85254 310.64185,207.10254 314.76636,236.34863 C 316.34888,247.5708 324.08374,267.90723 324.84595,286.23486 C 325.29321,296.99414 323.17603,307.00635 321.58911,315.6377 C 360.11353,305.4585 367.73462,304.30518 404.00513,312.83936 C 410.37915,314.33887 436.62573,310.21436 421.25269,290.3418 C 405.87964,270.46924 406.25464,248.34717 417.12817,240.84814 C 428.00171,233.34912 446.74976,228.84961 457.99829,234.09912 C 469.24683,239.34814 484.61987,255.84619 475.24585,271.59424 C 465.87231,287.34229 452.74878,290.7168 456.49829,303.84033 C 460.2478,316.96387 479.74536,320.33838 500.74292,321.83789 C 509.70142,322.47803 527.97192,323.28467 542.10864,320.12939 C 549.91821,318.38672 556.92212,315.89502 562.46753,313.56396 C 561.40796,277.80664 560.84888,245.71729 560.3606,241.97314 C 558.85278,230.41455 542.49536,217.28564 525.86499,223.2251 C 520.61548,225.1001 519.86548,231.84912 505.24243,232.59912 C 444.92798,235.69238 462.06958,143.26709 525.86499,180.48096 C 539.52759,188.45068 575.19409,190.7583 570.10913,156.85889 C 567.85962,141.86035 553.98608,102.86523 553.98608,102.86523 C 553.98608,102.86523 477.23755,111.82227 451.99878,91.991699 C 441.50024,83.74292 444.87476,69.494629 449.37427,61.245605 C 453.87378,52.996582 465.12231,46.622559 464.74731,36.123779 C 463.02563,-12.086426 392.96704,-10.902832 396.5061,36.873535 C 397.25562,46.997314 406.62964,52.621582 410.75415,60.495605 C 420.00757,78.161377 405.50024,96.073486 384.50757,99.490723 C 377.36206,100.65381 349.17505,102.65332 320.39429,102.23486 C 319.677,102.22461 318.95923,102.21143 318.24194,102.19775 C 315.08423,120.9751 311.55688,144.39697 311.76636,154.23389 z " style="fill:#f2c569;stroke:#000000" id="path2502"/>
|
||||
<path d="M 500.74292,321.83789 C 479.74536,320.33838 460.2478,316.96387 456.49829,303.84033 C 452.74878,290.7168 465.87231,287.34229 475.24585,271.59424 C 484.61987,255.84619 469.24683,239.34814 457.99829,234.09912 C 446.74976,228.84961 428.00171,233.34912 417.12817,240.84814 C 406.25464,248.34717 405.87964,270.46924 421.25269,290.3418 C 436.62573,310.21436 410.37915,314.33887 404.00513,312.83936 C 367.73462,304.30518 360.11353,305.4585 321.58911,315.6377 C 320.56372,321.21484 319.75854,326.2207 320.01538,330.46191 C 320.76538,342.83545 329.3894,385.95508 327.8894,392.7041 C 326.3894,399.45312 313.64136,418.20117 297.89331,407.32715 C 282.14526,396.45361 276.52075,393.4541 265.27222,394.5791 C 254.02368,395.70361 239.77563,402.07812 239.77563,419.32568 C 239.77563,436.57373 250.27417,449.69727 268.64673,447.82227 C 287.36353,445.9126 317.92163,423.11035 325.63989,452.69678 C 330.1394,469.94434 330.51392,487.19238 330.1394,498.44092 C 329.95825,503.87646 326.09985,518.06592 322.16089,531.28125 C 353.2854,532.73682 386.47095,531.26611 394.2561,529.93701 C 430.30933,523.78174 429.31909,496.09766 412.62866,477.44385 C 406.25464,470.31934 401.75513,455.32129 405.87964,444.82275 C 414.07056,423.97314 458.8064,422.17773 473.37134,438.82324 C 483.86987,450.82178 475.99585,477.44385 468.49683,482.69287 C 453.52222,493.17529 457.22485,516.83008 473.37134,528.06201 C 504.79126,549.91943 572.35913,535.56152 572.35913,535.56152 C 572.35913,535.56152 567.85962,498.06592 567.48462,471.81934 C 567.10962,445.57275 589.60669,450.07227 593.3562,450.07227 C 597.10571,450.07227 604.22974,455.32129 609.47925,459.4458 C 614.72876,463.57031 618.85327,469.94434 630.85181,470.69434 C 677.43726,473.60596 674.58813,420.7373 631.97632,413.32666 C 623.35229,411.82666 614.72876,416.32617 603.10522,424.57519 C 591.48169,432.82422 577.23315,425.32519 570.10913,417.45117 C 566.07788,412.99561 563.8479,360.16406 562.46753,313.56396 C 556.92212,315.89502 549.91821,318.38672 542.10864,320.12939 C 527.97192,323.28467 509.70142,322.47803 500.74292,321.83789 z " style="fill:#f3f3d6;stroke:#000000" id="path2504"/>
|
||||
<path d="M 240.52563,141.86035 C 257.60327,159.6499 243.94507,188.68799 214.65356,190.22949 C 185.09448,191.78516 164.66675,157.17822 190.28589,136.61621 C 200.49585,128.42139 198.05786,114.12158 179.78296,106.98975 C 154.4187,97.091553 90.54419,107.73975 90.54419,107.73975 C 90.54419,107.73975 100.88794,135.11328 101.41772,168.48242 C 101.79272,192.104 68.796875,189.47949 63.172607,186.85498 C 57.54834,184.23047 45.924805,173.73145 37.675781,173.73145 C -14.411865,173.73145 -10.013184,245.84375 39.925537,232.22412 C 48.174316,229.97461 56.42334,220.97559 68.796875,222.47559 C 81.17041,223.9751 87.544434,232.59912 87.544434,246.09766 C 87.544434,252.51709 87.0354,281.24268 86.340576,312.87012 C 119.15894,313.67676 160.60962,314.46582 170.03442,313.58887 C 186.15698,312.08936 195.90601,301.59033 188.40698,293.3418 C 180.90796,285.09277 156.16089,256.59619 179.03296,239.34814 C 201.90503,222.10059 235.65112,231.84912 239.77563,247.22217 C 243.90015,262.59521 240.52563,273.46924 234.90112,279.09326 C 229.27661,284.71777 210.52905,298.96582 221.40259,308.71484 C 232.27661,318.46338 263.77222,330.83691 302.39282,320.71338 C 309.58862,318.82715 315.92114,317.13525 321.58911,315.6377 C 323.17603,307.00635 325.29321,296.99414 324.84595,286.23486 C 324.08374,267.90723 316.34888,247.5708 314.76636,236.34863 C 310.64185,207.10254 349.26196,204.85254 356.38599,211.97656 C 363.51001,219.10107 377.75806,223.6001 382.25757,223.6001 C 419.78296,223.6001 424.61841,168.54297 380.00806,169.98193 C 368.38452,170.35693 357.8855,177.48096 354.88599,178.98096 C 351.88647,180.48096 354.13647,183.48047 336.13843,184.23047 C 318.14087,184.97998 312.14136,171.85693 311.76636,154.23389 C 311.55688,144.39697 315.08423,120.9751 318.24194,102.19775 C 290.37524,101.67725 262.46069,98.968262 254.39868,97.991211 C 233.38013,95.443359 217.17456,117.53662 240.52563,141.86035 z " style="fill:#bebcdb;stroke:#000000" id="path2506"/>
|
||||
<path d="M 325.63989,452.69678 C 317.92163,423.11035 287.36353,445.9126 268.64673,447.82227 C 250.27417,449.69727 239.77563,436.57373 239.77563,419.32568 C 239.77563,402.07812 254.02368,395.70361 265.27222,394.5791 C 276.52075,393.4541 282.14526,396.45361 297.89331,407.32715 C 313.64136,418.20117 326.3894,399.45313 327.8894,392.7041 C 329.3894,385.95508 320.76538,342.83545 320.01538,330.46191 C 319.75855,326.2207 320.56372,321.21484 321.58911,315.6377 C 315.92114,317.13525 309.58862,318.82715 302.39282,320.71338 C 263.77222,330.83691 232.27661,318.46338 221.40259,308.71484 C 210.52905,298.96582 229.27661,284.71777 234.90112,279.09326 C 240.52563,273.46924 243.90015,262.59521 239.77563,247.22217 C 235.65112,231.84912 201.90503,222.10059 179.03296,239.34814 C 156.16089,256.59619 180.90796,285.09277 188.40698,293.3418 C 195.90601,301.59033 186.15698,312.08936 170.03442,313.58887 C 160.60962,314.46582 119.15894,313.67676 86.340576,312.87012 C 85.573975,347.74561 84.581299,386.15088 83.794922,402.07812 C 82.295166,432.44922 109.29175,422.32568 115.66577,420.82568 C 122.04028,419.32568 126.16479,409.57715 143.03735,408.45215 C 185.9231,405.59326 186.09985,466.69629 144.16235,467.69482 C 128.41431,468.06982 113.79126,451.19678 108.16675,447.44727 C 102.54272,443.69775 87.919433,442.94775 83.794922,457.9458 C 82.01709,464.41113 78.118652,481.65137 78.098144,496.18994 C 78.071045,515.38037 82.295166,531.81201 82.295166,531.81201 C 82.295166,531.81201 105.54224,526.5625 149.41187,526.5625 C 193.28149,526.5625 199.65552,547.93506 194.78101,558.80859 C 189.90649,569.68213 181.28296,568.93213 179.40796,583.18066 C 172.7063,634.11133 253.34106,631.08203 249.14917,584.68018 C 247.96948,571.62354 237.16528,571.66699 232.27661,557.68359 C 222.17944,528.80273 244.64966,523.56299 257.39819,524.68799 C 263.59351,525.23437 290.95679,529.73389 320.75757,531.21582 C 321.22437,531.23877 321.69312,531.25928 322.16089,531.28125 C 326.09985,518.06592 329.95825,503.87646 330.1394,498.44092 C 330.51392,487.19238 330.1394,469.94434 325.63989,452.69678 z " style="fill:#d3ea9d;stroke:#000000" id="path2508"/>
|
||||
</g>
|
||||
</svg>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../bower_components/d3/d3.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,67 +0,0 @@
|
||||
/* global Y, d3 */
|
||||
|
||||
let y = new Y('jigsaw-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
let jigsaw = y.define('jigsaw', Y.Map)
|
||||
window.yJigsaw = y
|
||||
|
||||
var origin // mouse start position - translation of piece
|
||||
var drag = d3.behavior.drag()
|
||||
.on('dragstart', function (params) {
|
||||
// get the translation of the element
|
||||
var translation = d3
|
||||
.select(this)
|
||||
.attr('transform')
|
||||
.slice(10, -1)
|
||||
.split(',')
|
||||
.map(Number)
|
||||
// mouse coordinates
|
||||
var mouse = d3.mouse(this.parentNode)
|
||||
origin = {
|
||||
x: mouse[0] - translation[0],
|
||||
y: mouse[1] - translation[1]
|
||||
}
|
||||
})
|
||||
.on('drag', function () {
|
||||
var mouse = d3.mouse(this.parentNode)
|
||||
var x = mouse[0] - origin.x // =^= mouse - mouse at dragstart + translation at dragstart
|
||||
var y = mouse[1] - origin.y
|
||||
d3.select(this).attr('transform', 'translate(' + x + ',' + y + ')')
|
||||
})
|
||||
.on('dragend', function (piece, i) {
|
||||
// save the current translation of the puzzle piece
|
||||
var mouse = d3.mouse(this.parentNode)
|
||||
var x = mouse[0] - origin.x
|
||||
var y = mouse[1] - origin.y
|
||||
jigsaw.set(piece, {x: x, y: y})
|
||||
})
|
||||
|
||||
var data = ['piece1', 'piece2', 'piece3', 'piece4']
|
||||
var pieces = d3.select(document.querySelector('#puzzle-example')).selectAll('path').data(data)
|
||||
|
||||
pieces
|
||||
.classed('draggable', true)
|
||||
.attr('transform', function (piece) {
|
||||
var translation = piece.get('translation') || {x: 0, y: 0}
|
||||
return 'translate(' + translation.x + ',' + translation.y + ')'
|
||||
}).call(drag)
|
||||
|
||||
data.forEach(function (piece) {
|
||||
jigsaw.observe(function () {
|
||||
// whenever a property of a piece changes, update the translation of the pieces
|
||||
pieces
|
||||
.transition()
|
||||
.attr('transform', function (piece) {
|
||||
var translation = piece.get(piece)
|
||||
if (translation == null || typeof translation.x !== 'number' || typeof translation.y !== 'number') {
|
||||
translation = { x: 0, y: 0 }
|
||||
}
|
||||
return 'translate(' + translation.x + ',' + translation.y + ')'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<div id="monacoContainer"></div>
|
||||
<style>
|
||||
#monacoContainer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../node_modules/monaco-editor/min/vs/loader.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,22 +0,0 @@
|
||||
/* global Y, monaco */
|
||||
|
||||
require.config({ paths: { 'vs': '../node_modules/monaco-editor/min/vs' } })
|
||||
|
||||
let y = new Y('monaco-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
window.yMonaco = y
|
||||
|
||||
// Create Monaco editor
|
||||
var editor = monaco.editor.create(document.getElementById('monacoContainer'), {
|
||||
language: 'javascript'
|
||||
})
|
||||
|
||||
// Bind to y.share.monaco
|
||||
y.define('monaco', Y.Text).bindMonaco(editor)
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
</head>
|
||||
<script src="./index.mjs" type="module"></script>
|
||||
</head>
|
||||
<body contenteditable="true">
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,48 +0,0 @@
|
||||
|
||||
import IndexedDBPersistence from '../../src/Persistences/IndexeddbPersistence.mjs'
|
||||
import YWebsocketsConnector from '../../src/Connectors/WebsocketsConnector/WebsocketsConnector.mjs'
|
||||
import Y from '../../src/Y.mjs'
|
||||
import YXmlFragment from '../../src/Types/YXml/YXmlFragment.mjs'
|
||||
|
||||
const yCollection = new YCollection(new YWebsocketsConnector(), new IndexedDBPersistence())
|
||||
|
||||
const y = yCollection.getDocument('my-notes')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
persistence.addConnector(persistence)
|
||||
|
||||
const y = new Y()
|
||||
await persistence.persistY(y)
|
||||
|
||||
|
||||
connector.connectY('html-editor', y)
|
||||
persistence.connectY('html-editor', y)
|
||||
|
||||
|
||||
|
||||
|
||||
window.connector = connector
|
||||
|
||||
window.onload = function () {
|
||||
window.domBinding = new DomBinding(window.yXmlType, document.body, { scrollingElement: document.scrollingElement })
|
||||
}
|
||||
|
||||
window.y = y
|
||||
window.yXmlType = y.define('xml', YXmlFragment)
|
||||
window.undoManager = new UndoManager(window.yXmlType, {
|
||||
captureTimeout: 500
|
||||
})
|
||||
|
||||
document.onkeydown = function interceptUndoRedo (e) {
|
||||
if (e.keyCode === 90 && (e.metaKey || e.ctrlKey)) {
|
||||
if (!e.shiftKey) {
|
||||
window.undoManager.undo()
|
||||
} else {
|
||||
window.undoManager.redo()
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "examples",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"dist": "rollup -c",
|
||||
"watch": "rollup -cw"
|
||||
},
|
||||
"author": "Kevin Jahns",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"monaco-editor": "^0.8.3",
|
||||
"rollup": "^0.52.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"standard": "^10.0.2"
|
||||
},
|
||||
"standard": {
|
||||
"ignore": [
|
||||
"bower_components"
|
||||
]
|
||||
}
|
||||
}
|
||||
159
examples/prosemirror-history.js
Normal file
159
examples/prosemirror-history.js
Normal file
@@ -0,0 +1,159 @@
|
||||
|
||||
import { Plugin } from 'prosemirror-state'
|
||||
import crel from 'crel'
|
||||
import * as Y from '../src/index.js'
|
||||
import { prosemirrorPluginKey } from 'y-prosemirror'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as historyProtocol from 'y-protocols/history.js'
|
||||
|
||||
const niceColors = ['#3cb44b', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#008080', '#9a6324', '#800000', '#808000', '#000075', '#808080']
|
||||
|
||||
const createUserCSS = (userid, username, color = 'rgb(250, 129, 0)', color2 = 'rgba(250, 129, 0, .41)') => `
|
||||
[ychange_state][ychange_user="${userid}"]:hover::before {
|
||||
content: "${username}" !important;
|
||||
background-color: ${color} !important;
|
||||
}
|
||||
[ychange_state="added"][ychange_user="${userid}"] {
|
||||
background-color: ${color2} !important;
|
||||
}
|
||||
[ychange_state="removed"][ychange_user="${userid}"] {
|
||||
color: ${color} !important;
|
||||
}
|
||||
`
|
||||
|
||||
export const noteHistoryPlugin = new Plugin({
|
||||
state: {
|
||||
init (initargs, state) {
|
||||
return new NoteHistoryPlugin()
|
||||
},
|
||||
apply (tr, pluginState) {
|
||||
return pluginState
|
||||
}
|
||||
},
|
||||
view (editorView) {
|
||||
const hstate = noteHistoryPlugin.getState(editorView.state)
|
||||
hstate.init(editorView)
|
||||
return {
|
||||
destroy: hstate.destroy.bind(hstate)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const wrapper = crel('div', { style: 'display: flex;' })
|
||||
const historyContainer = crel('div', { style: 'align-self: baseline; flex-basis: 250px;', class: 'shared-history' })
|
||||
wrapper.insertBefore(historyContainer, null)
|
||||
const userStyleContainer = crel('style')
|
||||
wrapper.insertBefore(userStyleContainer, null)
|
||||
return { wrapper, historyContainer, userStyleContainer }
|
||||
}
|
||||
|
||||
class NoteHistoryPlugin {
|
||||
init (editorView) {
|
||||
this.editorView = editorView
|
||||
const { historyContainer, wrapper, userStyleContainer } = createWrapper()
|
||||
this.userStyleContainer = userStyleContainer
|
||||
this.wrapper = wrapper
|
||||
this.historyContainer = historyContainer
|
||||
const n = editorView.dom.parentNode.parentNode
|
||||
n.parentNode.replaceChild(this.wrapper, n)
|
||||
n.style['flex-grow'] = '1'
|
||||
wrapper.insertBefore(n, this.wrapper.firstChild)
|
||||
this.render()
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array)
|
||||
history.observe(this.render.bind(this))
|
||||
}
|
||||
destroy () {
|
||||
this.wrapper.parentNode.replaceChild(this.wrapper.firstChild, this.wrapper)
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array)
|
||||
history.unobserve(this.render)
|
||||
}
|
||||
render () {
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array).toArray()
|
||||
const fragment = document.createDocumentFragment()
|
||||
const snapshotBtn = crel('button', { type: 'button' }, ['snapshot'])
|
||||
fragment.insertBefore(snapshotBtn, null)
|
||||
let _prevSnap = null // empty
|
||||
snapshotBtn.addEventListener('click', () => {
|
||||
const awareness = y.getAwarenessInfo()
|
||||
const userMap = new Map()
|
||||
const aw = y.getLocalAwarenessInfo()
|
||||
userMap.set(y.userID, aw.name || 'unknown')
|
||||
awareness.forEach((a, userID) => {
|
||||
userMap.set(userID, a.name || 'Unknown')
|
||||
})
|
||||
this.snapshot(userMap)
|
||||
})
|
||||
history.forEach(buf => {
|
||||
const decoder = decoding.createDecoder(buf)
|
||||
const snapshot = historyProtocol.readHistorySnapshot(decoder)
|
||||
const date = new Date(decoding.readUint32(decoder) * 1000)
|
||||
const restoreBtn = crel('button', { type: 'button' }, ['restore'])
|
||||
const a = crel('a', [
|
||||
'• ' + date.toUTCString(), restoreBtn
|
||||
])
|
||||
const el = crel('div', [ a ])
|
||||
let prevSnapshot = _prevSnap // rebind to new variable
|
||||
restoreBtn.addEventListener('click', event => {
|
||||
if (prevSnapshot === null) {
|
||||
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
|
||||
}
|
||||
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot, restore: true }))
|
||||
event.stopPropagation()
|
||||
})
|
||||
a.addEventListener('click', () => {
|
||||
console.log('setting snapshot')
|
||||
if (prevSnapshot === null) {
|
||||
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
|
||||
}
|
||||
this.renderSnapshot(snapshot, prevSnapshot)
|
||||
})
|
||||
fragment.insertBefore(el, null)
|
||||
_prevSnap = snapshot
|
||||
})
|
||||
this.historyContainer.innerHTML = ''
|
||||
this.historyContainer.insertBefore(fragment, null)
|
||||
}
|
||||
renderSnapshot (snapshot, prevSnapshot) {
|
||||
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot }))
|
||||
/**
|
||||
* @type {Array<string|null>}
|
||||
*/
|
||||
let colors = niceColors.slice()
|
||||
let style = ''
|
||||
snapshot.userMap.forEach((name, userid) => {
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
const randInt = name.split('').map(s => s.charCodeAt(0)).reduce((a, b) => a + b)
|
||||
let color = null
|
||||
let i = 0
|
||||
for (; i < colors.length && color === null; i++) {
|
||||
color = colors[(randInt + i) % colors.length]
|
||||
}
|
||||
if (color === null) {
|
||||
colors = niceColors.slice()
|
||||
i = 0
|
||||
color = colors[randInt % colors.length]
|
||||
}
|
||||
colors[randInt % colors.length] = null
|
||||
style += createUserCSS(userid, name, color, color + '69')
|
||||
})
|
||||
this.userStyleContainer.innerHTML = style
|
||||
}
|
||||
/**
|
||||
* @param {Map<number, string>} [updatedUserMap] Maps from userid (yjs model) to account name (e.g. mail address)
|
||||
*/
|
||||
snapshot (updatedUserMap = new Map()) {
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array)
|
||||
const encoder = encoding.createEncoder()
|
||||
historyProtocol.writeHistorySnapshot(encoder, y, updatedUserMap)
|
||||
encoding.writeUint32(encoder, Math.floor(Date.now() / 1000))
|
||||
history.push([encoding.toUint8Array(encoder)])
|
||||
}
|
||||
}
|
||||
197
examples/prosemirror-schema.js
Normal file
197
examples/prosemirror-schema.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Schema } from 'prosemirror-model'
|
||||
|
||||
const brDOM = ['br']
|
||||
|
||||
const calcYchangeDomAttrs = (attrs, domAttrs = {}) => {
|
||||
domAttrs = Object.assign({}, domAttrs)
|
||||
if (attrs.ychange !== null) {
|
||||
domAttrs.ychange_user = attrs.ychange.user
|
||||
domAttrs.ychange_state = attrs.ychange.state
|
||||
}
|
||||
return domAttrs
|
||||
}
|
||||
|
||||
// :: Object
|
||||
// [Specs](#model.NodeSpec) for the nodes defined in this schema.
|
||||
export const nodes = {
|
||||
// :: NodeSpec The top level document node.
|
||||
doc: {
|
||||
content: 'block+'
|
||||
},
|
||||
|
||||
// :: NodeSpec A plain paragraph textblock. Represented in the DOM
|
||||
// as a `<p>` element.
|
||||
paragraph: {
|
||||
attrs: { ychange: { default: null } },
|
||||
content: 'inline*',
|
||||
group: 'block',
|
||||
parseDOM: [{ tag: 'p' }],
|
||||
toDOM (node) { return ['p', calcYchangeDomAttrs(node.attrs), 0] }
|
||||
},
|
||||
|
||||
// :: NodeSpec A blockquote (`<blockquote>`) wrapping one or more blocks.
|
||||
blockquote: {
|
||||
attrs: { ychange: { default: null } },
|
||||
content: 'block+',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
parseDOM: [{ tag: 'blockquote' }],
|
||||
toDOM (node) { return ['blockquote', calcYchangeDomAttrs(node.attrs), 0] }
|
||||
},
|
||||
|
||||
// :: NodeSpec A horizontal rule (`<hr>`).
|
||||
horizontal_rule: {
|
||||
attrs: { ychange: { default: null } },
|
||||
group: 'block',
|
||||
parseDOM: [{ tag: 'hr' }],
|
||||
toDOM (node) {
|
||||
return ['hr', calcYchangeDomAttrs(node.attrs)]
|
||||
}
|
||||
},
|
||||
|
||||
// :: NodeSpec A heading textblock, with a `level` attribute that
|
||||
// should hold the number 1 to 6. Parsed and serialized as `<h1>` to
|
||||
// `<h6>` elements.
|
||||
heading: {
|
||||
attrs: {
|
||||
level: { default: 1 },
|
||||
ychange: { default: null }
|
||||
},
|
||||
content: 'inline*',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
parseDOM: [{ tag: 'h1', attrs: { level: 1 } },
|
||||
{ tag: 'h2', attrs: { level: 2 } },
|
||||
{ tag: 'h3', attrs: { level: 3 } },
|
||||
{ tag: 'h4', attrs: { level: 4 } },
|
||||
{ tag: 'h5', attrs: { level: 5 } },
|
||||
{ tag: 'h6', attrs: { level: 6 } }],
|
||||
toDOM (node) { return ['h' + node.attrs.level, calcYchangeDomAttrs(node.attrs), 0] }
|
||||
},
|
||||
|
||||
// :: NodeSpec A code listing. Disallows marks or non-text inline
|
||||
// nodes by default. Represented as a `<pre>` element with a
|
||||
// `<code>` element inside of it.
|
||||
code_block: {
|
||||
attrs: { ychange: { default: null } },
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
group: 'block',
|
||||
code: true,
|
||||
defining: true,
|
||||
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }],
|
||||
toDOM (node) { return ['pre', calcYchangeDomAttrs(node.attrs), ['code', 0]] }
|
||||
},
|
||||
|
||||
// :: NodeSpec The text node.
|
||||
text: {
|
||||
group: 'inline'
|
||||
},
|
||||
|
||||
// :: NodeSpec An inline image (`<img>`) node. Supports `src`,
|
||||
// `alt`, and `href` attributes. The latter two default to the empty
|
||||
// string.
|
||||
image: {
|
||||
inline: true,
|
||||
attrs: {
|
||||
ychange: { default: null },
|
||||
src: {},
|
||||
alt: { default: null },
|
||||
title: { default: null }
|
||||
},
|
||||
group: 'inline',
|
||||
draggable: true,
|
||||
parseDOM: [{ tag: 'img[src]',
|
||||
getAttrs (dom) {
|
||||
return {
|
||||
src: dom.getAttribute('src'),
|
||||
title: dom.getAttribute('title'),
|
||||
alt: dom.getAttribute('alt')
|
||||
}
|
||||
} }],
|
||||
toDOM (node) {
|
||||
const domAttrs = {
|
||||
src: node.attrs.src,
|
||||
title: node.attrs.title,
|
||||
alt: node.attrs.alt
|
||||
}
|
||||
return ['img', calcYchangeDomAttrs(node.attrs, domAttrs)]
|
||||
}
|
||||
},
|
||||
|
||||
// :: NodeSpec A hard line break, represented in the DOM as `<br>`.
|
||||
hard_break: {
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
selectable: false,
|
||||
parseDOM: [{ tag: 'br' }],
|
||||
toDOM () { return brDOM }
|
||||
}
|
||||
}
|
||||
|
||||
const emDOM = ['em', 0]; const strongDOM = ['strong', 0]; const codeDOM = ['code', 0]
|
||||
|
||||
// :: Object [Specs](#model.MarkSpec) for the marks in the schema.
|
||||
export const marks = {
|
||||
// :: MarkSpec A link. Has `href` and `title` attributes. `title`
|
||||
// defaults to the empty string. Rendered and parsed as an `<a>`
|
||||
// element.
|
||||
link: {
|
||||
attrs: {
|
||||
href: {},
|
||||
title: { default: null }
|
||||
},
|
||||
inclusive: false,
|
||||
parseDOM: [{ tag: 'a[href]',
|
||||
getAttrs (dom) {
|
||||
return { href: dom.getAttribute('href'), title: dom.getAttribute('title') }
|
||||
} }],
|
||||
toDOM (node) { return ['a', node.attrs, 0] }
|
||||
},
|
||||
|
||||
// :: MarkSpec An emphasis mark. Rendered as an `<em>` element.
|
||||
// Has parse rules that also match `<i>` and `font-style: italic`.
|
||||
em: {
|
||||
parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }],
|
||||
toDOM () { return emDOM }
|
||||
},
|
||||
|
||||
// :: MarkSpec A strong mark. Rendered as `<strong>`, parse rules
|
||||
// also match `<b>` and `font-weight: bold`.
|
||||
strong: {
|
||||
parseDOM: [{ tag: 'strong' },
|
||||
// This works around a Google Docs misbehavior where
|
||||
// pasted content will be inexplicably wrapped in `<b>`
|
||||
// tags with a font-weight normal.
|
||||
{ tag: 'b', getAttrs: node => node.style.fontWeight !== 'normal' && null },
|
||||
{ style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null }],
|
||||
toDOM () { return strongDOM }
|
||||
},
|
||||
|
||||
// :: MarkSpec Code font mark. Represented as a `<code>` element.
|
||||
code: {
|
||||
parseDOM: [{ tag: 'code' }],
|
||||
toDOM () { return codeDOM }
|
||||
},
|
||||
ychange: {
|
||||
attrs: {
|
||||
user: { default: null },
|
||||
state: { default: null }
|
||||
},
|
||||
inclusive: false,
|
||||
parseDOM: [{ tag: 'ychange' }],
|
||||
toDOM (node) {
|
||||
return ['ychange', { ychange_user: node.attrs.user, ychange_state: node.attrs.state }, 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// :: Schema
|
||||
// This schema rougly corresponds to the document schema used by
|
||||
// [CommonMark](http://commonmark.org/), minus the list elements,
|
||||
// which are defined in the [`prosemirror-schema-list`](#schema-list)
|
||||
// module.
|
||||
//
|
||||
// To reuse elements from this schema, extend or read from its
|
||||
// `spec.nodes` and `spec.marks` [properties](#model.Schema.spec).
|
||||
export const schema = new Schema({ nodes, marks })
|
||||
330
examples/prosemirror.css
Normal file
330
examples/prosemirror.css
Normal file
@@ -0,0 +1,330 @@
|
||||
.ProseMirror {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ProseMirror li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::selection { background: transparent; }
|
||||
.ProseMirror-hideselection *::-moz-selection { background: transparent; }
|
||||
.ProseMirror-hideselection { caret-color: transparent; }
|
||||
|
||||
.ProseMirror-selectednode {
|
||||
outline: 2px solid #8cf;
|
||||
}
|
||||
|
||||
/* Make sure li selections wrap around markers */
|
||||
|
||||
li.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
li.ProseMirror-selectednode:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -32px;
|
||||
right: -2px; top: -2px; bottom: -2px;
|
||||
border: 2px solid #8cf;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ProseMirror-textblock-dropdown {
|
||||
min-width: 3em;
|
||||
}
|
||||
|
||||
.ProseMirror-menu {
|
||||
margin: 0 -4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ProseMirror-tooltip .ProseMirror-menu {
|
||||
width: -webkit-fit-content;
|
||||
width: fit-content;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
margin-right: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ProseMirror-menuseparator {
|
||||
border-right: 1px solid #ddd;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu {
|
||||
font-size: 90%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown {
|
||||
vertical-align: 1px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-wrap {
|
||||
padding: 1px 0 1px 4px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown:after {
|
||||
content: "";
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid currentColor;
|
||||
opacity: .6;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: calc(50% - 2px);
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu {
|
||||
position: absolute;
|
||||
background: white;
|
||||
color: #666;
|
||||
border: 1px solid #aaa;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-menu {
|
||||
z-index: 15;
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-item {
|
||||
cursor: pointer;
|
||||
padding: 2px 8px 2px 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-item:hover {
|
||||
background: #f2f2f2;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-wrap {
|
||||
position: relative;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-label:after {
|
||||
content: "";
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 4px solid currentColor;
|
||||
opacity: .6;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu {
|
||||
display: none;
|
||||
min-width: 4em;
|
||||
left: 100%;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
background: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
background: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled {
|
||||
opacity: .3;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
border-top-left-radius: inherit;
|
||||
border-top-right-radius: inherit;
|
||||
position: relative;
|
||||
min-height: 1em;
|
||||
color: #666;
|
||||
padding: 1px 6px;
|
||||
top: 0; left: 0; right: 0;
|
||||
border-bottom: 1px solid silver;
|
||||
background: white;
|
||||
z-index: 10;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
display: inline-block;
|
||||
line-height: .8;
|
||||
vertical-align: -2px; /* Compensate for padding */
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled.ProseMirror-icon {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ProseMirror-icon svg {
|
||||
fill: currentColor;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.ProseMirror-icon span {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid black;
|
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes ProseMirror-cursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
/* Add space around the hr to make clicking it easier */
|
||||
|
||||
.ProseMirror-example-setup-style hr {
|
||||
padding: 2px 10px;
|
||||
border: none;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.ProseMirror-example-setup-style hr:after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
background-color: silver;
|
||||
line-height: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror ul, .ProseMirror ol {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.ProseMirror blockquote {
|
||||
padding-left: 1em;
|
||||
border-left: 3px solid #eee;
|
||||
margin-left: 0; margin-right: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-example-setup-style img {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
background: white;
|
||||
padding: 5px 10px 5px 15px;
|
||||
border: 1px solid silver;
|
||||
position: fixed;
|
||||
border-radius: 3px;
|
||||
z-index: 11;
|
||||
box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
.ProseMirror-prompt h5 {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 100%;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt input[type="text"],
|
||||
.ProseMirror-prompt textarea {
|
||||
background: #eee;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt input[type="text"] {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-close {
|
||||
position: absolute;
|
||||
left: 2px; top: 1px;
|
||||
color: #666;
|
||||
border: none; background: transparent; padding: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-close:after {
|
||||
content: "✕";
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ProseMirror-invalid {
|
||||
background: #ffc;
|
||||
border: 1px solid #cc7;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
min-width: 10em;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
margin-top: 5px;
|
||||
display: none;
|
||||
}
|
||||
#editor, .editor {
|
||||
background: white;
|
||||
color: black;
|
||||
background-clip: padding-box;
|
||||
border-radius: 4px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
padding: 5px 0;
|
||||
margin-bottom: 23px;
|
||||
}
|
||||
|
||||
.ProseMirror p:first-child,
|
||||
.ProseMirror h1:first-child,
|
||||
.ProseMirror h2:first-child,
|
||||
.ProseMirror h3:first-child,
|
||||
.ProseMirror h4:first-child,
|
||||
.ProseMirror h5:first-child,
|
||||
.ProseMirror h6:first-child {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
padding: 4px 8px 4px 14px;
|
||||
line-height: 1.2;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror p { margin-bottom: 1em }
|
||||
|
||||
123
examples/prosemirror.html
Normal file
123
examples/prosemirror.html
Normal file
@@ -0,0 +1,123 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Yjs Prosemirror Example</title>
|
||||
<link rel=stylesheet href="./prosemirror.css">
|
||||
<style>
|
||||
placeholder {
|
||||
display: inline;
|
||||
border: 1px solid #ccc;
|
||||
color: #ccc;
|
||||
}
|
||||
placeholder:after {
|
||||
content: "☁";
|
||||
font-size: 200%;
|
||||
line-height: 0.1;
|
||||
font-weight: bold;
|
||||
}
|
||||
.ProseMirror img { max-width: 100px }
|
||||
/* this is a rough fix for the first cursor position when the first paragraph is empty */
|
||||
.ProseMirror > .ProseMirror-yjs-cursor:first-child {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.ProseMirror p:first-child, .ProseMirror h1:first-child, .ProseMirror h2:first-child, .ProseMirror h3:first-child, .ProseMirror h4:first-child, .ProseMirror h5:first-child, .ProseMirror h6:first-child {
|
||||
margin-top: 16px
|
||||
}
|
||||
.ProseMirror-yjs-cursor {
|
||||
position: absolute;
|
||||
border-left: black;
|
||||
border-left-style: solid;
|
||||
border-left-width: 2px;
|
||||
border-color: orange;
|
||||
height: 1em;
|
||||
word-break: normal;
|
||||
}
|
||||
.ProseMirror-yjs-cursor > div {
|
||||
position: relative;
|
||||
top: -1.05em;
|
||||
font-size: 13px;
|
||||
background-color: rgb(250, 129, 0);
|
||||
font-family: serif;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
line-height: normal;
|
||||
user-select: none;
|
||||
color: white;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
[ychange_state] {
|
||||
position: relative;
|
||||
}
|
||||
[ychange_state]:hover::before {
|
||||
content: attr(ychange_user);
|
||||
background-color: #fa8100;
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
right: 0;
|
||||
font-size: 12px;
|
||||
padding: 0 2px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
color: #fdfdfe;
|
||||
user-select: none;
|
||||
word-break: normal;
|
||||
}
|
||||
*[ychange_state='added'] {
|
||||
background-color: #fa810069;
|
||||
}
|
||||
ychange[ychange_state='removed'] {
|
||||
color: rgb(250, 129, 0);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
*:not(ychange)[ychange_state='removed'] {
|
||||
background-color: #ff9494c9;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
img[ychange_state='removed'] {
|
||||
padding: 2px;
|
||||
}
|
||||
.y-connect-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button type="button" class="y-connect-btn">Disconnect</button>
|
||||
<p>This example shows how to bind a YXmlFragment to a <a href="http://prosemirror.net">Prosemirror</a> editor using <a href="https://github.com/y-js/y-prosemirror">y-prosemirror</a>.</p>
|
||||
<p>The content of this editor is shared with every client that visits this domain.</p>
|
||||
<div class="code-html">
|
||||
|
||||
<div id="editor" style="margin-bottom: 23px"></div>
|
||||
<div style="display: none" id="content"></div>
|
||||
</div>
|
||||
<!-- The actual source file for the following code is found in ./prosemirror.js. Run `npm run watch` to compile the files -->
|
||||
<script class="code-js" src="./build/prosemirror.js" type="module">
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
import { prosemirrorPlugin, cursorPlugin } from 'yjs/bindings/prosemirror'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import { EditorState } from 'prosemirror-state'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { DOMParser } from 'prosemirror-model'
|
||||
import { schema } from 'prosemirror-schema-basic'
|
||||
import { exampleSetup } from 'prosemirror-example-setup'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('prosemirror')
|
||||
const type = ydocument.define('prosemirror', Y.XmlFragment)
|
||||
|
||||
const prosemirrorView = new EditorView(document.querySelector('#editor'), {
|
||||
state: EditorState.create({
|
||||
doc: DOMParser.fromSchema(schema).parse(document.querySelector('#content')),
|
||||
plugins: exampleSetup({schema}).concat([prosemirrorPlugin(type), cursorPlugin])
|
||||
})
|
||||
})
|
||||
|
||||
window.example = { provider, ydocument, type, prosemirrorView }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
36
examples/prosemirror.js
Normal file
36
examples/prosemirror.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { prosemirrorPlugin, cursorPlugin } from 'y-prosemirror'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import { EditorState } from 'prosemirror-state'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { DOMParser } from 'prosemirror-model'
|
||||
import { schema } from './prosemirror-schema.js'
|
||||
import { exampleSetup } from 'prosemirror-example-setup'
|
||||
// import { noteHistoryPlugin } from './prosemirror-history.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('prosemirror' /*, { gc: false } */)
|
||||
const type = ydocument.get('prosemirror', Y.XmlFragment)
|
||||
|
||||
const prosemirrorView = new EditorView(document.querySelector('#editor'), {
|
||||
state: EditorState.create({
|
||||
doc: DOMParser.fromSchema(schema).parse(document.querySelector('#content')),
|
||||
plugins: exampleSetup({ schema }).concat([prosemirrorPlugin(type), cursorPlugin])
|
||||
})
|
||||
})
|
||||
|
||||
const connectBtn = document.querySelector('.y-connect-btn')
|
||||
connectBtn.addEventListener('click', () => {
|
||||
if (ydocument.wsconnected) {
|
||||
ydocument.disconnect()
|
||||
connectBtn.textContent = 'Connect'
|
||||
} else {
|
||||
ydocument.connect()
|
||||
connectBtn.textContent = 'Disconnect'
|
||||
}
|
||||
})
|
||||
|
||||
window.example = { provider, ydocument, type, prosemirrorView }
|
||||
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- Main quill library -->
|
||||
<script src="../../node_modules/quill/dist/quill.min.js"></script>
|
||||
<link href="../../node_modules/quill/dist/quill.snow.css" rel="stylesheet">
|
||||
<!-- Quill cursors module -->
|
||||
<script src="../../node_modules/quill-cursors/dist/quill-cursors.min.js"></script>
|
||||
<link href="../../node_modules/quill-cursors/dist/quill-cursors.css" rel="stylesheet">
|
||||
<!-- Yjs Library and connector -->
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="quill-container">
|
||||
<div id="quill">
|
||||
</div>
|
||||
</div>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,78 +0,0 @@
|
||||
/* global Y, Quill, QuillCursors */
|
||||
|
||||
Quill.register('modules/cursors', QuillCursors)
|
||||
|
||||
let y = new Y('quill-0', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
let users = y.define('users', Y.Array)
|
||||
let myUserInfo = new Y.Map()
|
||||
myUserInfo.set('name', 'dada')
|
||||
myUserInfo.set('color', 'red')
|
||||
users.push([myUserInfo])
|
||||
|
||||
let quill = new Quill('#quill-container', {
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
['image', 'code-block'],
|
||||
[{ color: [] }, { background: [] }], // Snow theme fills in values
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
['link', 'image'],
|
||||
['link', 'code-block'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }]
|
||||
],
|
||||
cursors: {
|
||||
hideDelay: 500
|
||||
}
|
||||
},
|
||||
placeholder: 'Compose an epic...',
|
||||
theme: 'snow' // or 'bubble'
|
||||
})
|
||||
|
||||
let cursors = quill.getModule('cursors')
|
||||
|
||||
function drawCursors () {
|
||||
cursors.clearCursors()
|
||||
users.map((user, userId) => {
|
||||
if (user !== myUserInfo) {
|
||||
let relativeRange = user.get('range')
|
||||
let lastUpdated = new Date(user.get('last updated'))
|
||||
if (lastUpdated != null && new Date() - lastUpdated < 20000 && relativeRange != null) {
|
||||
let start = Y.utils.fromRelativePosition(y, relativeRange.start).offset
|
||||
let end = Y.utils.fromRelativePosition(y, relativeRange.end).offset
|
||||
let range = { index: start, length: end - start }
|
||||
cursors.setCursor(userId + '', range, user.get('name'), user.get('color'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
users.observeDeep(drawCursors)
|
||||
drawCursors()
|
||||
|
||||
quill.on('selection-change', function (range) {
|
||||
if (range != null) {
|
||||
myUserInfo.set('range', {
|
||||
start: Y.utils.getRelativePosition(yText, range.index),
|
||||
end: Y.utils.getRelativePosition(yText, range.index + range.length)
|
||||
})
|
||||
} else {
|
||||
myUserInfo.delete('range')
|
||||
}
|
||||
myUserInfo.set('last updated', new Date().toString())
|
||||
})
|
||||
|
||||
let yText = y.define('quill', Y.Text)
|
||||
let quillBinding = new Y.QuillBinding(yText, quill)
|
||||
|
||||
window.quillBinding = quillBinding
|
||||
window.yText = yText
|
||||
window.y = y
|
||||
window.quill = quill
|
||||
window.users = users
|
||||
window.cursors = cursors
|
||||
51
examples/quill.html
Normal file
51
examples/quill.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Yjs Quill Example</title>
|
||||
<link rel="stylesheet" href="https://cdn.quilljs.com/1.3.6/quill.snow.css">
|
||||
</head>
|
||||
<body>
|
||||
<p>This example shows how to bind a YText type to <a href="https://quilljs.com">Quill</a> editor.</p>
|
||||
<p>The content of this editor is shared with every client who visits this domain.</p>
|
||||
<div class="code-html">
|
||||
<div id="quill-container">
|
||||
<div id="quill">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The actual source file for the following code is found in ./quill.js. Run `npm run watch` to compile the files -->
|
||||
<script class="code-js" src="./build/quill.js" type="module">
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
import { QuillBinding } from 'yjs/bindings/quill.js'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import Quill from 'quill'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('quill')
|
||||
const ytext = ydocument.define('quill', Y.Text)
|
||||
|
||||
const quill = new Quill('#quill-container', {
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
['image', 'code-block'],
|
||||
[{ color: [] }, { background: [] }], // Snow theme fills in values
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
['link', 'image'],
|
||||
['link', 'code-block'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }]
|
||||
]
|
||||
},
|
||||
placeholder: 'Compose an epic...',
|
||||
theme: 'snow' // or 'bubble'
|
||||
})
|
||||
|
||||
window.quillBinding = new QuillBinding(ytext, quill)
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
30
examples/quill.js
Normal file
30
examples/quill.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { QuillBinding } from 'y-quill'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import Quill from 'quill'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('quill')
|
||||
const ytext = ydocument.define('quill', Y.Text)
|
||||
|
||||
const quill = new Quill('#quill-container', {
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
['image', 'code-block'],
|
||||
[{ color: [] }, { background: [] }], // Snow theme fills in values
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
['link', 'image'],
|
||||
['link', 'code-block'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }]
|
||||
]
|
||||
},
|
||||
placeholder: 'Compose an epic...',
|
||||
theme: 'snow' // or 'bubble'
|
||||
})
|
||||
|
||||
window.quillBinding = new QuillBinding(ytext, quill)
|
||||
@@ -1,18 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- Main Quill library -->
|
||||
<script src="../../node_modules/quill/dist/quill.min.js"></script>
|
||||
<link href="../../node_modules/quill/dist/quill.snow.css" rel="stylesheet">
|
||||
<!-- Yjs Library and connector -->
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="quill-container">
|
||||
<div id="quill">
|
||||
</div>
|
||||
</div>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,33 +0,0 @@
|
||||
/* global Y, Quill */
|
||||
|
||||
let y = new Y('quill-cursors-0', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
let quill = new Quill('#quill-container', {
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
['image', 'code-block'],
|
||||
[{ color: [] }, { background: [] }], // Snow theme fills in values
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
['link', 'image'],
|
||||
['link', 'code-block'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }]
|
||||
]
|
||||
},
|
||||
placeholder: 'Compose an epic...',
|
||||
theme: 'snow' // or 'bubble'
|
||||
})
|
||||
|
||||
let yText = y.define('quill', Y.Text)
|
||||
|
||||
let quillBinding = new Y.QuillBinding(yText, quill)
|
||||
window.quillBinding = quillBinding
|
||||
window.yText = yText
|
||||
window.y = y
|
||||
window.quill = quill
|
||||
@@ -1,29 +0,0 @@
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
|
||||
var pkg = require('./package.json')
|
||||
|
||||
export default {
|
||||
input: 'yjs-dist.mjs',
|
||||
name: 'Y',
|
||||
output: {
|
||||
file: 'yjs-dist.js',
|
||||
format: 'umd'
|
||||
},
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
main: true,
|
||||
module: true,
|
||||
browser: true
|
||||
}),
|
||||
commonjs()
|
||||
],
|
||||
sourcemap: true,
|
||||
banner: `
|
||||
/**
|
||||
* ${pkg.name} - ${pkg.description}
|
||||
* @version v${pkg.version}
|
||||
* @license ${pkg.license}
|
||||
*/
|
||||
`
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- quill does not include dist files! We are using the hosted version instead -->
|
||||
<!--link rel="stylesheet" href="../bower_components/quill/dist/quill.snow.css" /-->
|
||||
<link href="https://cdn.quilljs.com/1.0.4/quill.snow.css" rel="stylesheet">
|
||||
<link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.css" rel="stylesheet">
|
||||
<link href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.2.0/styles/monokai-sublime.min.css" rel="stylesheet">
|
||||
<style>
|
||||
#quill-container {
|
||||
border: 1px solid gray;
|
||||
box-shadow: 0px 0px 10px gray;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="quill-container">
|
||||
<div id="quill">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.js" type="text/javascript"></script>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.2.0/highlight.min.js" type="text/javascript"></script>
|
||||
<script src="https://cdn.quilljs.com/1.0.4/quill.js"></script>
|
||||
<!-- quill does not include dist files! We are using the hosted version instead (see above)
|
||||
<script src="../bower_components/quill/dist/quill.js"></script>
|
||||
-->
|
||||
<script src="../bower_components/yjs/y.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,49 +0,0 @@
|
||||
/* global Y, Quill */
|
||||
|
||||
// register yjs service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Register service worker
|
||||
// it is important to copy yjs-sw-template to the root directory!
|
||||
navigator.serviceWorker.register('./yjs-sw-template.js').then(function (reg) {
|
||||
console.log('Yjs service worker registration succeeded. Scope is ' + reg.scope)
|
||||
}).catch(function (err) {
|
||||
console.error('Yjs service worker registration failed with error ' + err)
|
||||
})
|
||||
}
|
||||
|
||||
// initialize a shared object. This function call returns a promise!
|
||||
Y({
|
||||
db: {
|
||||
name: 'memory'
|
||||
},
|
||||
connector: {
|
||||
name: 'serviceworker',
|
||||
room: 'ServiceWorkerExample2'
|
||||
},
|
||||
sourceDir: '/bower_components',
|
||||
share: {
|
||||
richtext: 'Richtext' // y.share.richtext is of type Y.Richtext
|
||||
}
|
||||
}).then(function (y) {
|
||||
window.yServiceWorker = y
|
||||
|
||||
// create quill element
|
||||
window.quill = new Quill('#quill', {
|
||||
modules: {
|
||||
formula: true,
|
||||
syntax: true,
|
||||
toolbar: [
|
||||
[{ size: ['small', false, 'large', 'huge'] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ color: [] }, { background: [] }], // Snow theme fills in values
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
['link', 'image'],
|
||||
['link', 'code-block'],
|
||||
[{ list: 'ordered' }]
|
||||
]
|
||||
},
|
||||
theme: 'snow'
|
||||
})
|
||||
// bind quill to richtext type
|
||||
y.share.richtext.bind(window.quill)
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
/* eslint-env worker */
|
||||
|
||||
// copy and modify this file
|
||||
|
||||
self.DBConfig = {
|
||||
name: 'indexeddb'
|
||||
}
|
||||
self.ConnectorConfig = {
|
||||
name: 'websockets-client',
|
||||
// url: '..',
|
||||
options: {
|
||||
jsonp: false
|
||||
}
|
||||
}
|
||||
|
||||
importScripts(
|
||||
'/bower_components/yjs/y.js',
|
||||
'/bower_components/y-memory/y-memory.js',
|
||||
'/bower_components/y-indexeddb/y-indexeddb.js',
|
||||
'/bower_components/y-websockets-client/y-websockets-client.js',
|
||||
'/bower_components/y-serviceworker/yjs-sw-include.js'
|
||||
)
|
||||
29
examples/style.css
Normal file
29
examples/style.css
Normal file
@@ -0,0 +1,29 @@
|
||||
footer img {
|
||||
display: none;
|
||||
}
|
||||
|
||||
nav .title h1 a {
|
||||
display: none;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #b93c1d;
|
||||
}
|
||||
|
||||
#resizer {
|
||||
background-color: #b93c1d;
|
||||
}
|
||||
|
||||
.main section article.readme h1:first-child img {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main section article.readme h1:first-child {
|
||||
margin-bottom: 16px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.main section article.readme h1:first-child::before {
|
||||
content: "Yjs";
|
||||
font-size: 2em;
|
||||
}
|
||||
32
examples/textarea.html
Normal file
32
examples/textarea.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Yjs Textarea Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>This example shows how to bind a YText type to a DOM Textarea.</p>
|
||||
<p>The content of this textarea is shared with every client who visits this domain.</p>
|
||||
<div class="code-html">
|
||||
|
||||
<textarea style="width:80%;" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<!-- The actual source file for the following code is found in ./textarea.js. Run `npm run watch` to compile the files -->
|
||||
<script class="code-js" src="./build/textarea.js" type="module">
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
import { TextareaBinding } from 'yjs/bindings/textarea.js'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('textarea')
|
||||
const type = ydocument.define('textarea', Y.Text)
|
||||
const textarea = document.querySelector('textarea')
|
||||
const binding = new TextareaBinding(type, textarea)
|
||||
|
||||
window.textareaExample = {
|
||||
provider, ydocument, type, textarea, binding
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
examples/textarea.js
Normal file
14
examples/textarea.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { TextareaBinding } from 'y-textarea'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('textarea')
|
||||
const type = ydocument.getText('textarea')
|
||||
const textarea = document.querySelector('textarea')
|
||||
const binding = new TextareaBinding(type, textarea)
|
||||
|
||||
window.textareaExample = {
|
||||
provider, ydocument, type, textarea, binding
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<textarea style="width:80%;" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
/* global Y */
|
||||
|
||||
let y = new Y('textarea-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yTextarea = y
|
||||
|
||||
// bind the textarea to a shared text element
|
||||
let type = y.define('textarea', Y.Text)
|
||||
let textarea = document.querySelector('textarea')
|
||||
window.binding = new Y.TextareaBinding(type, textarea)
|
||||
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
</head>
|
||||
<!-- jquery is not required for YXml. It is just here for convenience, and to test batch operations. -->
|
||||
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="./index.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1> Shared DOM Example </h1>
|
||||
<p> Use native DOM function or jQuery to manipulate the shared DOM (window.sharedDom). </p>
|
||||
<div class="command">
|
||||
<button type="button">Execute</button>
|
||||
<input type="text" value='$(sharedDom).append("<h3>Appended headline</h3>")' size="40"/>
|
||||
</div>
|
||||
<div class="command">
|
||||
<button type="button">Execute</button>
|
||||
<input type="text" value='$(sharedDom).attr("align","right")' size="40"/>
|
||||
</div>
|
||||
<div class="command">
|
||||
<button type="button">Execute</button>
|
||||
<input type="text" value='$(sharedDom).attr("style","color:blue;")' size="40"/>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* global $ */
|
||||
var commands = document.querySelectorAll('.command')
|
||||
Array.prototype.forEach.call(commands, function (command) {
|
||||
var execute = function () {
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(command.querySelector('input').value)
|
||||
}
|
||||
command.querySelector('button').onclick = execute
|
||||
$(command.querySelector('input')).keyup(function (e) {
|
||||
if (e.keyCode === 13) {
|
||||
execute()
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +0,0 @@
|
||||
/* global Y */
|
||||
|
||||
let y = new Y('xml-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yXml = y
|
||||
// bind xml type to a dom, and put it in body
|
||||
window.sharedDom = y.define('xml', Y.XmlElement).toDom()
|
||||
document.body.appendChild(window.sharedDom)
|
||||
6380
package-lock.json
generated
6380
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
109
package.json
109
package.json
@@ -1,38 +1,42 @@
|
||||
{
|
||||
"name": "yjs",
|
||||
"version": "13.0.0-60",
|
||||
"description": "A framework for real-time p2p shared editing on any data",
|
||||
"main": "./y.node.js",
|
||||
"browser": "./y.js",
|
||||
"module": "./src/y.js",
|
||||
"version": "13.0.0-84",
|
||||
"description": "Shared Editing Library",
|
||||
"main": "./dist/yjs.js",
|
||||
"module": "./dist/yjs.mjs",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"start": "node --experimental-modules src/Connectors/WebsocketsConnector/server.mjs",
|
||||
"test": "npm run lint",
|
||||
"debug": "concurrently 'rollup -wc rollup.test.js' 'cutest-serve y.test.js -o'",
|
||||
"lint": "standard src/**/*.mjs test/**/*.mjs tests-lib/**/*.mjs",
|
||||
"docs": "esdoc",
|
||||
"test": "npm run dist && PRODUCTION=1 node ./dist/tests.js --repitition-time 50 --production",
|
||||
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.js --repitition-time 10000",
|
||||
"dist": "rm -rf dist examples/build && rollup -c",
|
||||
"serve-examples": "concurrently 'npm run watch' 'serve examples'",
|
||||
"watch": "rollup -wc",
|
||||
"lint": "standard && tsc",
|
||||
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.v13.md --package ./package.json || true",
|
||||
"serve-docs": "npm run docs && serve ./docs/",
|
||||
"dist": "rollup -c rollup.browser.js; rollup -c rollup.node.js",
|
||||
"watch": "concurrently 'rollup -wc rollup.browser.js' 'rollup -wc rollup.node.js'",
|
||||
"postversion": "npm run dist",
|
||||
"postpublish": "tag-dist-files --overwrite-existing-tag",
|
||||
"demos": "concurrently 'node --experimental-modules src/Connectors/WebsocketsConnector/server.mjs' 'http-server'"
|
||||
},
|
||||
"now": {
|
||||
"engines": {
|
||||
"node": "10.x.x"
|
||||
}
|
||||
"preversion": "PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.js --repitition-time 1000",
|
||||
"postversion": "git push && git push --tags",
|
||||
"debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'",
|
||||
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.js",
|
||||
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.js"
|
||||
},
|
||||
"files": [
|
||||
"y.*",
|
||||
"dist/*",
|
||||
"src/*",
|
||||
".esdoc.json",
|
||||
"tests/*",
|
||||
"docs/*"
|
||||
],
|
||||
"dictionaries": {
|
||||
"doc": "docs",
|
||||
"example": "examples",
|
||||
"test": "tests"
|
||||
},
|
||||
"standard": {
|
||||
"ignore": [
|
||||
"/y.js",
|
||||
"/y.js.map"
|
||||
"/dist",
|
||||
"/node_modules",
|
||||
"/docs",
|
||||
"/examples/build"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
@@ -40,13 +44,7 @@
|
||||
"url": "https://github.com/y-js/yjs.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Yjs",
|
||||
"OT",
|
||||
"Collaboration",
|
||||
"Synchronization",
|
||||
"ShareJS",
|
||||
"Coweb",
|
||||
"Concurrency"
|
||||
"crdt"
|
||||
],
|
||||
"author": "Kevin Jahns",
|
||||
"email": "kevin.jahns@rwth-aachen.de",
|
||||
@@ -55,33 +53,28 @@
|
||||
"url": "https://github.com/y-js/yjs/issues"
|
||||
},
|
||||
"homepage": "http://y-js.org",
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.24.1",
|
||||
"babel-plugin-external-helpers": "^6.22.0",
|
||||
"babel-plugin-transform-regenerator": "^6.24.1",
|
||||
"babel-plugin-transform-runtime": "^6.23.0",
|
||||
"babel-preset-latest": "^6.24.1",
|
||||
"chance": "^1.0.9",
|
||||
"codemirror": "^5.37.0",
|
||||
"concurrently": "^3.4.0",
|
||||
"cutest": "^0.1.9",
|
||||
"esdoc": "^1.0.4",
|
||||
"esdoc-standard-plugin": "^1.0.0",
|
||||
"quill": "^1.3.5",
|
||||
"quill-cursors": "^1.0.2",
|
||||
"rollup": "^0.58.2",
|
||||
"rollup-plugin-babel": "^2.7.1",
|
||||
"rollup-plugin-commonjs": "^8.0.2",
|
||||
"rollup-plugin-inject": "^2.0.0",
|
||||
"rollup-plugin-multi-entry": "^2.0.1",
|
||||
"rollup-plugin-node-resolve": "^3.0.0",
|
||||
"rollup-plugin-uglify": "^1.0.2",
|
||||
"rollup-regenerator-runtime": "^6.23.1",
|
||||
"rollup-watch": "^3.2.2",
|
||||
"standard": "^11.0.1",
|
||||
"tag-dist-files": "^0.1.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"uws": "^10.148.0"
|
||||
"lib0": "0.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"codemirror": "^5.46.0",
|
||||
"concurrently": "^3.6.1",
|
||||
"jsdoc": "^3.6.2",
|
||||
"live-server": "^1.2.1",
|
||||
"prosemirror-example-setup": "^1.0.1",
|
||||
"prosemirror-schema-basic": "^1.0.1",
|
||||
"prosemirror-state": "^1.2.3",
|
||||
"prosemirror-view": "^1.9.5",
|
||||
"quill": "^1.3.6",
|
||||
"quill-cursors": "^1.0.3",
|
||||
"rollup": "^1.11.3",
|
||||
"rollup-cli": "^1.0.9",
|
||||
"rollup-plugin-commonjs": "^9.3.4",
|
||||
"rollup-plugin-node-resolve": "^4.2.4",
|
||||
"rollup-plugin-terser": "^4.0.4",
|
||||
"standard": "^11.0.1",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^3.4.5",
|
||||
"y-protocols": "0.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import babel from 'rollup-plugin-babel'
|
||||
import uglify from 'rollup-plugin-uglify'
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
var pkg = require('./package.json')
|
||||
|
||||
export default {
|
||||
input: 'src/Y.dist.mjs',
|
||||
name: 'Y',
|
||||
sourcemap: true,
|
||||
output: {
|
||||
file: 'y.js',
|
||||
format: 'umd'
|
||||
},
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
main: true,
|
||||
module: true,
|
||||
browser: true
|
||||
}),
|
||||
commonjs(),
|
||||
babel(),
|
||||
uglify({
|
||||
mangle: {
|
||||
except: ['YMap', 'Y', 'YArray', 'YText', 'YXmlHook', 'YXmlFragment', 'YXmlElement', 'YXmlEvent', 'YXmlText', 'YEvent', 'YArrayEvent', 'YMapEvent', 'Type', 'Delete', 'ItemJSON', 'ItemString', 'Item']
|
||||
},
|
||||
output: {
|
||||
comments: function (node, comment) {
|
||||
var text = comment.value
|
||||
var type = comment.type
|
||||
if (type === 'comment2') {
|
||||
// multiline comment
|
||||
return /@license/i.test(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
banner: `
|
||||
/**
|
||||
* ${pkg.name} - ${pkg.description}
|
||||
* @version v${pkg.version}
|
||||
* @license ${pkg.license}
|
||||
*/
|
||||
`
|
||||
}
|
||||
103
rollup.config.js
Normal file
103
rollup.config.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
import { terser } from 'rollup-plugin-terser'
|
||||
|
||||
const customModules = new Set([
|
||||
'y-websocket',
|
||||
'y-codemirror',
|
||||
'y-ace',
|
||||
'y-textarea',
|
||||
'y-quill',
|
||||
'y-dom',
|
||||
'y-prosemirror'
|
||||
])
|
||||
/**
|
||||
* @type {Set<any>}
|
||||
*/
|
||||
const customLibModules = new Set([
|
||||
'lib0',
|
||||
'y-protocols'
|
||||
])
|
||||
const debugResolve = {
|
||||
resolveId (importee) {
|
||||
if (importee === 'yjs') {
|
||||
return `${process.cwd()}/src/index.js`
|
||||
}
|
||||
if (customModules.has(importee.split('/')[0])) {
|
||||
return `${process.cwd()}/../${importee}/src/${importee}.js`
|
||||
}
|
||||
if (customLibModules.has(importee.split('/')[0])) {
|
||||
return `${process.cwd()}/../${importee}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const minificationPlugins = process.env.PRODUCTION ? [terser({
|
||||
module: true,
|
||||
compress: {
|
||||
hoist_vars: true,
|
||||
module: true,
|
||||
passes: 5,
|
||||
pure_getters: true,
|
||||
unsafe_comps: true,
|
||||
unsafe_undefined: true
|
||||
},
|
||||
mangle: {
|
||||
toplevel: true
|
||||
}
|
||||
})] : []
|
||||
|
||||
export default [{
|
||||
input: './src/index.js',
|
||||
output: [{
|
||||
name: 'Y',
|
||||
file: 'dist/yjs.js',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
paths: path => {
|
||||
if (/^lib0\//.test(path)) {
|
||||
return `lib0/dist/${path.slice(5)}`
|
||||
}
|
||||
return path
|
||||
}
|
||||
}, {
|
||||
name: 'Y',
|
||||
file: 'dist/yjs.mjs',
|
||||
format: 'es',
|
||||
sourcemap: true
|
||||
}],
|
||||
external: id => /^lib0\//.test(id)
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
output: {
|
||||
name: 'test',
|
||||
file: 'dist/tests.js',
|
||||
format: 'iife',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
sourcemap: true,
|
||||
mainFields: ['module', 'browser', 'main']
|
||||
})
|
||||
// commonjs()
|
||||
]
|
||||
}, {
|
||||
input: ['./examples/prosemirror.js'], // './examples/textarea.js', './examples/quill.js', './examples/dom.js', './examples/codemirror.js'
|
||||
output: {
|
||||
dir: 'examples/build',
|
||||
format: 'esm',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
sourcemap: true,
|
||||
mainFields: ['module', 'browser', 'main']
|
||||
}),
|
||||
commonjs(),
|
||||
...minificationPlugins
|
||||
]
|
||||
}]
|
||||
@@ -1,28 +0,0 @@
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
var pkg = require('./package.json')
|
||||
|
||||
export default {
|
||||
input: 'src/Y.dist.mjs',
|
||||
nameame: 'Y',
|
||||
sourcemap: true,
|
||||
output: {
|
||||
file: 'y.node.js',
|
||||
format: 'cjs'
|
||||
},
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
main: true,
|
||||
module: true,
|
||||
browser: true
|
||||
}),
|
||||
commonjs()
|
||||
],
|
||||
banner: `
|
||||
/**
|
||||
* ${pkg.name} - ${pkg.description}
|
||||
* @version v${pkg.version}
|
||||
* @license ${pkg.license}
|
||||
*/
|
||||
`
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
import multiEntry from 'rollup-plugin-multi-entry'
|
||||
|
||||
export default {
|
||||
input: 'test/index.mjs',
|
||||
name: 'y-tests',
|
||||
sourcemap: true,
|
||||
output: {
|
||||
file: 'y.test.js',
|
||||
format: 'umd'
|
||||
},
|
||||
plugins: [
|
||||
multiEntry(),
|
||||
nodeResolve({
|
||||
main: true,
|
||||
module: true,
|
||||
browser: true
|
||||
}),
|
||||
commonjs()
|
||||
]
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
|
||||
import { createMutualExclude } from '../Util/mutualExclude.mjs'
|
||||
|
||||
/**
|
||||
* Abstract class for bindings.
|
||||
*
|
||||
* A binding handles data binding from a Yjs type to a data object. For example,
|
||||
* you can bind a Quill editor instance to a YText instance with the `QuillBinding` class.
|
||||
*
|
||||
* It is expected that a concrete implementation accepts two parameters
|
||||
* (type and binding target).
|
||||
*
|
||||
* @example
|
||||
* const quill = new Quill(document.createElement('div'))
|
||||
* const type = y.define('quill', Y.Text)
|
||||
* const binding = new Y.QuillBinding(quill, type)
|
||||
*
|
||||
*/
|
||||
export default class Binding {
|
||||
/**
|
||||
* @param {YType} type Yjs type.
|
||||
* @param {any} target Binding Target.
|
||||
*/
|
||||
constructor (type, target) {
|
||||
/**
|
||||
* The Yjs type that is bound to `target`
|
||||
* @type {YType}
|
||||
*/
|
||||
this.type = type
|
||||
/**
|
||||
* The target that `type` is bound to.
|
||||
* @type {*}
|
||||
*/
|
||||
this.target = target
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
this._mutualExclude = createMutualExclude()
|
||||
}
|
||||
/**
|
||||
* Remove all data observers (both from the type and the target).
|
||||
*/
|
||||
destroy () {
|
||||
this.type = null
|
||||
this.target = null
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
|
||||
import Binding from '../Binding.mjs'
|
||||
import simpleDiff from '../../Util/simpleDiff.mjs'
|
||||
import { getRelativePosition, fromRelativePosition } from '../../Util/relativePosition.mjs'
|
||||
|
||||
function typeObserver () {
|
||||
this._mutualExclude(() => {
|
||||
const textarea = this.target
|
||||
const textType = this.type
|
||||
const relativeStart = getRelativePosition(textType, textarea.selectionStart)
|
||||
const relativeEnd = getRelativePosition(textType, textarea.selectionEnd)
|
||||
textarea.value = textType.toString()
|
||||
const start = fromRelativePosition(textType._y, relativeStart)
|
||||
const end = fromRelativePosition(textType._y, relativeEnd)
|
||||
textarea.setSelectionRange(start, end)
|
||||
})
|
||||
}
|
||||
|
||||
function domObserver () {
|
||||
this._mutualExclude(() => {
|
||||
let diff = simpleDiff(this.type.toString(), this.target.value)
|
||||
this.type.delete(diff.pos, diff.remove)
|
||||
this.type.insert(diff.pos, diff.insert)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A binding that binds a YText to a dom textarea.
|
||||
*
|
||||
* This binding is automatically destroyed when its parent is deleted.
|
||||
*
|
||||
* @example
|
||||
* const textare = document.createElement('textarea')
|
||||
* const type = y.define('textarea', Y.Text)
|
||||
* const binding = new Y.QuillBinding(type, textarea)
|
||||
*
|
||||
*/
|
||||
export default class TextareaBinding extends Binding {
|
||||
constructor (textType, domTextarea) {
|
||||
// Binding handles textType as this.type and domTextarea as this.target
|
||||
super(textType, domTextarea)
|
||||
// set initial value
|
||||
domTextarea.value = textType.toString()
|
||||
// Observers are handled by this class
|
||||
this._typeObserver = typeObserver.bind(this)
|
||||
this._domObserver = domObserver.bind(this)
|
||||
textType.observe(this._typeObserver)
|
||||
domTextarea.addEventListener('input', this._domObserver)
|
||||
}
|
||||
destroy () {
|
||||
// Remove everything that is handled by this class
|
||||
this.type.unobserve(this._typeObserver)
|
||||
this.target.unobserve(this._domObserver)
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/* global MutationObserver */
|
||||
|
||||
import Binding from '../Binding.mjs'
|
||||
import { createAssociation, removeAssociation } from './util.mjs'
|
||||
import { beforeTransactionSelectionFixer, afterTransactionSelectionFixer } from './selection.mjs'
|
||||
import { defaultFilter, applyFilterOnType } from './filter.mjs'
|
||||
import typeObserver from './typeObserver.mjs'
|
||||
import domObserver from './domObserver.mjs'
|
||||
|
||||
/**
|
||||
* A binding that binds the children of a YXmlFragment to a DOM element.
|
||||
*
|
||||
* This binding is automatically destroyed when its parent is deleted.
|
||||
*
|
||||
* @example
|
||||
* const div = document.createElement('div')
|
||||
* const type = y.define('xml', Y.XmlFragment)
|
||||
* const binding = new Y.QuillBinding(type, div)
|
||||
*
|
||||
*/
|
||||
export default class DomBinding extends Binding {
|
||||
/**
|
||||
* @param {YXmlFragment} type The bind source. This is the ultimate source of
|
||||
* truth.
|
||||
* @param {Element} target The bind target. Mirrors the target.
|
||||
* @param {Object} [opts] Optional configurations
|
||||
|
||||
* @param {FilterFunction} [opts.filter=defaultFilter] The filter function to use.
|
||||
*/
|
||||
constructor (type, target, opts = {}) {
|
||||
// Binding handles textType as this.type and domTextarea as this.target
|
||||
super(type, target)
|
||||
this.opts = opts
|
||||
opts.document = opts.document || document
|
||||
opts.hooks = opts.hooks || {}
|
||||
this.scrollingElement = opts.scrollingElement || null
|
||||
/**
|
||||
* Maps each DOM element to the type that it is associated with.
|
||||
* @type {Map}
|
||||
*/
|
||||
this.domToType = new Map()
|
||||
/**
|
||||
* Maps each YXml type to the DOM element that it is associated with.
|
||||
* @type {Map}
|
||||
*/
|
||||
this.typeToDom = new Map()
|
||||
/**
|
||||
* Defines which DOM attributes and elements to filter out.
|
||||
* Also filters remote changes.
|
||||
* @type {FilterFunction}
|
||||
*/
|
||||
this.filter = opts.filter || defaultFilter
|
||||
// set initial value
|
||||
target.innerHTML = ''
|
||||
type.forEach(child => {
|
||||
target.insertBefore(child.toDom(opts.document, opts.hooks, this), null)
|
||||
})
|
||||
this._typeObserver = typeObserver.bind(this)
|
||||
this._domObserver = (mutations) => {
|
||||
domObserver.call(this, mutations, opts.document)
|
||||
}
|
||||
type.observeDeep(this._typeObserver)
|
||||
this._mutationObserver = new MutationObserver(this._domObserver)
|
||||
this._mutationObserver.observe(target, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
subtree: true
|
||||
})
|
||||
const y = type._y
|
||||
// Force flush dom changes before Type changes are applied (they might
|
||||
// modify the dom)
|
||||
this._beforeTransactionHandler = (y, transaction, remote) => {
|
||||
this._domObserver(this._mutationObserver.takeRecords())
|
||||
beforeTransactionSelectionFixer(y, this, transaction, remote)
|
||||
}
|
||||
y.on('beforeTransaction', this._beforeTransactionHandler)
|
||||
this._afterTransactionHandler = (y, transaction, remote) => {
|
||||
afterTransactionSelectionFixer(y, this, transaction, remote)
|
||||
// remove associations
|
||||
// TODO: this could be done more efficiently
|
||||
// e.g. Always delete using the following approach, or removeAssociation
|
||||
// in dom/type-observer..
|
||||
transaction.deletedStructs.forEach(type => {
|
||||
const dom = this.typeToDom.get(type)
|
||||
if (dom !== undefined) {
|
||||
removeAssociation(this, dom, type)
|
||||
}
|
||||
})
|
||||
}
|
||||
y.on('afterTransaction', this._afterTransactionHandler)
|
||||
// Before calling observers, apply dom filter to all changed and new types.
|
||||
this._beforeObserverCallsHandler = (y, transaction) => {
|
||||
// Apply dom filter to new and changed types
|
||||
transaction.changedTypes.forEach((subs, type) => {
|
||||
// Only check attributes. New types are filtered below.
|
||||
if ((subs.size > 1 || (subs.size === 1 && subs.has(null) === false))) {
|
||||
applyFilterOnType(y, this, type)
|
||||
}
|
||||
})
|
||||
transaction.newTypes.forEach(type => {
|
||||
applyFilterOnType(y, this, type)
|
||||
})
|
||||
}
|
||||
y.on('beforeObserverCalls', this._beforeObserverCallsHandler)
|
||||
createAssociation(this, target, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: currently does not apply filter to existing elements!
|
||||
* @param {FilterFunction} filter The filter function to use from now on.
|
||||
*/
|
||||
setFilter (filter) {
|
||||
this.filter = filter
|
||||
// TODO: apply filter to all elements
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all properties that are handled by this class.
|
||||
*/
|
||||
destroy () {
|
||||
this.domToType = null
|
||||
this.typeToDom = null
|
||||
this.type.unobserveDeep(this._typeObserver)
|
||||
this._mutationObserver.disconnect()
|
||||
const y = this.type._y
|
||||
y.off('beforeTransaction', this._beforeTransactionHandler)
|
||||
y.off('beforeObserverCalls', this._beforeObserverCallsHandler)
|
||||
y.off('afterTransaction', this._afterTransactionHandler)
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A filter defines which elements and attributes to share.
|
||||
* Return null if the node should be filtered. Otherwise return the Map of
|
||||
* accepted attributes.
|
||||
*
|
||||
* @typedef {function(nodeName: String, attrs: Map): Map|null} FilterFunction
|
||||
*/
|
||||
@@ -1,144 +0,0 @@
|
||||
|
||||
import YXmlHook from '../../Types/YXml/YXmlHook.mjs'
|
||||
import {
|
||||
iterateUntilUndeleted,
|
||||
removeAssociation,
|
||||
insertNodeHelper } from './util.mjs'
|
||||
import diff from '../../Util/simpleDiff.mjs'
|
||||
import YXmlFragment from '../../Types/YXml/YXmlFragment.mjs'
|
||||
|
||||
/**
|
||||
* 1. Check if any of the nodes was deleted
|
||||
* 2. Iterate over the children.
|
||||
* 2.1 If a node exists that is not yet bound to a type, insert a new node
|
||||
* 2.2 If _contents.length < dom.childNodes.length, fill the
|
||||
* rest of _content with childNodes
|
||||
* 2.3 If a node was moved, delete it and
|
||||
* recreate a new yxml element that is bound to that node.
|
||||
* You can detect that a node was moved because expectedId
|
||||
* !== actualId in the list
|
||||
* @private
|
||||
*/
|
||||
function applyChangesFromDom (binding, dom, yxml, _document) {
|
||||
if (yxml == null || yxml === false || yxml.constructor === YXmlHook) {
|
||||
return
|
||||
}
|
||||
const y = yxml._y
|
||||
const knownChildren = new Set()
|
||||
for (let i = dom.childNodes.length - 1; i >= 0; i--) {
|
||||
const type = binding.domToType.get(dom.childNodes[i])
|
||||
if (type !== undefined && type !== false) {
|
||||
knownChildren.add(type)
|
||||
}
|
||||
}
|
||||
// 1. Check if any of the nodes was deleted
|
||||
yxml.forEach(function (childType) {
|
||||
if (knownChildren.has(childType) === false) {
|
||||
childType._delete(y)
|
||||
removeAssociation(binding, binding.typeToDom.get(childType), childType)
|
||||
}
|
||||
})
|
||||
// 2. iterate
|
||||
const childNodes = dom.childNodes
|
||||
const len = childNodes.length
|
||||
let prevExpectedType = null
|
||||
let expectedType = iterateUntilUndeleted(yxml._start)
|
||||
for (let domCnt = 0; domCnt < len; domCnt++) {
|
||||
const childNode = childNodes[domCnt]
|
||||
const childType = binding.domToType.get(childNode)
|
||||
if (childType !== undefined) {
|
||||
if (childType === false) {
|
||||
// should be ignored or is going to be deleted
|
||||
continue
|
||||
}
|
||||
if (expectedType !== null) {
|
||||
if (expectedType !== childType) {
|
||||
// 2.3 Not expected node
|
||||
if (childType._parent !== yxml) {
|
||||
// child was moved from another parent
|
||||
// childType is going to be deleted by its previous parent
|
||||
removeAssociation(binding, childNode, childType)
|
||||
} else {
|
||||
// child was moved to a different position.
|
||||
removeAssociation(binding, childNode, childType)
|
||||
childType._delete(y)
|
||||
}
|
||||
prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode, _document, binding)
|
||||
} else {
|
||||
// Found expected node. Continue.
|
||||
prevExpectedType = expectedType
|
||||
expectedType = iterateUntilUndeleted(expectedType._right)
|
||||
}
|
||||
} else {
|
||||
// 2.2 Fill _content with child nodes
|
||||
prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode, _document, binding)
|
||||
}
|
||||
} else {
|
||||
// 2.1 A new node was found
|
||||
prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode, _document, binding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export default function domObserver (mutations, _document) {
|
||||
this._mutualExclude(() => {
|
||||
this.type._y.transact(() => {
|
||||
let diffChildren = new Set()
|
||||
mutations.forEach(mutation => {
|
||||
const dom = mutation.target
|
||||
const yxml = this.domToType.get(dom)
|
||||
if (yxml === undefined) { // In case yxml is undefined, we double check if we forgot to bind the dom
|
||||
let parent = dom
|
||||
let yParent
|
||||
do {
|
||||
parent = parent.parentElement
|
||||
yParent = this.domToType.get(parent)
|
||||
} while (yParent === undefined && parent !== null)
|
||||
if (yParent !== false && yParent !== undefined && yParent.constructor !== YXmlHook) {
|
||||
diffChildren.add(parent)
|
||||
}
|
||||
return
|
||||
} else if (yxml === false || yxml.constructor === YXmlHook) {
|
||||
// dom element is filtered / a dom hook
|
||||
return
|
||||
}
|
||||
switch (mutation.type) {
|
||||
case 'characterData':
|
||||
var change = diff(yxml.toString(), dom.nodeValue)
|
||||
yxml.delete(change.pos, change.remove)
|
||||
yxml.insert(change.pos, change.insert)
|
||||
break
|
||||
case 'attributes':
|
||||
if (yxml.constructor === YXmlFragment) {
|
||||
break
|
||||
}
|
||||
let name = mutation.attributeName
|
||||
let val = dom.getAttribute(name)
|
||||
// check if filter accepts attribute
|
||||
let attributes = new Map()
|
||||
attributes.set(name, val)
|
||||
if (yxml.constructor !== YXmlFragment && this.filter(dom.nodeName, attributes).size > 0) {
|
||||
if (yxml.getAttribute(name) !== val) {
|
||||
if (val == null) {
|
||||
yxml.removeAttribute(name)
|
||||
} else {
|
||||
yxml.setAttribute(name, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'childList':
|
||||
diffChildren.add(mutation.target)
|
||||
break
|
||||
}
|
||||
})
|
||||
for (let dom of diffChildren) {
|
||||
const yxml = this.domToType.get(dom)
|
||||
applyChangesFromDom(this, dom, yxml, _document)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
|
||||
import YXmlText from '../../Types/YXml/YXmlText.mjs'
|
||||
import YXmlHook from '../../Types/YXml/YXmlHook.mjs'
|
||||
import YXmlElement from '../../Types/YXml/YXmlElement.mjs'
|
||||
import { createAssociation, domsToTypes } from './util.mjs'
|
||||
import { filterDomAttributes, defaultFilter } from './filter.mjs'
|
||||
|
||||
/**
|
||||
* Creates a Yjs type (YXml) based on the contents of a DOM Element.
|
||||
*
|
||||
* @param {Element|TextNode} element The DOM Element
|
||||
* @param {?Document} _document Optional. Provide the global document object
|
||||
* @param {Hooks} [hooks = {}] Optional. Set of Yjs Hooks
|
||||
* @param {Filter} [filter=defaultFilter] Optional. Dom element filter
|
||||
* @param {?DomBinding} binding Warning: This property is for internal use only!
|
||||
* @return {YXmlElement | YXmlText}
|
||||
*/
|
||||
export default function domToType (element, _document = document, hooks = {}, filter = defaultFilter, binding) {
|
||||
let type
|
||||
switch (element.nodeType) {
|
||||
case _document.ELEMENT_NODE:
|
||||
let hookName = null
|
||||
let hook
|
||||
// configure `hookName !== undefined` if element is a hook.
|
||||
if (element.hasAttribute('data-yjs-hook')) {
|
||||
hookName = element.getAttribute('data-yjs-hook')
|
||||
hook = hooks[hookName]
|
||||
if (hook === undefined) {
|
||||
console.error(`Unknown hook "${hookName}". Deleting yjsHook dataset property.`)
|
||||
delete element.removeAttribute('data-yjs-hook')
|
||||
hookName = null
|
||||
}
|
||||
}
|
||||
if (hookName === null) {
|
||||
// Not a hook
|
||||
const attrs = filterDomAttributes(element, filter)
|
||||
if (attrs === null) {
|
||||
type = false
|
||||
} else {
|
||||
type = new YXmlElement(element.nodeName)
|
||||
attrs.forEach((val, key) => {
|
||||
type.setAttribute(key, val)
|
||||
})
|
||||
type.insert(0, domsToTypes(element.childNodes, document, hooks, filter, binding))
|
||||
}
|
||||
} else {
|
||||
// Is a hook
|
||||
type = new YXmlHook(hookName)
|
||||
hook.fillType(element, type)
|
||||
}
|
||||
break
|
||||
case _document.TEXT_NODE:
|
||||
type = new YXmlText()
|
||||
type.insert(0, element.nodeValue)
|
||||
break
|
||||
default:
|
||||
throw new Error('Can\'t transform this node type to a YXml type!')
|
||||
}
|
||||
createAssociation(binding, element, type)
|
||||
return type
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import isParentOf from '../../Util/isParentOf.mjs'
|
||||
|
||||
/**
|
||||
* Default filter method (does nothing).
|
||||
*
|
||||
* @param {String} nodeName The nodeName of the element
|
||||
* @param {Map} attrs Map of key-value pairs that are attributes of the node.
|
||||
* @return {Map | null} The allowed attributes or null, if the element should be
|
||||
* filtered.
|
||||
*/
|
||||
export function defaultFilter (nodeName, attrs) {
|
||||
// TODO: implement basic filter that filters out dangerous properties!
|
||||
return attrs
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function filterDomAttributes (dom, filter) {
|
||||
const attrs = new Map()
|
||||
for (let i = dom.attributes.length - 1; i >= 0; i--) {
|
||||
const attr = dom.attributes[i]
|
||||
attrs.set(attr.name, attr.value)
|
||||
}
|
||||
return filter(dom.nodeName, attrs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a filter on a type.
|
||||
*
|
||||
* @param {Y} y The Yjs instance.
|
||||
* @param {DomBinding} binding The DOM binding instance that has the dom filter.
|
||||
* @param {YXmlElement | YXmlFragment } type The type to apply the filter to.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function applyFilterOnType (y, binding, type) {
|
||||
if (isParentOf(binding.type, type)) {
|
||||
const nodeName = type.nodeName
|
||||
let attributes = new Map()
|
||||
if (type.getAttributes !== undefined) {
|
||||
let attrs = type.getAttributes()
|
||||
for (let key in attrs) {
|
||||
attributes.set(key, attrs[key])
|
||||
}
|
||||
}
|
||||
const filteredAttributes = binding.filter(nodeName, new Map(attributes))
|
||||
if (filteredAttributes === null) {
|
||||
type._delete(y)
|
||||
} else {
|
||||
// iterate original attributes
|
||||
attributes.forEach((value, key) => {
|
||||
// delete all attributes that are not in filteredAttributes
|
||||
if (filteredAttributes.has(key) === false) {
|
||||
type.removeAttribute(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/* globals getSelection */
|
||||
|
||||
import { getRelativePosition, fromRelativePosition } from '../../Util/relativePosition.mjs'
|
||||
|
||||
let browserSelection = null
|
||||
let relativeSelection = null
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export let beforeTransactionSelectionFixer
|
||||
if (typeof getSelection !== 'undefined') {
|
||||
beforeTransactionSelectionFixer = function _beforeTransactionSelectionFixer (y, domBinding, transaction, remote) {
|
||||
if (!remote) {
|
||||
return
|
||||
}
|
||||
relativeSelection = { from: null, to: null, fromY: null, toY: null }
|
||||
browserSelection = getSelection()
|
||||
const anchorNode = browserSelection.anchorNode
|
||||
const anchorNodeType = domBinding.domToType.get(anchorNode)
|
||||
if (anchorNode !== null && anchorNodeType !== undefined) {
|
||||
relativeSelection.from = getRelativePosition(anchorNodeType, browserSelection.anchorOffset)
|
||||
relativeSelection.fromY = anchorNodeType._y
|
||||
}
|
||||
const focusNode = browserSelection.focusNode
|
||||
const focusNodeType = domBinding.domToType.get(focusNode)
|
||||
if (focusNode !== null && focusNodeType !== undefined) {
|
||||
relativeSelection.to = getRelativePosition(focusNodeType, browserSelection.focusOffset)
|
||||
relativeSelection.toY = focusNodeType._y
|
||||
}
|
||||
}
|
||||
} else {
|
||||
beforeTransactionSelectionFixer = function _fakeBeforeTransactionSelectionFixer () {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export function afterTransactionSelectionFixer (y, domBinding, transaction, remote) {
|
||||
if (relativeSelection === null || !remote) {
|
||||
return
|
||||
}
|
||||
const to = relativeSelection.to
|
||||
const from = relativeSelection.from
|
||||
const fromY = relativeSelection.fromY
|
||||
const toY = relativeSelection.toY
|
||||
let shouldUpdate = false
|
||||
let anchorNode = browserSelection.anchorNode
|
||||
let anchorOffset = browserSelection.anchorOffset
|
||||
let focusNode = browserSelection.focusNode
|
||||
let focusOffset = browserSelection.focusOffset
|
||||
if (from !== null) {
|
||||
let sel = fromRelativePosition(fromY, from)
|
||||
if (sel !== null) {
|
||||
let node = domBinding.typeToDom.get(sel.type)
|
||||
let offset = sel.offset
|
||||
if (node !== anchorNode || offset !== anchorOffset) {
|
||||
anchorNode = node
|
||||
anchorOffset = offset
|
||||
shouldUpdate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (to !== null) {
|
||||
let sel = fromRelativePosition(toY, to)
|
||||
if (sel !== null) {
|
||||
let node = domBinding.typeToDom.get(sel.type)
|
||||
let offset = sel.offset
|
||||
if (node !== focusNode || offset !== focusOffset) {
|
||||
focusNode = node
|
||||
focusOffset = offset
|
||||
shouldUpdate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldUpdate) {
|
||||
browserSelection.setBaseAndExtent(
|
||||
anchorNode,
|
||||
anchorOffset,
|
||||
focusNode,
|
||||
focusOffset
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/* global getSelection */
|
||||
|
||||
import YXmlText from '../../Types/YXml/YXmlText.mjs'
|
||||
import YXmlHook from '../../Types/YXml/YXmlHook.mjs'
|
||||
import { removeDomChildrenUntilElementFound } from './util.mjs'
|
||||
|
||||
function findScrollReference (scrollingElement) {
|
||||
if (scrollingElement !== null) {
|
||||
let anchor = getSelection().anchorNode
|
||||
if (anchor == null) {
|
||||
let children = scrollingElement.children // only iterate through non-text nodes
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const elem = children[i]
|
||||
const rect = elem.getBoundingClientRect()
|
||||
if (rect.top >= 0) {
|
||||
return { elem, top: rect.top }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (anchor.nodeType === document.TEXT_NODE) {
|
||||
anchor = anchor.parentElement
|
||||
}
|
||||
const top = anchor.getBoundingClientRect().top
|
||||
return { elem: anchor, top: top }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function fixScroll (scrollingElement, ref) {
|
||||
if (ref !== null) {
|
||||
const { elem, top } = ref
|
||||
const currentTop = elem.getBoundingClientRect().top
|
||||
const newScroll = scrollingElement.scrollTop + currentTop - top
|
||||
if (newScroll >= 0) {
|
||||
scrollingElement.scrollTop = newScroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export default function typeObserver (events) {
|
||||
this._mutualExclude(() => {
|
||||
const scrollRef = findScrollReference(this.scrollingElement)
|
||||
events.forEach(event => {
|
||||
const yxml = event.target
|
||||
const dom = this.typeToDom.get(yxml)
|
||||
if (dom !== undefined && dom !== false) {
|
||||
if (yxml.constructor === YXmlText) {
|
||||
dom.nodeValue = yxml.toString()
|
||||
} else if (event.attributesChanged !== undefined) {
|
||||
// update attributes
|
||||
event.attributesChanged.forEach(attributeName => {
|
||||
const value = yxml.getAttribute(attributeName)
|
||||
if (value === undefined) {
|
||||
dom.removeAttribute(attributeName)
|
||||
} else {
|
||||
dom.setAttribute(attributeName, value)
|
||||
}
|
||||
})
|
||||
/*
|
||||
* TODO: instead of hard-checking the types, it would be best to
|
||||
* specify the type's features. E.g.
|
||||
* - _yxmlHasAttributes
|
||||
* - _yxmlHasChildren
|
||||
* Furthermore, the features shouldn't be encoded in the types,
|
||||
* only in the attributes (above)
|
||||
*/
|
||||
if (event.childListChanged && yxml.constructor !== YXmlHook) {
|
||||
let currentChild = dom.firstChild
|
||||
yxml.forEach(childType => {
|
||||
const childNode = this.typeToDom.get(childType)
|
||||
switch (childNode) {
|
||||
case undefined:
|
||||
// Does not exist. Create it.
|
||||
const node = childType.toDom(this.opts.document, this.opts.hooks, this)
|
||||
dom.insertBefore(node, currentChild)
|
||||
break
|
||||
case false:
|
||||
// nop
|
||||
break
|
||||
default:
|
||||
// Is already attached to the dom.
|
||||
// Find it and remove all dom nodes in-between.
|
||||
removeDomChildrenUntilElementFound(dom, currentChild, childNode)
|
||||
currentChild = childNode.nextSibling
|
||||
break
|
||||
}
|
||||
})
|
||||
removeDomChildrenUntilElementFound(dom, currentChild, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
fixScroll(this.scrollingElement, scrollRef)
|
||||
})
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
|
||||
import domToType from './domToType.mjs'
|
||||
|
||||
/**
|
||||
* Iterates items until an undeleted item is found.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function iterateUntilUndeleted (item) {
|
||||
while (item !== null && item._deleted) {
|
||||
item = item._right
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an association (the information that a DOM element belongs to a
|
||||
* type).
|
||||
*
|
||||
* @param {DomBinding} domBinding The binding object
|
||||
* @param {Element} dom The dom that is to be associated with type
|
||||
* @param {YXmlElement|YXmlHook} type The type that is to be associated with dom
|
||||
*
|
||||
*/
|
||||
export function removeAssociation (domBinding, dom, type) {
|
||||
domBinding.domToType.delete(dom)
|
||||
domBinding.typeToDom.delete(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an association (the information that a DOM element belongs to a
|
||||
* type).
|
||||
*
|
||||
* @param {DomBinding} domBinding The binding object
|
||||
* @param {Element} dom The dom that is to be associated with type
|
||||
* @param {YXmlElement|YXmlHook} type The type that is to be associated with dom
|
||||
*
|
||||
*/
|
||||
export function createAssociation (domBinding, dom, type) {
|
||||
if (domBinding !== undefined) {
|
||||
domBinding.domToType.set(dom, type)
|
||||
domBinding.typeToDom.set(type, dom)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If oldDom is associated with a type, associate newDom with the type and
|
||||
* forget about oldDom. If oldDom is not associated with any type, nothing happens.
|
||||
*
|
||||
* @param {DomBinding} domBinding The binding object
|
||||
* @param {Element} oldDom The existing dom
|
||||
* @param {Element} newDom The new dom object
|
||||
*/
|
||||
export function switchAssociation (domBinding, oldDom, newDom) {
|
||||
if (domBinding !== undefined) {
|
||||
const type = domBinding.domToType.get(oldDom)
|
||||
if (type !== undefined) {
|
||||
removeAssociation(domBinding, oldDom, type)
|
||||
createAssociation(domBinding, newDom, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert Dom Elements after one of the children of this YXmlFragment.
|
||||
* The Dom elements will be bound to a new YXmlElement and inserted at the
|
||||
* specified position.
|
||||
*
|
||||
* @param {YXmlElement} type The type in which to insert DOM elements.
|
||||
* @param {YXmlElement|null} prev The reference node. New YxmlElements are
|
||||
* inserted after this node. Set null to insert at
|
||||
* the beginning.
|
||||
* @param {Array<Element>} doms The Dom elements to insert.
|
||||
* @param {?Document} _document Optional. Provide the global document object.
|
||||
* @param {DomBinding} binding The dom binding
|
||||
* @return {Array<YXmlElement>} The YxmlElements that are inserted.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function insertDomElementsAfter (type, prev, doms, _document, binding) {
|
||||
const types = domsToTypes(doms, _document, binding.opts.hooks, binding.filter, binding)
|
||||
return type.insertAfter(prev, types)
|
||||
}
|
||||
|
||||
export function domsToTypes (doms, _document, hooks, filter, binding) {
|
||||
const types = []
|
||||
for (let dom of doms) {
|
||||
const t = domToType(dom, _document, hooks, filter, binding)
|
||||
if (t !== false) {
|
||||
types.push(t)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export function insertNodeHelper (yxml, prevExpectedNode, child, _document, binding) {
|
||||
let insertedNodes = insertDomElementsAfter(yxml, prevExpectedNode, [child], _document, binding)
|
||||
if (insertedNodes.length > 0) {
|
||||
return insertedNodes[0]
|
||||
} else {
|
||||
return prevExpectedNode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove children until `elem` is found.
|
||||
*
|
||||
* @param {Element} parent The parent of `elem` and `currentChild`.
|
||||
* @param {Element} currentChild Start removing elements with `currentChild`. If
|
||||
* `currentChild` is `elem` it won't be removed.
|
||||
* @param {Element|null} elem The elemnt to look for.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function removeDomChildrenUntilElementFound (parent, currentChild, elem) {
|
||||
while (currentChild !== elem) {
|
||||
const del = currentChild
|
||||
currentChild = currentChild.nextSibling
|
||||
parent.removeChild(del)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import Binding from '../Binding.mjs'
|
||||
|
||||
function typeObserver (event) {
|
||||
const quill = this.target
|
||||
// Force flush Quill changes.
|
||||
quill.update('yjs')
|
||||
this._mutualExclude(function () {
|
||||
// Apply computed delta.
|
||||
quill.updateContents(event.delta, 'yjs')
|
||||
// Force flush Quill changes. Ignore applied changes.
|
||||
quill.update('yjs')
|
||||
})
|
||||
}
|
||||
|
||||
function quillObserver (delta) {
|
||||
this._mutualExclude(() => {
|
||||
this.type.applyDelta(delta.ops)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A Binding that binds a YText type to a Quill editor.
|
||||
*
|
||||
* @example
|
||||
* const quill = new Quill(document.createElement('div'))
|
||||
* const type = y.define('quill', Y.Text)
|
||||
* const binding = new Y.QuillBinding(quill, type)
|
||||
* // Now modifications on the DOM will be reflected in the Type, and the other
|
||||
* // way around!
|
||||
*/
|
||||
export default class QuillBinding extends Binding {
|
||||
/**
|
||||
* @param {YText} textType
|
||||
* @param {Quill} quill
|
||||
*/
|
||||
constructor (textType, quill) {
|
||||
// Binding handles textType as this.type and quill as this.target.
|
||||
super(textType, quill)
|
||||
// Set initial value.
|
||||
quill.setContents(textType.toDelta(), 'yjs')
|
||||
// Observers are handled by this class.
|
||||
this._typeObserver = typeObserver.bind(this)
|
||||
this._quillObserver = quillObserver.bind(this)
|
||||
textType.observe(this._typeObserver)
|
||||
quill.on('text-change', this._quillObserver)
|
||||
}
|
||||
destroy () {
|
||||
// Remove everything that is handled by this class.
|
||||
this.type.unobserve(this._typeObserver)
|
||||
this.target.off('text-change', this._quillObserver)
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
|
||||
import Binding from '../Binding.mjs'
|
||||
import simpleDiff from '../../Util/simpleDiff.mjs'
|
||||
import { getRelativePosition, fromRelativePosition } from '../../Util/relativePosition.mjs'
|
||||
|
||||
function typeObserver () {
|
||||
this._mutualExclude(() => {
|
||||
const textarea = this.target
|
||||
const textType = this.type
|
||||
const relativeStart = getRelativePosition(textType, textarea.selectionStart)
|
||||
const relativeEnd = getRelativePosition(textType, textarea.selectionEnd)
|
||||
textarea.value = textType.toString()
|
||||
const start = fromRelativePosition(textType._y, relativeStart)
|
||||
const end = fromRelativePosition(textType._y, relativeEnd)
|
||||
textarea.setSelectionRange(start, end)
|
||||
})
|
||||
}
|
||||
|
||||
function domObserver () {
|
||||
this._mutualExclude(() => {
|
||||
let diff = simpleDiff(this.type.toString(), this.target.value)
|
||||
this.type.delete(diff.pos, diff.remove)
|
||||
this.type.insert(diff.pos, diff.insert)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A binding that binds a YText to a dom textarea.
|
||||
*
|
||||
* This binding is automatically destroyed when its parent is deleted.
|
||||
*
|
||||
* @example
|
||||
* const textare = document.createElement('textarea')
|
||||
* const type = y.define('textarea', Y.Text)
|
||||
* const binding = new Y.QuillBinding(type, textarea)
|
||||
*
|
||||
*/
|
||||
export default class TextareaBinding extends Binding {
|
||||
constructor (textType, domTextarea) {
|
||||
// Binding handles textType as this.type and domTextarea as this.target
|
||||
super(textType, domTextarea)
|
||||
// set initial value
|
||||
domTextarea.value = textType.toString()
|
||||
// Observers are handled by this class
|
||||
this._typeObserver = typeObserver.bind(this)
|
||||
this._domObserver = domObserver.bind(this)
|
||||
textType.observe(this._typeObserver)
|
||||
domTextarea.addEventListener('input', this._domObserver)
|
||||
}
|
||||
destroy () {
|
||||
// Remove everything that is handled by this class
|
||||
this.type.unobserve(this._typeObserver)
|
||||
this.target.unobserve(this._domObserver)
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
import BinaryEncoder from './Util/Binary/Encoder.mjs'
|
||||
import BinaryDecoder from './Util/Binary/Decoder.mjs'
|
||||
|
||||
import { sendSyncStep1, readSyncStep1 } from './MessageHandler/syncStep1.mjs'
|
||||
import { readSyncStep2 } from './MessageHandler/syncStep2.mjs'
|
||||
import { integrateRemoteStructs } from './MessageHandler/integrateRemoteStructs.mjs'
|
||||
|
||||
import debug from 'debug'
|
||||
|
||||
// TODO: rename Connector
|
||||
|
||||
export default class AbstractConnector {
|
||||
constructor (y, opts) {
|
||||
this.y = y
|
||||
this.opts = opts
|
||||
if (opts.role == null || opts.role === 'master') {
|
||||
this.role = 'master'
|
||||
} else if (opts.role === 'slave') {
|
||||
this.role = 'slave'
|
||||
} else {
|
||||
throw new Error("Role must be either 'master' or 'slave'!")
|
||||
}
|
||||
this.log = debug('y:connector')
|
||||
this.logMessage = debug('y:connector-message')
|
||||
this._forwardAppliedStructs = opts.forwardAppliedOperations || false // TODO: rename
|
||||
this.role = opts.role
|
||||
this.connections = new Map()
|
||||
this.isSynced = false
|
||||
this.userEventListeners = []
|
||||
this.whenSyncedListeners = []
|
||||
this.currentSyncTarget = null
|
||||
this.debug = opts.debug === true
|
||||
this.broadcastBuffer = new BinaryEncoder()
|
||||
this.broadcastBufferSize = 0
|
||||
this.protocolVersion = 11
|
||||
this.authInfo = opts.auth || null
|
||||
this.checkAuth = opts.checkAuth || function () { return Promise.resolve('write') } // default is everyone has write access
|
||||
if (opts.maxBufferLength == null) {
|
||||
this.maxBufferLength = -1
|
||||
} else {
|
||||
this.maxBufferLength = opts.maxBufferLength
|
||||
}
|
||||
}
|
||||
|
||||
reconnect () {
|
||||
this.log('reconnecting..')
|
||||
}
|
||||
|
||||
disconnect () {
|
||||
this.log('discronnecting..')
|
||||
this.connections = new Map()
|
||||
this.isSynced = false
|
||||
this.currentSyncTarget = null
|
||||
this.whenSyncedListeners = []
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
onUserEvent (f) {
|
||||
this.userEventListeners.push(f)
|
||||
}
|
||||
|
||||
removeUserEventListener (f) {
|
||||
this.userEventListeners = this.userEventListeners.filter(g => f !== g)
|
||||
}
|
||||
|
||||
userLeft (user) {
|
||||
if (this.connections.has(user)) {
|
||||
this.log('%s: User left %s', this.y.userID, user)
|
||||
this.connections.delete(user)
|
||||
// check if isSynced event can be sent now
|
||||
this._setSyncedWith(null)
|
||||
for (var f of this.userEventListeners) {
|
||||
f({
|
||||
action: 'userLeft',
|
||||
user: user
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userJoined (user, role, auth) {
|
||||
if (role == null) {
|
||||
throw new Error('You must specify the role of the joined user!')
|
||||
}
|
||||
if (this.connections.has(user)) {
|
||||
throw new Error('This user already joined!')
|
||||
}
|
||||
this.log('%s: User joined %s', this.y.userID, user)
|
||||
this.connections.set(user, {
|
||||
uid: user,
|
||||
isSynced: false,
|
||||
role: role,
|
||||
processAfterAuth: [],
|
||||
processAfterSync: [],
|
||||
auth: auth || null,
|
||||
receivedSyncStep2: false
|
||||
})
|
||||
let defer = {}
|
||||
defer.promise = new Promise(function (resolve) { defer.resolve = resolve })
|
||||
this.connections.get(user).syncStep2 = defer
|
||||
for (var f of this.userEventListeners) {
|
||||
f({
|
||||
action: 'userJoined',
|
||||
user: user,
|
||||
role: role
|
||||
})
|
||||
}
|
||||
this._syncWithUser(user)
|
||||
}
|
||||
|
||||
// Execute a function _when_ we are connected.
|
||||
// If not connected, wait until connected
|
||||
whenSynced (f) {
|
||||
if (this.isSynced) {
|
||||
f()
|
||||
} else {
|
||||
this.whenSyncedListeners.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
_syncWithUser (userID) {
|
||||
if (this.role === 'slave') {
|
||||
return // "The current sync has not finished or this is controlled by a master!"
|
||||
}
|
||||
sendSyncStep1(this, userID)
|
||||
}
|
||||
|
||||
_fireIsSyncedListeners () {
|
||||
if (!this.isSynced) {
|
||||
this.isSynced = true
|
||||
// It is safer to remove this!
|
||||
// call whensynced listeners
|
||||
for (var f of this.whenSyncedListeners) {
|
||||
f()
|
||||
}
|
||||
this.whenSyncedListeners = []
|
||||
this.y._setContentReady()
|
||||
this.y.emit('synced')
|
||||
}
|
||||
}
|
||||
|
||||
send (uid, buffer) {
|
||||
const y = this.y
|
||||
if (!(buffer instanceof ArrayBuffer || buffer instanceof Uint8Array)) {
|
||||
throw new Error('Expected Message to be an ArrayBuffer or Uint8Array - don\'t use this method to send custom messages')
|
||||
}
|
||||
this.log('User%s to User%s: Send \'%y\'', y.userID, uid, buffer)
|
||||
this.logMessage('User%s to User%s: Send %Y', y.userID, uid, [y, buffer])
|
||||
}
|
||||
|
||||
broadcast (buffer) {
|
||||
const y = this.y
|
||||
if (!(buffer instanceof ArrayBuffer || buffer instanceof Uint8Array)) {
|
||||
throw new Error('Expected Message to be an ArrayBuffer or Uint8Array - don\'t use this method to send custom messages')
|
||||
}
|
||||
this.log('User%s: Broadcast \'%y\'', y.userID, buffer)
|
||||
this.logMessage('User%s: Broadcast: %Y', y.userID, [y, buffer])
|
||||
}
|
||||
|
||||
/*
|
||||
Buffer operations, and broadcast them when ready.
|
||||
*/
|
||||
broadcastStruct (struct) {
|
||||
const firstContent = this.broadcastBuffer.length === 0
|
||||
if (firstContent) {
|
||||
this.broadcastBuffer.writeVarString(this.y.room)
|
||||
this.broadcastBuffer.writeVarString('update')
|
||||
this.broadcastBufferSize = 0
|
||||
this.broadcastBufferSizePos = this.broadcastBuffer.pos
|
||||
this.broadcastBuffer.writeUint32(0)
|
||||
}
|
||||
this.broadcastBufferSize++
|
||||
struct._toBinary(this.broadcastBuffer)
|
||||
if (this.maxBufferLength > 0 && this.broadcastBuffer.length > this.maxBufferLength) {
|
||||
// it is necessary to send the buffer now
|
||||
// cache the buffer and check if server is responsive
|
||||
const buffer = this.broadcastBuffer
|
||||
buffer.setUint32(this.broadcastBufferSizePos, this.broadcastBufferSize)
|
||||
this.broadcastBuffer = new BinaryEncoder()
|
||||
this.whenRemoteResponsive().then(() => {
|
||||
this.broadcast(buffer.createBuffer())
|
||||
})
|
||||
} else if (firstContent) {
|
||||
// send the buffer when all transactions are finished
|
||||
// (or buffer exceeds maxBufferLength)
|
||||
setTimeout(() => {
|
||||
if (this.broadcastBuffer.length > 0) {
|
||||
const buffer = this.broadcastBuffer
|
||||
buffer.setUint32(this.broadcastBufferSizePos, this.broadcastBufferSize)
|
||||
this.broadcast(buffer.createBuffer())
|
||||
this.broadcastBuffer = new BinaryEncoder()
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Somehow check the responsiveness of the remote clients/server
|
||||
* Default behavior:
|
||||
* Wait 100ms before broadcasting the next batch of operations
|
||||
*
|
||||
* Only used when maxBufferLength is set
|
||||
*
|
||||
*/
|
||||
whenRemoteResponsive () {
|
||||
return new Promise(function (resolve) {
|
||||
setTimeout(resolve, 100)
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
You received a raw message, and you know that it is intended for Yjs. Then call this function.
|
||||
*/
|
||||
receiveMessage (sender, buffer, skipAuth) {
|
||||
const y = this.y
|
||||
const userID = y.userID
|
||||
skipAuth = skipAuth || false
|
||||
if (!(buffer instanceof ArrayBuffer || buffer instanceof Uint8Array)) {
|
||||
return Promise.reject(new Error('Expected Message to be an ArrayBuffer or Uint8Array!'))
|
||||
}
|
||||
if (sender === userID) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
let decoder = new BinaryDecoder(buffer)
|
||||
let encoder = new BinaryEncoder()
|
||||
let roomname = decoder.readVarString() // read room name
|
||||
encoder.writeVarString(roomname)
|
||||
let messageType = decoder.readVarString()
|
||||
let senderConn = this.connections.get(sender)
|
||||
this.log('User%s from User%s: Receive \'%s\'', userID, sender, messageType)
|
||||
this.logMessage('User%s from User%s: Receive %Y', userID, sender, [y, buffer])
|
||||
if (senderConn == null && !skipAuth) {
|
||||
throw new Error('Received message from unknown peer!')
|
||||
}
|
||||
if (messageType === 'sync step 1' || messageType === 'sync step 2') {
|
||||
let auth = decoder.readVarUint()
|
||||
if (senderConn.auth == null) {
|
||||
senderConn.processAfterAuth.push([messageType, senderConn, decoder, encoder, sender])
|
||||
// check auth
|
||||
return this.checkAuth(auth, y, sender).then(authPermissions => {
|
||||
if (senderConn.auth == null) {
|
||||
senderConn.auth = authPermissions
|
||||
y.emit('userAuthenticated', {
|
||||
user: senderConn.uid,
|
||||
auth: authPermissions
|
||||
})
|
||||
}
|
||||
let messages = senderConn.processAfterAuth
|
||||
senderConn.processAfterAuth = []
|
||||
|
||||
messages.forEach(m =>
|
||||
this.computeMessage(m[0], m[1], m[2], m[3], m[4])
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
if ((skipAuth || senderConn.auth != null) && (messageType !== 'update' || senderConn.isSynced)) {
|
||||
this.computeMessage(messageType, senderConn, decoder, encoder, sender, skipAuth)
|
||||
} else {
|
||||
senderConn.processAfterSync.push([messageType, senderConn, decoder, encoder, sender, false])
|
||||
}
|
||||
}
|
||||
|
||||
computeMessage (messageType, senderConn, decoder, encoder, sender, skipAuth) {
|
||||
if (messageType === 'sync step 1' && (senderConn.auth === 'write' || senderConn.auth === 'read')) {
|
||||
// cannot wait for sync step 1 to finish, because we may wait for sync step 2 in sync step 1 (->lock)
|
||||
readSyncStep1(decoder, encoder, this.y, senderConn, sender)
|
||||
} else {
|
||||
const y = this.y
|
||||
y.transact(function () {
|
||||
if (messageType === 'sync step 2' && senderConn.auth === 'write') {
|
||||
readSyncStep2(decoder, encoder, y, senderConn, sender)
|
||||
} else if (messageType === 'update' && (skipAuth || senderConn.auth === 'write')) {
|
||||
integrateRemoteStructs(y, decoder)
|
||||
} else {
|
||||
throw new Error('Unable to receive message')
|
||||
}
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
_setSyncedWith (user) {
|
||||
if (user != null) {
|
||||
const userConn = this.connections.get(user)
|
||||
userConn.isSynced = true
|
||||
const messages = userConn.processAfterSync
|
||||
userConn.processAfterSync = []
|
||||
messages.forEach(m => {
|
||||
this.computeMessage(m[0], m[1], m[2], m[3], m[4])
|
||||
})
|
||||
}
|
||||
const conns = Array.from(this.connections.values())
|
||||
if (conns.length > 0 && conns.every(u => u.isSynced)) {
|
||||
this._fireIsSyncedListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import BinaryEncoder from '../../Util/Binary/Encoder.mjs'
|
||||
/* global WebSocket */
|
||||
import NamedEventHandler from '../../Util/NamedEventHandler.mjs'
|
||||
import decodeMessage, { messageSS, messageSubscribe, messageStructs } from './decodeMessage.mjs'
|
||||
import { createMutualExclude } from '../../Util/mutualExclude.mjs'
|
||||
import { messageCheckUpdateCounter } from './decodeMessage.mjs'
|
||||
|
||||
export const STATE_DISCONNECTED = 0
|
||||
export const STATE_CONNECTED = 1
|
||||
|
||||
export default class WebsocketsConnector extends NamedEventHandler {
|
||||
constructor (url = 'ws://localhost:1234') {
|
||||
super()
|
||||
this.url = url
|
||||
this._state = STATE_DISCONNECTED
|
||||
this._socket = null
|
||||
this._rooms = new Map()
|
||||
this._connectToServer = true
|
||||
this._reconnectTimeout = 300
|
||||
this._mutualExclude = createMutualExclude()
|
||||
this._persistence = null
|
||||
this.connect()
|
||||
}
|
||||
|
||||
getRoom (roomName) {
|
||||
return this._rooms.get(roomName) || { y: null, roomName, localUpdateCounter: 1 }
|
||||
}
|
||||
|
||||
syncPersistence (persistence) {
|
||||
this._persistence = persistence
|
||||
if (this._state === STATE_CONNECTED) {
|
||||
persistence.getAllDocuments().then(docs => {
|
||||
const encoder = new BinaryEncoder()
|
||||
docs.forEach(doc => {
|
||||
messageCheckUpdateCounter(doc.roomName, encoder, doc.remoteUpdateCounter)
|
||||
});
|
||||
this.send(encoder)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
connectY (roomName, y) {
|
||||
let room = this._rooms.get(roomName)
|
||||
if (room !== undefined) {
|
||||
throw new Error('Room is already taken! There can be only one Yjs instance per roomName!')
|
||||
}
|
||||
this._rooms.set(roomName, {
|
||||
roomName,
|
||||
y,
|
||||
localUpdateCounter: 1
|
||||
})
|
||||
y.on('afterTransaction', (y, transaction) => {
|
||||
this._mutualExclude(() => {
|
||||
if (transaction.encodedStructsLen > 0) {
|
||||
const encoder = new BinaryEncoder()
|
||||
const room = this._rooms.get(roomName)
|
||||
messageStructs(roomName, y, encoder, transaction.encodedStructs, ++room.localUpdateCounter)
|
||||
this.send(encoder)
|
||||
}
|
||||
})
|
||||
})
|
||||
if (this._state === STATE_CONNECTED) {
|
||||
const encoder = new BinaryEncoder()
|
||||
messageSS(roomName, y, encoder)
|
||||
messageSubscribe(roomName, y, encoder)
|
||||
this.send(encoder)
|
||||
}
|
||||
}
|
||||
|
||||
_setState (state) {
|
||||
this._state = state
|
||||
this.emit('stateChanged', {
|
||||
state: this.state
|
||||
})
|
||||
}
|
||||
|
||||
get state () {
|
||||
return this._state === STATE_DISCONNECTED ? 'disconnected' : 'connected'
|
||||
}
|
||||
|
||||
_onOpen () {
|
||||
this._setState(STATE_CONNECTED)
|
||||
if (this._persistence === null) {
|
||||
const encoder = new BinaryEncoder()
|
||||
for (const [roomName, room] of this._rooms) {
|
||||
const y = room.y
|
||||
messageSS(roomName, y, encoder)
|
||||
messageSubscribe(roomName, y, encoder)
|
||||
}
|
||||
this.send(encoder)
|
||||
} else {
|
||||
this.syncPersistence(this._persistence)
|
||||
}
|
||||
}
|
||||
|
||||
send (encoder) {
|
||||
if (encoder.length > 0 && this._socket.readyState === WebSocket.OPEN) {
|
||||
this._socket.send(encoder.createBuffer())
|
||||
}
|
||||
}
|
||||
|
||||
_onClose () {
|
||||
this._setState(STATE_DISCONNECTED)
|
||||
this._socket = null
|
||||
if (this._connectToServer) {
|
||||
setTimeout(() => {
|
||||
if (this._connectToServer) {
|
||||
this.connect()
|
||||
}
|
||||
}, this._reconnectTimeout)
|
||||
this.connect()
|
||||
}
|
||||
}
|
||||
|
||||
_onMessage (message) {
|
||||
if (message.data.byteLength > 0) {
|
||||
const reply = decodeMessage(this, message.data, null, false, this._persistence)
|
||||
this.send(reply)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect (code = 1000, reason = 'Client manually disconnected') {
|
||||
const socket = this._socket
|
||||
this._connectToServer = false
|
||||
socket.close(code, reason)
|
||||
}
|
||||
|
||||
connect () {
|
||||
if (this._socket === null) {
|
||||
const socket = new WebSocket(this.url)
|
||||
socket.binaryType = 'arraybuffer'
|
||||
this._socket = socket
|
||||
this._connectToServer = true
|
||||
// Connection opened
|
||||
socket.addEventListener('open', this._onOpen.bind(this))
|
||||
socket.addEventListener('close', this._onClose.bind(this))
|
||||
socket.addEventListener('message', this._onMessage.bind(this))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import BinaryDecoder from '../../Util/Binary/Decoder.mjs'
|
||||
import BinaryEncoder from '../../Util/Binary/Encoder.mjs'
|
||||
import { readStateSet, writeStateSet } from '../../MessageHandler/stateSet.mjs'
|
||||
import { writeStructs } from '../../MessageHandler/syncStep1.mjs'
|
||||
import { writeDeleteSet, readDeleteSet } from '../../MessageHandler/deleteSet.mjs'
|
||||
import { integrateRemoteStructs } from '../../MessageHandler/integrateRemoteStructs.mjs'
|
||||
|
||||
const CONTENT_GET_SS = 4
|
||||
export function messageGetSS (roomName, y, encoder) {
|
||||
encoder.writeVarString(roomName)
|
||||
encoder.writeVarUint(CONTENT_GET_SS)
|
||||
}
|
||||
|
||||
const CONTENT_SUBSCRIBE = 3
|
||||
export function messageSubscribe (roomName, y, encoder) {
|
||||
encoder.writeVarString(roomName)
|
||||
encoder.writeVarUint(CONTENT_SUBSCRIBE)
|
||||
}
|
||||
|
||||
const CONTENT_SS = 0
|
||||
export function messageSS (roomName, y, encoder) {
|
||||
encoder.writeVarString(roomName)
|
||||
encoder.writeVarUint(CONTENT_SS)
|
||||
writeStateSet(y, encoder)
|
||||
}
|
||||
|
||||
const CONTENT_STRUCTS_DSS = 2
|
||||
export function messageStructsDSS (roomName, y, encoder, ss, updateCounter) {
|
||||
encoder.writeVarString(roomName)
|
||||
encoder.writeVarUint(CONTENT_STRUCTS_DSS)
|
||||
encoder.writeVarUint(updateCounter)
|
||||
const structsDS = new BinaryEncoder()
|
||||
writeStructs(y, structsDS, ss)
|
||||
writeDeleteSet(y, structsDS)
|
||||
encoder.writeVarUint(structsDS.length)
|
||||
encoder.writeBinaryEncoder(structsDS)
|
||||
}
|
||||
|
||||
const CONTENT_STRUCTS = 5
|
||||
export function messageStructs (roomName, y, encoder, structsBinaryEncoder, updateCounter) {
|
||||
encoder.writeVarString(roomName)
|
||||
encoder.writeVarUint(CONTENT_STRUCTS)
|
||||
encoder.writeVarUint(updateCounter)
|
||||
encoder.writeVarUint(structsBinaryEncoder.length)
|
||||
encoder.writeBinaryEncoder(structsBinaryEncoder)
|
||||
}
|
||||
|
||||
const CONTENT_CHECK_COUNTER = 6
|
||||
export function messageCheckUpdateCounter (roomName, encoder, updateCounter = 0) {
|
||||
encoder.writeVarString(roomName)
|
||||
encoder.writeVarUint(CONTENT_CHECK_COUNTER)
|
||||
encoder.writeVarUint(updateCounter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a client-message.
|
||||
*
|
||||
* A client-message consists of multiple message-elements that are concatenated without delimiter.
|
||||
* Each has the following structure:
|
||||
* - roomName
|
||||
* - content_type
|
||||
* - content (additional info that is encoded based on the value of content_type)
|
||||
*
|
||||
* The message is encoded until no more message-elements are available.
|
||||
*
|
||||
* @param {*} connector The connector that handles the connections
|
||||
* @param {*} message The binary encoded message
|
||||
* @param {*} ws The connection object
|
||||
*/
|
||||
export default function decodeMessage (connector, message, ws, isServer = false, persistence) {
|
||||
const decoder = new BinaryDecoder(message)
|
||||
const encoder = new BinaryEncoder()
|
||||
while (decoder.hasContent()) {
|
||||
const roomName = decoder.readVarString()
|
||||
const contentType = decoder.readVarUint()
|
||||
const room = connector.getRoom(roomName)
|
||||
const y = room.y
|
||||
switch (contentType) {
|
||||
case CONTENT_CHECK_COUNTER:
|
||||
const updateCounter = decoder.readVarUint()
|
||||
if (room.localUpdateCounter !== updateCounter) {
|
||||
messageGetSS(roomName, y, encoder)
|
||||
}
|
||||
connector.subscribe(roomName, ws)
|
||||
break
|
||||
case CONTENT_STRUCTS:
|
||||
console.log(`${roomName}: received update`)
|
||||
connector._mutualExclude(() => {
|
||||
const remoteUpdateCounter = decoder.readVarUint()
|
||||
persistence.setRemoteUpdateCounter(roomName, remoteUpdateCounter)
|
||||
const messageLen = decoder.readVarUint()
|
||||
if (y === null) {
|
||||
persistence._persistStructs(roomName, decoder.readArrayBuffer(messageLen))
|
||||
} else {
|
||||
y.transact(() => {
|
||||
integrateRemoteStructs(y, decoder)
|
||||
}, true)
|
||||
}
|
||||
})
|
||||
break
|
||||
case CONTENT_GET_SS:
|
||||
if (y !== null) {
|
||||
messageSS(roomName, y, encoder)
|
||||
} else {
|
||||
persistence._createYInstance(roomName).then(y => {
|
||||
const encoder = new BinaryEncoder()
|
||||
messageSS(roomName, y, encoder)
|
||||
connector.send(encoder, ws)
|
||||
})
|
||||
}
|
||||
break
|
||||
case CONTENT_SUBSCRIBE:
|
||||
connector.subscribe(roomName, ws)
|
||||
break
|
||||
case CONTENT_SS:
|
||||
// received state set
|
||||
// reply with missing content
|
||||
const ss = readStateSet(decoder)
|
||||
const sendStructsDSS = () => {
|
||||
if (y !== null) { // TODO: how to sync local content?
|
||||
const encoder = new BinaryEncoder()
|
||||
messageStructsDSS(roomName, y, encoder, ss, room.localUpdateCounter) // room.localUpdateHandler in case it changes
|
||||
if (isServer) {
|
||||
messageSS(roomName, y, encoder)
|
||||
}
|
||||
connector.send(encoder, ws)
|
||||
}
|
||||
}
|
||||
if (room.persistenceLoaded !== undefined) {
|
||||
room.persistenceLoaded.then(sendStructsDSS)
|
||||
} else {
|
||||
sendStructsDSS()
|
||||
}
|
||||
break
|
||||
case CONTENT_STRUCTS_DSS:
|
||||
console.log(`${roomName}: synced`)
|
||||
connector._mutualExclude(() => {
|
||||
const remoteUpdateCounter = decoder.readVarUint()
|
||||
persistence.setRemoteUpdateCounter(roomName, remoteUpdateCounter)
|
||||
const messageLen = decoder.readVarUint()
|
||||
if (y === null) {
|
||||
persistence._persistStructsDS(roomName, decoder.readArrayBuffer(messageLen))
|
||||
} else {
|
||||
y.transact(() => {
|
||||
integrateRemoteStructs(y, decoder)
|
||||
readDeleteSet(y, decoder)
|
||||
}, true)
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
console.error('Unexpected content type!')
|
||||
if (ws !== null) {
|
||||
ws.close() // TODO: specify reason
|
||||
}
|
||||
}
|
||||
}
|
||||
return encoder
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import Y from '../../Y.mjs'
|
||||
import uws from 'uws'
|
||||
import BinaryEncoder from '../../Util/Binary/Encoder.mjs'
|
||||
import decodeMessage, { messageStructs } from './decodeMessage.mjs'
|
||||
import FilePersistence from '../../Persistences/FilePersistence.mjs'
|
||||
|
||||
const WebsocketsServer = uws.Server
|
||||
const persistence = new FilePersistence('.yjsPersisted')
|
||||
/**
|
||||
* Maps from room-name to ..
|
||||
* {
|
||||
* connections, // Set of ws-clients that listen to the room
|
||||
* y // Yjs instance that handles the room
|
||||
* }
|
||||
*/
|
||||
const rooms = new Map()
|
||||
/**
|
||||
* Maps from ws-connection to Set<roomName> - the set of connected roomNames
|
||||
*/
|
||||
const connections = new Map()
|
||||
const port = process.env.PORT || 1234
|
||||
const wss = new WebsocketsServer({
|
||||
port,
|
||||
perMessageDeflate: {}
|
||||
})
|
||||
|
||||
/**
|
||||
* Set of room names that are scheduled to be sweeped (destroyed because they don't have a connection anymore)
|
||||
*/
|
||||
const scheduledSweeps = new Set()
|
||||
/* TODO: enable sweeping
|
||||
setInterval(function sweepRoomes () {
|
||||
scheduledSweeps.forEach(roomName => {
|
||||
const room = rooms.get(roomName)
|
||||
if (room !== undefined) {
|
||||
if (room.connections.size === 0) {
|
||||
persistence.saveState(roomName, room.y).then(() => {
|
||||
if (room.connections.size === 0) {
|
||||
room.y.destroy()
|
||||
rooms.delete(roomName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
scheduledSweeps.clear()
|
||||
}, 5000) */
|
||||
|
||||
const wsConnector = {
|
||||
send: (encoder, ws) => {
|
||||
const message = encoder.createBuffer()
|
||||
ws.send(message, null, null, true)
|
||||
},
|
||||
_mutualExclude: f => { f() },
|
||||
subscribe: function subscribe (roomName, ws) {
|
||||
let roomNames = connections.get(ws)
|
||||
if (roomNames === undefined) {
|
||||
roomNames = new Set()
|
||||
connections.set(ws, roomNames)
|
||||
}
|
||||
roomNames.add(roomName)
|
||||
const room = this.getRoom(roomName)
|
||||
room.connections.add(ws)
|
||||
},
|
||||
getRoom: function getRoom (roomName) {
|
||||
let room = rooms.get(roomName)
|
||||
if (room === undefined) {
|
||||
const y = new Y(roomName, null, null, { gc: true })
|
||||
const persistenceLoaded = persistence.readState(roomName, y)
|
||||
room = {
|
||||
name: roomName,
|
||||
connections: new Set(),
|
||||
y,
|
||||
persistenceLoaded,
|
||||
localUpdateCounter: 1
|
||||
}
|
||||
y.on('afterTransaction', (y, transaction) => {
|
||||
if (transaction.encodedStructsLen > 0) {
|
||||
// save to persistence
|
||||
persistence.saveUpdate(roomName, y, transaction.encodedStructs)
|
||||
// forward update to clients
|
||||
persistence._mutex(() => { // do not broadcast if persistence.readState is called
|
||||
const encoder = new BinaryEncoder()
|
||||
messageStructs(roomName, y, encoder, transaction.encodedStructs, ++room.localUpdateCounter)
|
||||
const message = encoder.createBuffer()
|
||||
// when changed, broakcast update to all connections
|
||||
room.connections.forEach(conn => {
|
||||
conn.send(message, null, null, true)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
rooms.set(roomName, room)
|
||||
}
|
||||
return room
|
||||
}
|
||||
}
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
ws.on('message', function onWSMessage (message) {
|
||||
if (message.byteLength > 0) {
|
||||
const reply = decodeMessage(wsConnector, message, ws, true, persistence)
|
||||
if (reply.length > 0) {
|
||||
ws.send(reply.createBuffer(), null, null, true)
|
||||
}
|
||||
}
|
||||
})
|
||||
ws.on('close', function onWSClose () {
|
||||
const roomNames = connections.get(ws)
|
||||
if (roomNames !== undefined) {
|
||||
roomNames.forEach(roomName => {
|
||||
const room = rooms.get(roomName)
|
||||
if (room !== undefined) {
|
||||
const connections = room.connections
|
||||
connections.delete(ws)
|
||||
if (connections.size === 0) {
|
||||
scheduledSweeps.add(roomName)
|
||||
}
|
||||
}
|
||||
})
|
||||
connections.delete(ws)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
|
||||
import { writeStructs } from './syncStep1.mjs'
|
||||
import { integrateRemoteStructs } from './integrateRemoteStructs.mjs'
|
||||
import { readDeleteSet, writeDeleteSet } from './deleteSet.mjs'
|
||||
import BinaryEncoder from '../Util/Binary/Encoder.mjs'
|
||||
|
||||
/**
|
||||
* Read the Decoder and fill the Yjs instance with data in the decoder.
|
||||
*
|
||||
* @param {Y} y The Yjs instance
|
||||
* @param {BinaryDecoder} decoder The BinaryDecoder to read from.
|
||||
*/
|
||||
export function fromBinary (y, decoder) {
|
||||
y.transact(function () {
|
||||
integrateRemoteStructs(y, decoder)
|
||||
readDeleteSet(y, decoder)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the Yjs model to binary format.
|
||||
*
|
||||
* @param {Y} y The Yjs instance
|
||||
* @return {BinaryEncoder} The encoder instance that can be transformed
|
||||
* to ArrayBuffer or Buffer.
|
||||
*/
|
||||
export function toBinary (y) {
|
||||
let encoder = new BinaryEncoder()
|
||||
writeStructs(y, encoder, new Map())
|
||||
writeDeleteSet(y, encoder)
|
||||
return encoder
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { deleteItemRange } from '../Struct/Delete.mjs'
|
||||
import ID from '../Util/ID/ID.mjs'
|
||||
|
||||
export function stringifyDeleteSet (y, decoder, strBuilder) {
|
||||
let dsLength = decoder.readUint32()
|
||||
for (let i = 0; i < dsLength; i++) {
|
||||
let user = decoder.readVarUint()
|
||||
strBuilder.push(' -' + user + ':')
|
||||
let dvLength = decoder.readVarUint()
|
||||
for (let j = 0; j < dvLength; j++) {
|
||||
let from = decoder.readVarUint()
|
||||
let len = decoder.readVarUint()
|
||||
let gc = decoder.readUint8() === 1
|
||||
strBuilder.push(`clock: ${from}, length: ${len}, gc: ${gc}`)
|
||||
}
|
||||
}
|
||||
return strBuilder
|
||||
}
|
||||
|
||||
export function writeDeleteSet (y, encoder) {
|
||||
let currentUser = null
|
||||
let currentLength
|
||||
let lastLenPos
|
||||
|
||||
let numberOfUsers = 0
|
||||
let laterDSLenPus = encoder.pos
|
||||
encoder.writeUint32(0)
|
||||
|
||||
y.ds.iterate(null, null, function (n) {
|
||||
var user = n._id.user
|
||||
var clock = n._id.clock
|
||||
var len = n.len
|
||||
var gc = n.gc
|
||||
if (currentUser !== user) {
|
||||
numberOfUsers++
|
||||
// a new user was found
|
||||
if (currentUser !== null) { // happens on first iteration
|
||||
encoder.setUint32(lastLenPos, currentLength)
|
||||
}
|
||||
currentUser = user
|
||||
encoder.writeVarUint(user)
|
||||
// pseudo-fill pos
|
||||
lastLenPos = encoder.pos
|
||||
encoder.writeUint32(0)
|
||||
currentLength = 0
|
||||
}
|
||||
encoder.writeVarUint(clock)
|
||||
encoder.writeVarUint(len)
|
||||
encoder.writeUint8(gc ? 1 : 0)
|
||||
currentLength++
|
||||
})
|
||||
if (currentUser !== null) { // happens on first iteration
|
||||
encoder.setUint32(lastLenPos, currentLength)
|
||||
}
|
||||
encoder.setUint32(laterDSLenPus, numberOfUsers)
|
||||
}
|
||||
|
||||
export function readDeleteSet (y, decoder) {
|
||||
let dsLength = decoder.readUint32()
|
||||
for (let i = 0; i < dsLength; i++) {
|
||||
let user = decoder.readVarUint()
|
||||
let dv = []
|
||||
let dvLength = decoder.readUint32()
|
||||
for (let j = 0; j < dvLength; j++) {
|
||||
let from = decoder.readVarUint()
|
||||
let len = decoder.readVarUint()
|
||||
let gc = decoder.readUint8() === 1
|
||||
dv.push([from, len, gc])
|
||||
}
|
||||
if (dvLength > 0) {
|
||||
let pos = 0
|
||||
let d = dv[pos]
|
||||
let deletions = []
|
||||
y.ds.iterate(new ID(user, 0), new ID(user, Number.MAX_VALUE), function (n) {
|
||||
// cases:
|
||||
// 1. d deletes something to the right of n
|
||||
// => go to next n (break)
|
||||
// 2. d deletes something to the left of n
|
||||
// => create deletions
|
||||
// => reset d accordingly
|
||||
// *)=> if d doesn't delete anything anymore, go to next d (continue)
|
||||
// 3. not 2) and d deletes something that also n deletes
|
||||
// => reset d so that it doesn't contain n's deletion
|
||||
// *)=> if d does not delete anything anymore, go to next d (continue)
|
||||
while (d != null) {
|
||||
var diff = 0 // describe the diff of length in 1) and 2)
|
||||
if (n._id.clock + n.len <= d[0]) {
|
||||
// 1)
|
||||
break
|
||||
} else if (d[0] < n._id.clock) {
|
||||
// 2)
|
||||
// delete maximum the len of d
|
||||
// else delete as much as possible
|
||||
diff = Math.min(n._id.clock - d[0], d[1])
|
||||
// deleteItemRange(y, user, d[0], diff, true)
|
||||
deletions.push([user, d[0], diff])
|
||||
} else {
|
||||
// 3)
|
||||
diff = n._id.clock + n.len - d[0] // never null (see 1)
|
||||
if (d[2] && !n.gc) {
|
||||
// d marks as gc'd but n does not
|
||||
// then delete either way
|
||||
// deleteItemRange(y, user, d[0], Math.min(diff, d[1]), true)
|
||||
deletions.push([user, d[0], Math.min(diff, d[1])])
|
||||
}
|
||||
}
|
||||
if (d[1] <= diff) {
|
||||
// d doesn't delete anything anymore
|
||||
d = dv[++pos]
|
||||
} else {
|
||||
d[0] = d[0] + diff // reset pos
|
||||
d[1] = d[1] - diff // reset length
|
||||
}
|
||||
}
|
||||
})
|
||||
// TODO: It would be more performant to apply the deletes in the above loop
|
||||
// Adapt the Tree implementation to support delete while iterating
|
||||
for (let i = deletions.length - 1; i >= 0; i--) {
|
||||
const del = deletions[i]
|
||||
deleteItemRange(y, del[0], del[1], del[2], true)
|
||||
}
|
||||
// for the rest.. just apply it
|
||||
for (; pos < dv.length; pos++) {
|
||||
d = dv[pos]
|
||||
deleteItemRange(y, user, d[0], d[1], true)
|
||||
// deletions.push([user, d[0], d[1], d[2]])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { getStruct } from '../Util/structReferences.mjs'
|
||||
import BinaryDecoder from '../Util/Binary/Decoder.mjs'
|
||||
import { logID } from './messageToString.mjs'
|
||||
import GC from '../Struct/GC.mjs'
|
||||
|
||||
class MissingEntry {
|
||||
constructor (decoder, missing, struct) {
|
||||
this.decoder = decoder
|
||||
this.missing = missing.length
|
||||
this.struct = struct
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Integrate remote struct
|
||||
* When a remote struct is integrated, other structs might be ready to ready to
|
||||
* integrate.
|
||||
*/
|
||||
function _integrateRemoteStructHelper (y, struct) {
|
||||
const id = struct._id
|
||||
if (id === undefined) {
|
||||
struct._integrate(y)
|
||||
} else {
|
||||
if (y.ss.getState(id.user) > id.clock) {
|
||||
return
|
||||
}
|
||||
if (!y.gcEnabled || struct.constructor === GC || (struct._parent.constructor !== GC && struct._parent._deleted === false)) {
|
||||
// Is either a GC or Item with an undeleted parent
|
||||
// save to integrate
|
||||
struct._integrate(y)
|
||||
} else {
|
||||
// Is an Item. parent was deleted.
|
||||
struct._gc(y)
|
||||
}
|
||||
let msu = y._missingStructs.get(id.user)
|
||||
if (msu != null) {
|
||||
let clock = id.clock
|
||||
const finalClock = clock + struct._length
|
||||
for (;clock < finalClock; clock++) {
|
||||
const missingStructs = msu.get(clock)
|
||||
if (missingStructs !== undefined) {
|
||||
missingStructs.forEach(missingDef => {
|
||||
missingDef.missing--
|
||||
if (missingDef.missing === 0) {
|
||||
const decoder = missingDef.decoder
|
||||
let oldPos = decoder.pos
|
||||
let missing = missingDef.struct._fromBinary(y, decoder)
|
||||
decoder.pos = oldPos
|
||||
if (missing.length === 0) {
|
||||
y._readyToIntegrate.push(missingDef.struct)
|
||||
}
|
||||
}
|
||||
})
|
||||
msu.delete(clock)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyStructs (y, decoder, strBuilder) {
|
||||
const len = decoder.readUint32()
|
||||
for (let i = 0; i < len; i++) {
|
||||
let reference = decoder.readVarUint()
|
||||
let Constr = getStruct(reference)
|
||||
let struct = new Constr()
|
||||
let missing = struct._fromBinary(y, decoder)
|
||||
let logMessage = ' ' + struct._logString()
|
||||
if (missing.length > 0) {
|
||||
logMessage += ' .. missing: ' + missing.map(logID).join(', ')
|
||||
}
|
||||
strBuilder.push(logMessage)
|
||||
}
|
||||
}
|
||||
|
||||
export function integrateRemoteStructs (y, decoder) {
|
||||
const len = decoder.readUint32()
|
||||
for (let i = 0; i < len; i++) {
|
||||
let reference = decoder.readVarUint()
|
||||
let Constr = getStruct(reference)
|
||||
let struct = new Constr()
|
||||
let decoderPos = decoder.pos
|
||||
let missing = struct._fromBinary(y, decoder)
|
||||
if (missing.length === 0) {
|
||||
while (struct != null) {
|
||||
_integrateRemoteStructHelper(y, struct)
|
||||
struct = y._readyToIntegrate.shift()
|
||||
}
|
||||
} else {
|
||||
let _decoder = new BinaryDecoder(decoder.uint8arr)
|
||||
_decoder.pos = decoderPos
|
||||
let missingEntry = new MissingEntry(_decoder, missing, struct)
|
||||
let missingStructs = y._missingStructs
|
||||
for (let i = missing.length - 1; i >= 0; i--) {
|
||||
let m = missing[i]
|
||||
if (!missingStructs.has(m.user)) {
|
||||
missingStructs.set(m.user, new Map())
|
||||
}
|
||||
let msu = missingStructs.get(m.user)
|
||||
if (!msu.has(m.clock)) {
|
||||
msu.set(m.clock, [])
|
||||
}
|
||||
let mArray = msu = msu.get(m.clock)
|
||||
mArray.push(missingEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import BinaryDecoder from '../Util/Binary/Decoder.mjs'
|
||||
import { stringifyStructs } from './integrateRemoteStructs.mjs'
|
||||
import { stringifySyncStep1 } from './syncStep1.mjs'
|
||||
import { stringifySyncStep2 } from './syncStep2.mjs'
|
||||
import ID from '../Util/ID/ID.mjs'
|
||||
import RootID from '../Util/ID/RootID.mjs'
|
||||
import Y from '../Y.mjs'
|
||||
|
||||
export function messageToString ([y, buffer]) {
|
||||
let decoder = new BinaryDecoder(buffer)
|
||||
decoder.readVarString() // read roomname
|
||||
let type = decoder.readVarString()
|
||||
let strBuilder = []
|
||||
strBuilder.push('\n === ' + type + ' ===')
|
||||
if (type === 'update') {
|
||||
stringifyStructs(y, decoder, strBuilder)
|
||||
} else if (type === 'sync step 1') {
|
||||
stringifySyncStep1(y, decoder, strBuilder)
|
||||
} else if (type === 'sync step 2') {
|
||||
stringifySyncStep2(y, decoder, strBuilder)
|
||||
} else {
|
||||
strBuilder.push('-- Unknown message type - probably an encoding issue!!!')
|
||||
}
|
||||
return strBuilder.join('\n')
|
||||
}
|
||||
|
||||
export function messageToRoomname (buffer) {
|
||||
let decoder = new BinaryDecoder(buffer)
|
||||
decoder.readVarString() // roomname
|
||||
return decoder.readVarString() // messageType
|
||||
}
|
||||
|
||||
export function logID (id) {
|
||||
if (id !== null && id._id != null) {
|
||||
id = id._id
|
||||
}
|
||||
if (id === null) {
|
||||
return '()'
|
||||
} else if (id instanceof ID) {
|
||||
return `(${id.user},${id.clock})`
|
||||
} else if (id instanceof RootID) {
|
||||
return `(${id.name},${id.type})`
|
||||
} else if (id.constructor === Y) {
|
||||
return `y`
|
||||
} else {
|
||||
throw new Error('This is not a valid ID!')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper utility to convert an item to a readable format.
|
||||
*
|
||||
* @param {String} name The name of the item class (YText, ItemString, ..).
|
||||
* @param {Item} item The item instance.
|
||||
* @param {String} [append] Additional information to append to the returned
|
||||
* string.
|
||||
* @return {String} A readable string that represents the item object.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function logItemHelper (name, item, append) {
|
||||
const left = item._left !== null ? item._left._lastId : null
|
||||
const origin = item._origin !== null ? item._origin._lastId : null
|
||||
return `${name}(id:${logID(item._id)},left:${logID(left)},origin:${logID(origin)},right:${logID(item._right)},parent:${logID(item._parent)},parentSub:${item._parentSub}${append !== undefined ? ' - ' + append : ''})`
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
export function readStateSet (decoder) {
|
||||
let ss = new Map()
|
||||
let ssLength = decoder.readUint32()
|
||||
for (let i = 0; i < ssLength; i++) {
|
||||
let user = decoder.readVarUint()
|
||||
let clock = decoder.readVarUint()
|
||||
ss.set(user, clock)
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
export function writeStateSet (y, encoder) {
|
||||
let lenPosition = encoder.pos
|
||||
let len = 0
|
||||
encoder.writeUint32(0)
|
||||
for (let [user, clock] of y.ss.state) {
|
||||
encoder.writeVarUint(user)
|
||||
encoder.writeVarUint(clock)
|
||||
len++
|
||||
}
|
||||
encoder.setUint32(lenPosition, len)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import BinaryEncoder from '../Util/Binary/Encoder.mjs'
|
||||
import { readStateSet, writeStateSet } from './stateSet.mjs'
|
||||
import { writeDeleteSet } from './deleteSet.mjs'
|
||||
import ID from '../Util/ID/ID.mjs'
|
||||
import { RootFakeUserID } from '../Util/ID/RootID.mjs'
|
||||
|
||||
export function stringifySyncStep1 (y, decoder, strBuilder) {
|
||||
let auth = decoder.readVarString()
|
||||
let protocolVersion = decoder.readVarUint()
|
||||
strBuilder.push(` - auth: "${auth}"`)
|
||||
strBuilder.push(` - protocolVersion: ${protocolVersion}`)
|
||||
// write SS
|
||||
let ssBuilder = []
|
||||
let len = decoder.readUint32()
|
||||
for (let i = 0; i < len; i++) {
|
||||
let user = decoder.readVarUint()
|
||||
let clock = decoder.readVarUint()
|
||||
ssBuilder.push(`(${user}:${clock})`)
|
||||
}
|
||||
strBuilder.push(' == SS: ' + ssBuilder.join(','))
|
||||
}
|
||||
|
||||
export function sendSyncStep1 (connector, syncUser) {
|
||||
let encoder = new BinaryEncoder()
|
||||
encoder.writeVarString(connector.y.room)
|
||||
encoder.writeVarString('sync step 1')
|
||||
encoder.writeVarString(connector.authInfo || '')
|
||||
encoder.writeVarUint(connector.protocolVersion)
|
||||
writeStateSet(connector.y, encoder)
|
||||
connector.send(syncUser, encoder.createBuffer())
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Write all Items that are not not included in ss to
|
||||
* the encoder object.
|
||||
*/
|
||||
export function writeStructs (y, encoder, ss) {
|
||||
const lenPos = encoder.pos
|
||||
encoder.writeUint32(0)
|
||||
let len = 0
|
||||
for (let user of y.ss.state.keys()) {
|
||||
let clock = ss.get(user) || 0
|
||||
if (user !== RootFakeUserID) {
|
||||
const minBound = new ID(user, clock)
|
||||
const overlappingLeft = y.os.findPrev(minBound)
|
||||
const rightID = overlappingLeft === null ? null : overlappingLeft._id
|
||||
if (rightID !== null && rightID.user === user && rightID.clock + overlappingLeft._length > clock) {
|
||||
const struct = overlappingLeft._clonePartial(clock - rightID.clock)
|
||||
struct._toBinary(encoder)
|
||||
len++
|
||||
}
|
||||
y.os.iterate(minBound, new ID(user, Number.MAX_VALUE), function (struct) {
|
||||
struct._toBinary(encoder)
|
||||
len++
|
||||
})
|
||||
}
|
||||
}
|
||||
encoder.setUint32(lenPos, len)
|
||||
}
|
||||
|
||||
export function readSyncStep1 (decoder, encoder, y, senderConn, sender) {
|
||||
let protocolVersion = decoder.readVarUint()
|
||||
// check protocol version
|
||||
if (protocolVersion !== y.connector.protocolVersion) {
|
||||
console.warn(
|
||||
`You tried to sync with a Yjs instance that has a different protocol version
|
||||
(You: ${protocolVersion}, Client: ${protocolVersion}).
|
||||
`)
|
||||
y.destroy()
|
||||
}
|
||||
// write sync step 2
|
||||
encoder.writeVarString('sync step 2')
|
||||
encoder.writeVarString(y.connector.authInfo || '')
|
||||
const ss = readStateSet(decoder)
|
||||
writeStructs(y, encoder, ss)
|
||||
writeDeleteSet(y, encoder)
|
||||
y.connector.send(senderConn.uid, encoder.createBuffer())
|
||||
senderConn.receivedSyncStep2 = true
|
||||
if (y.connector.role === 'slave') {
|
||||
sendSyncStep1(y.connector, sender)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { stringifyStructs, integrateRemoteStructs } from './integrateRemoteStructs.mjs'
|
||||
import { readDeleteSet } from './deleteSet.mjs'
|
||||
|
||||
export function stringifySyncStep2 (y, decoder, strBuilder) {
|
||||
strBuilder.push(' - auth: ' + decoder.readVarString())
|
||||
strBuilder.push(' == OS:')
|
||||
stringifyStructs(y, decoder, strBuilder)
|
||||
// write DS to string
|
||||
strBuilder.push(' == DS:')
|
||||
let len = decoder.readUint32()
|
||||
for (let i = 0; i < len; i++) {
|
||||
let user = decoder.readVarUint()
|
||||
strBuilder.push(` User: ${user}: `)
|
||||
let len2 = decoder.readUint32()
|
||||
for (let j = 0; j < len2; j++) {
|
||||
let from = decoder.readVarUint()
|
||||
let to = decoder.readVarUint()
|
||||
let gc = decoder.readUint8() === 1
|
||||
strBuilder.push(`[${from}, ${to}, ${gc}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readSyncStep2 (decoder, encoder, y, senderConn, sender) {
|
||||
integrateRemoteStructs(y, decoder)
|
||||
readDeleteSet(y, decoder)
|
||||
y.connector._setSyncedWith(sender)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import BinaryEncoder from './Util/Binary/Encoder.mjs'
|
||||
import BinaryDecoder from './Util/Binary/Decoder.mjs'
|
||||
import { toBinary, fromBinary } from './MessageHandler/binaryEncode.mjs'
|
||||
import { integrateRemoteStructs } from './MessageHandler/integrateRemoteStructs.mjs'
|
||||
import { createMutualExclude } from './Util/mutualExclude.mjs'
|
||||
|
||||
function getFreshCnf () {
|
||||
let buffer = new BinaryEncoder()
|
||||
buffer.writeUint32(0)
|
||||
return {
|
||||
len: 0,
|
||||
buffer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract persistence class.
|
||||
*/
|
||||
export default class AbstractPersistence {
|
||||
constructor (opts) {
|
||||
this.opts = opts
|
||||
this.ys = new Map()
|
||||
}
|
||||
|
||||
_init (y) {
|
||||
let cnf = this.ys.get(y)
|
||||
if (cnf === undefined) {
|
||||
cnf = getFreshCnf()
|
||||
cnf.mutualExclude = createMutualExclude()
|
||||
this.ys.set(y, cnf)
|
||||
return this.init(y).then(() => {
|
||||
y.on('afterTransaction', (y, transaction) => {
|
||||
let cnf = this.ys.get(y)
|
||||
if (cnf.len > 0) {
|
||||
cnf.buffer.setUint32(0, cnf.len)
|
||||
this.saveUpdate(y, cnf.buffer.createBuffer(), transaction)
|
||||
let _cnf = getFreshCnf()
|
||||
for (let key in _cnf) {
|
||||
cnf[key] = _cnf[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
return this.retrieve(y)
|
||||
}).then(function () {
|
||||
return Promise.resolve(cnf)
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve(cnf)
|
||||
}
|
||||
}
|
||||
deinit (y) {
|
||||
this.ys.delete(y)
|
||||
y.persistence = null
|
||||
}
|
||||
|
||||
destroy () {
|
||||
this.ys = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all persisted data that belongs to a room.
|
||||
* Automatically destroys all Yjs all Yjs instances that persist to
|
||||
* the room. If `destroyYjsInstances = false` the persistence functionality
|
||||
* will be removed from the Yjs instances.
|
||||
*
|
||||
* ** Must be overwritten! **
|
||||
*/
|
||||
removePersistedData (room, destroyYjsInstances = true) {
|
||||
this.ys.forEach((cnf, y) => {
|
||||
if (y.room === room) {
|
||||
if (destroyYjsInstances) {
|
||||
y.destroy()
|
||||
} else {
|
||||
this.deinit(y)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* overwrite */
|
||||
saveUpdate (buffer) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save struct to update buffer.
|
||||
* saveUpdate is called when transaction ends
|
||||
*/
|
||||
saveStruct (y, struct) {
|
||||
let cnf = this.ys.get(y)
|
||||
if (cnf !== undefined) {
|
||||
cnf.mutualExclude(function () {
|
||||
struct._toBinary(cnf.buffer)
|
||||
cnf.len++
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* overwrite */
|
||||
retrieve (y, model, updates) {
|
||||
let cnf = this.ys.get(y)
|
||||
if (cnf !== undefined) {
|
||||
cnf.mutualExclude(function () {
|
||||
y.transact(function () {
|
||||
if (model != null) {
|
||||
fromBinary(y, new BinaryDecoder(new Uint8Array(model)))
|
||||
}
|
||||
if (updates != null) {
|
||||
for (let i = 0; i < updates.length; i++) {
|
||||
integrateRemoteStructs(y, new BinaryDecoder(new Uint8Array(updates[i])))
|
||||
}
|
||||
}
|
||||
})
|
||||
y.emit('persistenceReady')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* overwrite */
|
||||
persist (y) {
|
||||
return toBinary(y).createBuffer()
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
export default class AbstractPersistence {}
|
||||
@@ -1,72 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import BinaryDecoder from '../Util/Binary/Decoder.mjs'
|
||||
import BinaryEncoder from '../Util/Binary/Encoder.mjs'
|
||||
import { createMutualExclude } from '../Util/mutualExclude.mjs'
|
||||
import { encodeUpdate, encodeStructsDS, decodePersisted } from './decodePersisted.mjs'
|
||||
|
||||
function createFilePath (persistence, roomName) {
|
||||
// TODO: filename checking!
|
||||
return path.join(persistence.dir, roomName)
|
||||
}
|
||||
|
||||
export default class FilePersistence {
|
||||
constructor (dir) {
|
||||
this.dir = dir
|
||||
this._mutex = createMutualExclude()
|
||||
}
|
||||
setRemoteUpdateCounter (roomName, remoteUpdateCounter) {
|
||||
// TODO: implement
|
||||
// nop
|
||||
}
|
||||
saveUpdate (room, y, encodedStructs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._mutex(() => {
|
||||
const filePath = createFilePath(this, room)
|
||||
const updateMessage = new BinaryEncoder()
|
||||
encodeUpdate(y, encodedStructs, updateMessage)
|
||||
fs.appendFile(filePath, Buffer.from(updateMessage.createBuffer()), (err) => {
|
||||
if (err !== null) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}, resolve)
|
||||
})
|
||||
}
|
||||
saveState (roomName, y) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const encoder = new BinaryEncoder()
|
||||
encodeStructsDS(y, encoder)
|
||||
const filePath = createFilePath(this, roomName)
|
||||
fs.writeFile(filePath, Buffer.from(encoder.createBuffer()), (err) => {
|
||||
if (err !== null) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
readState (roomName, y) {
|
||||
// Check if the file exists in the current directory.
|
||||
return new Promise((resolve, reject) => {
|
||||
const filePath = path.join(this.dir, roomName)
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err !== null) {
|
||||
resolve()
|
||||
// reject(err)
|
||||
} else {
|
||||
this._mutex(() => {
|
||||
console.info(`unpacking data (${data.length})`)
|
||||
console.time('unpacking')
|
||||
decodePersisted(y, new BinaryDecoder(data))
|
||||
console.timeEnd('unpacking')
|
||||
})
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
/* global indexedDB, location, BroadcastChannel */
|
||||
|
||||
import Y from '../Y.mjs'
|
||||
import { createMutualExclude } from '../Util/mutualExclude.mjs'
|
||||
import { decodePersisted, encodeStructsDS, encodeUpdate } from './decodePersisted.mjs'
|
||||
import BinaryDecoder from '../Util/Binary/Decoder.mjs'
|
||||
import BinaryEncoder from '../Util/Binary/Encoder.mjs'
|
||||
import { PERSIST_STRUCTS_DS } from './decodePersisted.mjs';
|
||||
import { PERSIST_UPDATE } from './decodePersisted.mjs';
|
||||
/*
|
||||
* Request to Promise transformer
|
||||
*/
|
||||
function rtop (request) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
request.onerror = function (event) {
|
||||
reject(new Error(event.target.error))
|
||||
}
|
||||
request.onblocked = function () {
|
||||
location.reload()
|
||||
}
|
||||
request.onsuccess = function (event) {
|
||||
resolve(event.target.result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function openDB (room) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
let request = indexedDB.open(room)
|
||||
request.onupgradeneeded = function (event) {
|
||||
const db = event.target.result
|
||||
if (db.objectStoreNames.contains('updates')) {
|
||||
db.deleteObjectStore('updates')
|
||||
}
|
||||
db.createObjectStore('updates', {autoIncrement: true})
|
||||
}
|
||||
request.onerror = function (event) {
|
||||
reject(new Error(event.target.error))
|
||||
}
|
||||
request.onblocked = function () {
|
||||
location.reload()
|
||||
}
|
||||
request.onsuccess = function (event) {
|
||||
const db = event.target.result
|
||||
db.onversionchange = function () { db.close() }
|
||||
resolve(db)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function persist (room) {
|
||||
let t = room.db.transaction(['updates'], 'readwrite')
|
||||
let updatesStore = t.objectStore('updates')
|
||||
return rtop(updatesStore.getAll())
|
||||
.then(updates => {
|
||||
// apply all previous updates before deleting them
|
||||
room.mutex(() => {
|
||||
updates.forEach(update => {
|
||||
decodePersisted(y, new BinaryDecoder(update))
|
||||
})
|
||||
})
|
||||
const encoder = new BinaryEncoder()
|
||||
encodeStructsDS(y, encoder)
|
||||
// delete all pending updates
|
||||
rtop(updatesStore.clear()).then(() => {
|
||||
// write current model
|
||||
updatesStore.put(encoder.createBuffer())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function saveUpdate (room, updateBuffer) {
|
||||
const db = room.db
|
||||
if (db !== null) {
|
||||
const t = db.transaction(['updates'], 'readwrite')
|
||||
const updatesStore = t.objectStore('updates')
|
||||
const updatePut = rtop(updatesStore.put(updateBuffer))
|
||||
rtop(updatesStore.count()).then(cnt => {
|
||||
if (cnt >= PREFERRED_TRIM_SIZE) {
|
||||
persist(room)
|
||||
}
|
||||
})
|
||||
return updatePut
|
||||
}
|
||||
}
|
||||
|
||||
function registerRoomInPersistence (documentsDB, roomName) {
|
||||
return documentsDB.then(
|
||||
db => Promise.all([
|
||||
db,
|
||||
rtop(db.transaction(['documents'], 'readonly').objectStore('documents').get(roomName))
|
||||
])
|
||||
).then(
|
||||
([db, doc]) => {
|
||||
if (doc === undefined) {
|
||||
return rtop(db.transaction(['documents'], 'readwrite').objectStore('documents').add({ roomName, serverUpdateCounter: 0 }))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const PREFERRED_TRIM_SIZE = 400
|
||||
|
||||
export default class IndexedDBPersistence {
|
||||
constructor () {
|
||||
this._rooms = new Map()
|
||||
this._documentsDB = new Promise(function (resolve, reject) {
|
||||
let request = indexedDB.open('_yjs_documents')
|
||||
request.onupgradeneeded = function (event) {
|
||||
const db = event.target.result
|
||||
if (db.objectStoreNames.contains('documents')) {
|
||||
db.deleteObjectStore('documents')
|
||||
}
|
||||
db.createObjectStore('documents', { keyPath: "roomName" })
|
||||
}
|
||||
request.onerror = function (event) {
|
||||
reject(new Error(event.target.error))
|
||||
}
|
||||
request.onblocked = function () {
|
||||
location.reload()
|
||||
}
|
||||
request.onsuccess = function (event) {
|
||||
const db = event.target.result
|
||||
db.onversionchange = function () { db.close() }
|
||||
resolve(db)
|
||||
}
|
||||
})
|
||||
addEventListener('unload', () => {
|
||||
// close everything when page unloads
|
||||
this._rooms.forEach(room => {
|
||||
if (room.db !== null) {
|
||||
room.db.close()
|
||||
} else {
|
||||
room.dbPromise.then(db => db.close())
|
||||
}
|
||||
})
|
||||
this._documentsDB.then(db => db.close())
|
||||
})
|
||||
}
|
||||
getAllDocuments () {
|
||||
return this._documentsDB.then(
|
||||
db => rtop(db.transaction(['documents'], 'readonly').objectStore('documents').getAll())
|
||||
)
|
||||
}
|
||||
setRemoteUpdateCounter (roomName, remoteUpdateCounter) {
|
||||
this._documentsDB.then(
|
||||
db => rtop(db.transaction(['documents'], 'readwrite').objectStore('documents').put({ roomName, remoteUpdateCounter }))
|
||||
)
|
||||
}
|
||||
|
||||
_createYInstance (roomName) {
|
||||
const room = this._rooms.get(roomName)
|
||||
if (room !== undefined) {
|
||||
return room.y
|
||||
}
|
||||
const y = new Y()
|
||||
return openDB(roomName).then(
|
||||
db => rtop(db.transaction(['updates'], 'readonly').objectStore('updates').getAll())
|
||||
).then(
|
||||
updates =>
|
||||
y.transact(() => {
|
||||
updates.forEach(update => {
|
||||
decodePersisted(y, new BinaryDecoder(update))
|
||||
})
|
||||
}, true)
|
||||
).then(() => Promise.resolve(y))
|
||||
}
|
||||
|
||||
_persistStructsDS (roomName, structsDS) {
|
||||
const encoder = new BinaryEncoder()
|
||||
encoder.writeVarUint(PERSIST_STRUCTS_DS)
|
||||
encoder.writeArrayBuffer(structsDS)
|
||||
return openDB(roomName).then(db => {
|
||||
const t = db.transaction(['updates'], 'readwrite')
|
||||
const updatesStore = t.objectStore('updates')
|
||||
return rtop(updatesStore.put(encoder.createBuffer()))
|
||||
})
|
||||
}
|
||||
|
||||
_persistStructs (roomName, structs) {
|
||||
const encoder = new BinaryEncoder()
|
||||
encoder.writeVarUint(PERSIST_UPDATE)
|
||||
encoder.writeArrayBuffer(structs)
|
||||
return openDB(roomName).then(db => {
|
||||
const t = db.transaction(['updates'], 'readwrite')
|
||||
const updatesStore = t.objectStore('updates')
|
||||
return rtop(updatesStore.put(encoder.createBuffer()))
|
||||
})
|
||||
}
|
||||
|
||||
connectY (roomName, y) {
|
||||
if (this._rooms.has(roomName)) {
|
||||
throw new Error('A Y instance is already bound to this room!')
|
||||
}
|
||||
let room = {
|
||||
db: null,
|
||||
dbPromise: null,
|
||||
channel: null,
|
||||
mutex: createMutualExclude(),
|
||||
y
|
||||
}
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
room.channel = new BroadcastChannel('__yjs__' + roomName)
|
||||
room.channel.addEventListener('message', e => {
|
||||
room.mutex(function () {
|
||||
decodePersisted(y, new BinaryDecoder(e.data))
|
||||
})
|
||||
})
|
||||
}
|
||||
y.on('destroyed', () => {
|
||||
this.disconnectY(roomName, y)
|
||||
})
|
||||
y.on('afterTransaction', (y, transaction) => {
|
||||
room.mutex(() => {
|
||||
if (transaction.encodedStructsLen > 0) {
|
||||
const encoder = new BinaryEncoder()
|
||||
const update = new BinaryEncoder()
|
||||
encodeUpdate(y, transaction.encodedStructs, update)
|
||||
const updateBuffer = update.createBuffer()
|
||||
if (room.channel !== null) {
|
||||
room.channel.postMessage(updateBuffer)
|
||||
}
|
||||
if (transaction.encodedStructsLen > 0) {
|
||||
if (room.db !== null) {
|
||||
saveUpdate(room, updateBuffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
// register document in documentsDB
|
||||
this._documentsDB.then(
|
||||
db =>
|
||||
rtop(db.transaction(['documents'], 'readonly').objectStore('documents').get(roomName))
|
||||
.then(
|
||||
doc => doc === undefined && rtop(db.transaction(['documents'], 'readwrite').objectStore('documents').add({ roomName, serverUpdateCounter: -1 }))
|
||||
)
|
||||
)
|
||||
// open room db and read existing data
|
||||
return room.dbPromise = openDB(roomName)
|
||||
.then(db => {
|
||||
room.db = db
|
||||
const t = room.db.transaction(['updates'], 'readwrite')
|
||||
const updatesStore = t.objectStore('updates')
|
||||
// write current state as update
|
||||
const encoder = new BinaryEncoder()
|
||||
encodeStructsDS(y, encoder)
|
||||
return rtop(updatesStore.put(encoder.createBuffer())).then(() => {
|
||||
// read persisted state
|
||||
return rtop(updatesStore.getAll()).then(updates => {
|
||||
room.mutex(() => {
|
||||
y.transact(() => {
|
||||
updates.forEach(update => {
|
||||
decodePersisted(y, new BinaryDecoder(update))
|
||||
})
|
||||
}, true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
disconnectY (roomName) {
|
||||
const {
|
||||
db, channel
|
||||
} = this._rooms.get(roomName)
|
||||
db.close()
|
||||
if (channel !== null) {
|
||||
channel.close()
|
||||
}
|
||||
this._rooms.delete(roomName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all persisted data that belongs to a room.
|
||||
* Automatically destroys all Yjs all Yjs instances that persist to
|
||||
* the room. If `destroyYjsInstances = false` the persistence functionality
|
||||
* will be removed from the Yjs instances.
|
||||
*/
|
||||
removePersistedData (roomName, destroyYjsInstances = true) {
|
||||
this.disconnectY(roomName)
|
||||
return rtop(indexedDB.deleteDatabase(roomName))
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { integrateRemoteStructs } from '../MessageHandler/integrateRemoteStructs.mjs'
|
||||
import { writeStructs } from '../MessageHandler/syncStep1.mjs'
|
||||
import { writeDeleteSet, readDeleteSet } from '../MessageHandler/deleteSet.mjs'
|
||||
|
||||
export const PERSIST_UPDATE = 0
|
||||
/**
|
||||
* Write an update to an encoder.
|
||||
*
|
||||
* @param {Yjs} y A Yjs instance
|
||||
* @param {BinaryEncoder} updateEncoder I.e. transaction.encodedStructs
|
||||
*/
|
||||
export function encodeUpdate (y, updateEncoder, encoder) {
|
||||
encoder.writeVarUint(PERSIST_UPDATE)
|
||||
encoder.writeBinaryEncoder(updateEncoder)
|
||||
}
|
||||
|
||||
export const PERSIST_STRUCTS_DS = 1
|
||||
|
||||
/**
|
||||
* Write the current Yjs data model to an encoder.
|
||||
*
|
||||
* @param {Yjs} y A Yjs instance
|
||||
* @param {BinaryEncoder} encoder An encoder to write to
|
||||
*/
|
||||
export function encodeStructsDS (y, encoder) {
|
||||
encoder.writeVarUint(PERSIST_STRUCTS_DS)
|
||||
writeStructs(y, encoder, new Map())
|
||||
writeDeleteSet(y, encoder)
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed the Yjs instance with the persisted state
|
||||
* @param {Yjs} y A Yjs instance.
|
||||
* @param {BinaryDecoder} decoder A Decoder instance that holds the file content.
|
||||
*/
|
||||
export function decodePersisted (y, decoder) {
|
||||
y.transact(() => {
|
||||
while (decoder.hasContent()) {
|
||||
const contentType = decoder.readVarUint()
|
||||
switch (contentType) {
|
||||
case PERSIST_UPDATE:
|
||||
integrateRemoteStructs(y, decoder)
|
||||
break
|
||||
case PERSIST_STRUCTS_DS:
|
||||
integrateRemoteStructs(y, decoder)
|
||||
readDeleteSet(y, decoder)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, true)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
|
||||
import Tree from '../Util/Tree.mjs'
|
||||
import ID from '../Util/ID/ID.mjs'
|
||||
|
||||
class DSNode {
|
||||
constructor (id, len, gc) {
|
||||
this._id = id
|
||||
this.len = len
|
||||
this.gc = gc
|
||||
}
|
||||
clone () {
|
||||
return new DSNode(this._id, this.len, this.gc)
|
||||
}
|
||||
}
|
||||
|
||||
export default class DeleteStore extends Tree {
|
||||
logTable () {
|
||||
const deletes = []
|
||||
this.iterate(null, null, function (n) {
|
||||
deletes.push({
|
||||
user: n._id.user,
|
||||
clock: n._id.clock,
|
||||
len: n.len,
|
||||
gc: n.gc
|
||||
})
|
||||
})
|
||||
console.table(deletes)
|
||||
}
|
||||
isDeleted (id) {
|
||||
var n = this.findWithUpperBound(id)
|
||||
return n !== null && n._id.user === id.user && id.clock < n._id.clock + n.len
|
||||
}
|
||||
mark (id, length, gc) {
|
||||
if (length === 0) return
|
||||
// Step 1. Unmark range
|
||||
const leftD = this.findWithUpperBound(new ID(id.user, id.clock - 1))
|
||||
// Resize left DSNode if necessary
|
||||
if (leftD !== null && leftD._id.user === id.user) {
|
||||
if (leftD._id.clock < id.clock && id.clock < leftD._id.clock + leftD.len) {
|
||||
// node is overlapping. need to resize
|
||||
if (id.clock + length < leftD._id.clock + leftD.len) {
|
||||
// overlaps new mark range and some more
|
||||
// create another DSNode to the right of new mark
|
||||
this.put(new DSNode(new ID(id.user, id.clock + length), leftD._id.clock + leftD.len - id.clock - length, leftD.gc))
|
||||
}
|
||||
// resize left DSNode
|
||||
leftD.len = id.clock - leftD._id.clock
|
||||
} // Otherwise there is no overlapping
|
||||
}
|
||||
// Resize right DSNode if necessary
|
||||
const upper = new ID(id.user, id.clock + length - 1)
|
||||
const rightD = this.findWithUpperBound(upper)
|
||||
if (rightD !== null && rightD._id.user === id.user) {
|
||||
if (rightD._id.clock < id.clock + length && id.clock <= rightD._id.clock && id.clock + length < rightD._id.clock + rightD.len) { // we only consider the case where we resize the node
|
||||
const d = id.clock + length - rightD._id.clock
|
||||
rightD._id = new ID(rightD._id.user, rightD._id.clock + d)
|
||||
rightD.len -= d
|
||||
}
|
||||
}
|
||||
// Now we only have to delete all inner marks
|
||||
const deleteNodeIds = []
|
||||
this.iterate(id, upper, m => {
|
||||
deleteNodeIds.push(m._id)
|
||||
})
|
||||
for (let i = deleteNodeIds.length - 1; i >= 0; i--) {
|
||||
this.delete(deleteNodeIds[i])
|
||||
}
|
||||
let newMark = new DSNode(id, length, gc)
|
||||
// Step 2. Check if we can extend left or right
|
||||
if (leftD !== null && leftD._id.user === id.user && leftD._id.clock + leftD.len === id.clock && leftD.gc === gc) {
|
||||
// We can extend left
|
||||
leftD.len += length
|
||||
newMark = leftD
|
||||
}
|
||||
const rightNext = this.find(new ID(id.user, id.clock + length))
|
||||
if (rightNext !== null && rightNext._id.user === id.user && id.clock + length === rightNext._id.clock && gc === rightNext.gc) {
|
||||
// We can merge newMark and rightNext
|
||||
newMark.len += rightNext.len
|
||||
this.delete(rightNext._id)
|
||||
}
|
||||
if (leftD !== newMark) {
|
||||
// only put if we didn't extend left
|
||||
this.put(newMark)
|
||||
}
|
||||
}
|
||||
// TODO: exchange markDeleted for mark()
|
||||
markDeleted (id, length) {
|
||||
this.mark(id, length, false)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import Tree from '../Util/Tree.mjs'
|
||||
import RootID from '../Util/ID/RootID.mjs'
|
||||
import { getStruct } from '../Util/structReferences.mjs'
|
||||
import { logID } from '../MessageHandler/messageToString.mjs'
|
||||
import GC from '../Struct/GC.mjs'
|
||||
|
||||
export default class OperationStore extends Tree {
|
||||
constructor (y) {
|
||||
super()
|
||||
this.y = y
|
||||
}
|
||||
logTable () {
|
||||
const items = []
|
||||
this.iterate(null, null, function (item) {
|
||||
if (item.constructor === GC) {
|
||||
items.push({
|
||||
id: logID(item),
|
||||
content: item._length,
|
||||
deleted: 'GC'
|
||||
})
|
||||
} else {
|
||||
items.push({
|
||||
id: logID(item),
|
||||
origin: logID(item._origin === null ? null : item._origin._lastId),
|
||||
left: logID(item._left === null ? null : item._left._lastId),
|
||||
right: logID(item._right),
|
||||
right_origin: logID(item._right_origin),
|
||||
parent: logID(item._parent),
|
||||
parentSub: item._parentSub,
|
||||
deleted: item._deleted,
|
||||
content: JSON.stringify(item._content)
|
||||
})
|
||||
}
|
||||
})
|
||||
console.table(items)
|
||||
}
|
||||
get (id) {
|
||||
let struct = this.find(id)
|
||||
if (struct === null && id instanceof RootID) {
|
||||
const Constr = getStruct(id.type)
|
||||
const y = this.y
|
||||
struct = new Constr()
|
||||
struct._id = id
|
||||
struct._parent = y
|
||||
y.transact(() => {
|
||||
struct._integrate(y)
|
||||
})
|
||||
this.put(struct)
|
||||
}
|
||||
return struct
|
||||
}
|
||||
// Use getItem for structs with _length > 1
|
||||
getItem (id) {
|
||||
var item = this.findWithUpperBound(id)
|
||||
if (item === null) {
|
||||
return null
|
||||
}
|
||||
const itemID = item._id
|
||||
if (id.user === itemID.user && id.clock < itemID.clock + item._length) {
|
||||
return item
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// Return an insertion such that id is the first element of content
|
||||
// This function manipulates an item, if necessary
|
||||
getItemCleanStart (id) {
|
||||
var ins = this.getItem(id)
|
||||
if (ins === null || ins._length === 1) {
|
||||
return ins
|
||||
}
|
||||
const insID = ins._id
|
||||
if (insID.clock === id.clock) {
|
||||
return ins
|
||||
} else {
|
||||
return ins._splitAt(this.y, id.clock - insID.clock)
|
||||
}
|
||||
}
|
||||
// Return an insertion such that id is the last element of content
|
||||
// This function manipulates an operation, if necessary
|
||||
getItemCleanEnd (id) {
|
||||
var ins = this.getItem(id)
|
||||
if (ins === null || ins._length === 1) {
|
||||
return ins
|
||||
}
|
||||
const insID = ins._id
|
||||
if (insID.clock + ins._length - 1 === id.clock) {
|
||||
return ins
|
||||
} else {
|
||||
ins._splitAt(this.y, id.clock - insID.clock + 1)
|
||||
return ins
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import ID from '../Util/ID/ID.mjs'
|
||||
|
||||
export default class StateStore {
|
||||
constructor (y) {
|
||||
this.y = y
|
||||
this.state = new Map()
|
||||
}
|
||||
logTable () {
|
||||
const entries = []
|
||||
for (let [user, state] of this.state) {
|
||||
entries.push({
|
||||
user, state
|
||||
})
|
||||
}
|
||||
console.table(entries)
|
||||
}
|
||||
getNextID (len) {
|
||||
const user = this.y.userID
|
||||
const state = this.getState(user)
|
||||
this.setState(user, state + len)
|
||||
return new ID(user, state)
|
||||
}
|
||||
updateRemoteState (struct) {
|
||||
let user = struct._id.user
|
||||
let userState = this.state.get(user)
|
||||
while (struct !== null && struct._id.clock === userState) {
|
||||
userState += struct._length
|
||||
struct = this.y.os.get(new ID(user, userState))
|
||||
}
|
||||
this.state.set(user, userState)
|
||||
}
|
||||
getState (user) {
|
||||
let state = this.state.get(user)
|
||||
if (state == null) {
|
||||
return 0
|
||||
}
|
||||
return state
|
||||
}
|
||||
setState (user, state) {
|
||||
// TODO: modify missingi structs here
|
||||
const beforeState = this.y._transaction.beforeState
|
||||
if (!beforeState.has(user)) {
|
||||
beforeState.set(user, this.getState(user))
|
||||
}
|
||||
this.state.set(user, state)
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { getStructReference } from '../Util/structReferences.mjs'
|
||||
import ID from '../Util/ID/ID.mjs'
|
||||
import { logID } from '../MessageHandler/messageToString.mjs'
|
||||
import { writeStructToTransaction } from '../Transaction.mjs'
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Delete all items in an ID-range
|
||||
* TODO: implement getItemCleanStartNode for better performance (only one lookup)
|
||||
*/
|
||||
export function deleteItemRange (y, user, clock, range, gcChildren) {
|
||||
const createDelete = y.connector !== null && y.connector._forwardAppliedStructs
|
||||
let item = y.os.getItemCleanStart(new ID(user, clock))
|
||||
if (item !== null) {
|
||||
if (!item._deleted) {
|
||||
item._splitAt(y, range)
|
||||
item._delete(y, createDelete, true)
|
||||
}
|
||||
let itemLen = item._length
|
||||
range -= itemLen
|
||||
clock += itemLen
|
||||
if (range > 0) {
|
||||
let node = y.os.findNode(new ID(user, clock))
|
||||
while (node !== null && node.val !== null && range > 0 && node.val._id.equals(new ID(user, clock))) {
|
||||
const nodeVal = node.val
|
||||
if (!nodeVal._deleted) {
|
||||
nodeVal._splitAt(y, range)
|
||||
nodeVal._delete(y, createDelete, gcChildren)
|
||||
}
|
||||
const nodeLen = nodeVal._length
|
||||
range -= nodeLen
|
||||
clock += nodeLen
|
||||
node = node.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* A Delete change is not a real Item, but it provides the same interface as an
|
||||
* Item. The only difference is that it will not be saved in the ItemStore
|
||||
* (OperationStore), but instead it is safed in the DeleteStore.
|
||||
*/
|
||||
export default class Delete {
|
||||
constructor () {
|
||||
this._target = null
|
||||
this._length = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Read the next Item in a Decoder and fill this Item with the read data.
|
||||
*
|
||||
* This is called when data is received from a remote peer.
|
||||
*
|
||||
* @param {Y} y The Yjs instance that this Item belongs to.
|
||||
* @param {BinaryDecoder} decoder The decoder object to read data from.
|
||||
*/
|
||||
_fromBinary (y, decoder) {
|
||||
// TODO: set target, and add it to missing if not found
|
||||
// There is an edge case in p2p networks!
|
||||
const targetID = decoder.readID()
|
||||
this._targetID = targetID
|
||||
this._length = decoder.readVarUint()
|
||||
if (y.os.getItem(targetID) === null) {
|
||||
return [targetID]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Transform the properties of this type to binary and write it to an
|
||||
* BinaryEncoder.
|
||||
*
|
||||
* This is called when this Item is sent to a remote peer.
|
||||
*
|
||||
* @param {BinaryEncoder} encoder The encoder to write data to.
|
||||
*/
|
||||
_toBinary (encoder) {
|
||||
encoder.writeUint8(getStructReference(this.constructor))
|
||||
encoder.writeID(this._targetID)
|
||||
encoder.writeVarUint(this._length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Integrates this Item into the shared structure.
|
||||
*
|
||||
* This method actually applies the change to the Yjs instance. In the case of
|
||||
* Delete it marks the delete target as deleted.
|
||||
*
|
||||
* * If created remotely (a remote user deleted something),
|
||||
* this Delete is applied to all structs in id-range.
|
||||
* * If created lokally (e.g. when y-array deletes a range of elements),
|
||||
* this struct is broadcasted only (it is already executed)
|
||||
*/
|
||||
_integrate (y, locallyCreated = false) {
|
||||
if (!locallyCreated) {
|
||||
// from remote
|
||||
const id = this._targetID
|
||||
deleteItemRange(y, id.user, id.clock, this._length, false)
|
||||
} else if (y.connector !== null) {
|
||||
// from local
|
||||
y.connector.broadcastStruct(this)
|
||||
}
|
||||
if (y.persistence !== null) {
|
||||
y.persistence.saveStruct(y, this)
|
||||
}
|
||||
writeStructToTransaction(y._transaction, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform this YXml Type to a readable format.
|
||||
* Useful for logging as all Items and Delete implement this method.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_logString () {
|
||||
return `Delete - target: ${logID(this._targetID)}, len: ${this._length}`
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { getStructReference } from '../Util/structReferences.mjs'
|
||||
import { RootFakeUserID } from '../Util/ID/RootID.mjs'
|
||||
import ID from '../Util/ID/ID.mjs'
|
||||
import { writeStructToTransaction } from '../Transaction.mjs'
|
||||
|
||||
// TODO should have the same base class as Item
|
||||
export default class GC {
|
||||
constructor () {
|
||||
this._id = null
|
||||
this._length = 0
|
||||
}
|
||||
|
||||
get _deleted () {
|
||||
return true
|
||||
}
|
||||
|
||||
_integrate (y) {
|
||||
const id = this._id
|
||||
const userState = y.ss.getState(id.user)
|
||||
if (id.clock === userState) {
|
||||
y.ss.setState(id.user, id.clock + this._length)
|
||||
}
|
||||
y.ds.mark(this._id, this._length, true)
|
||||
let n = y.os.put(this)
|
||||
const prev = n.prev().val
|
||||
if (prev !== null && prev.constructor === GC && prev._id.user === n.val._id.user && prev._id.clock + prev._length === n.val._id.clock) {
|
||||
// TODO: do merging for all items!
|
||||
prev._length += n.val._length
|
||||
y.os.delete(n.val._id)
|
||||
n = prev
|
||||
}
|
||||
if (n.val) {
|
||||
n = n.val
|
||||
}
|
||||
const next = y.os.findNext(n._id)
|
||||
if (next !== null && next.constructor === GC && next._id.user === n._id.user && next._id.clock === n._id.clock + n._length) {
|
||||
n._length += next._length
|
||||
y.os.delete(next._id)
|
||||
}
|
||||
if (id.user !== RootFakeUserID) {
|
||||
if (y.connector !== null && (y.connector._forwardAppliedStructs || id.user === y.userID)) {
|
||||
y.connector.broadcastStruct(this)
|
||||
}
|
||||
if (y.persistence !== null) {
|
||||
y.persistence.saveStruct(y, this)
|
||||
}
|
||||
writeStructToTransaction(y._transaction, this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the properties of this type to binary and write it to an
|
||||
* BinaryEncoder.
|
||||
*
|
||||
* This is called when this Item is sent to a remote peer.
|
||||
*
|
||||
* @param {BinaryEncoder} encoder The encoder to write data to.
|
||||
* @private
|
||||
*/
|
||||
_toBinary (encoder) {
|
||||
encoder.writeUint8(getStructReference(this.constructor))
|
||||
encoder.writeID(this._id)
|
||||
encoder.writeVarUint(this._length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the next Item in a Decoder and fill this Item with the read data.
|
||||
*
|
||||
* This is called when data is received from a remote peer.
|
||||
*
|
||||
* @param {Y} y The Yjs instance that this Item belongs to.
|
||||
* @param {BinaryDecoder} decoder The decoder object to read data from.
|
||||
* @private
|
||||
*/
|
||||
_fromBinary (y, decoder) {
|
||||
const id = decoder.readID()
|
||||
this._id = id
|
||||
this._length = decoder.readVarUint()
|
||||
const missing = []
|
||||
if (y.ss.getState(id.user) < id.clock) {
|
||||
missing.push(new ID(id.user, id.clock - 1))
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
_splitAt () {
|
||||
return this
|
||||
}
|
||||
|
||||
_clonePartial (diff) {
|
||||
const gc = new GC()
|
||||
gc._id = new ID(this._id.user, this._id.clock + diff)
|
||||
gc._length = this._length - diff
|
||||
return gc
|
||||
}
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
import { getStructReference } from '../Util/structReferences.mjs'
|
||||
import ID from '../Util/ID/ID.mjs'
|
||||
import { default as RootID, RootFakeUserID } from '../Util/ID/RootID.mjs'
|
||||
import Delete from './Delete.mjs'
|
||||
import { transactionTypeChanged, writeStructToTransaction } from '../Transaction.mjs'
|
||||
import GC from './GC.mjs'
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Helper utility to split an Item (see {@link Item#_splitAt})
|
||||
* - copies all properties from a to b
|
||||
* - connects a to b
|
||||
* - assigns the correct _id
|
||||
* - saves b to os
|
||||
*/
|
||||
export function splitHelper (y, a, b, diff) {
|
||||
const aID = a._id
|
||||
b._id = new ID(aID.user, aID.clock + diff)
|
||||
b._origin = a
|
||||
b._left = a
|
||||
b._right = a._right
|
||||
if (b._right !== null) {
|
||||
b._right._left = b
|
||||
}
|
||||
b._right_origin = a._right_origin
|
||||
// do not set a._right_origin, as this will lead to problems when syncing
|
||||
a._right = b
|
||||
b._parent = a._parent
|
||||
b._parentSub = a._parentSub
|
||||
b._deleted = a._deleted
|
||||
// now search all relevant items to the right and update origin
|
||||
// if origin is not it foundOrigins, we don't have to search any longer
|
||||
let foundOrigins = new Set()
|
||||
foundOrigins.add(a)
|
||||
let o = b._right
|
||||
while (o !== null && foundOrigins.has(o._origin)) {
|
||||
if (o._origin === a) {
|
||||
o._origin = b
|
||||
}
|
||||
foundOrigins.add(o)
|
||||
o = o._right
|
||||
}
|
||||
y.os.put(b)
|
||||
if (y._transaction.newTypes.has(a)) {
|
||||
y._transaction.newTypes.add(b)
|
||||
} else if (y._transaction.deletedStructs.has(a)) {
|
||||
y._transaction.deletedStructs.add(b)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class that represents any content.
|
||||
*/
|
||||
export default class Item {
|
||||
constructor () {
|
||||
/**
|
||||
* The uniqe identifier of this type.
|
||||
* @type {ID}
|
||||
*/
|
||||
this._id = null
|
||||
/**
|
||||
* The item that was originally to the left of this item.
|
||||
* @type {Item}
|
||||
*/
|
||||
this._origin = null
|
||||
/**
|
||||
* The item that is currently to the left of this item.
|
||||
* @type {Item}
|
||||
*/
|
||||
this._left = null
|
||||
/**
|
||||
* The item that is currently to the right of this item.
|
||||
* @type {Item}
|
||||
*/
|
||||
this._right = null
|
||||
/**
|
||||
* The item that was originally to the right of this item.
|
||||
* @type {Item}
|
||||
*/
|
||||
this._right_origin = null
|
||||
/**
|
||||
* The parent type.
|
||||
* @type {Y|YType}
|
||||
*/
|
||||
this._parent = null
|
||||
/**
|
||||
* If the parent refers to this item with some kind of key (e.g. YMap, the
|
||||
* key is specified here. The key is then used to refer to the list in which
|
||||
* to insert this item. If `parentSub = null` type._start is the list in
|
||||
* which to insert to. Otherwise it is `parent._start`.
|
||||
* @type {String}
|
||||
*/
|
||||
this._parentSub = null
|
||||
/**
|
||||
* Whether this item was deleted or not.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
this._deleted = false
|
||||
/**
|
||||
* If this type's effect is reundone this type refers to the type that undid
|
||||
* this operation.
|
||||
* @type {Item}
|
||||
*/
|
||||
this._redone = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Item with the same effect as this Item (without position effect)
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_copy () {
|
||||
return new this.constructor()
|
||||
}
|
||||
|
||||
/**
|
||||
* Redoes the effect of this operation.
|
||||
*
|
||||
* @param {Y} y The Yjs instance.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_redo (y) {
|
||||
if (this._redone !== null) {
|
||||
return this._redone
|
||||
}
|
||||
let struct = this._copy()
|
||||
let left = this._left
|
||||
let right = this
|
||||
let parent = this._parent
|
||||
// make sure that parent is redone
|
||||
if (parent._deleted === true && parent._redone === null) {
|
||||
parent._redo(y)
|
||||
}
|
||||
if (parent._redone !== null) {
|
||||
parent = parent._redone
|
||||
// find next cloned items
|
||||
while (left !== null) {
|
||||
if (left._redone !== null && left._redone._parent === parent) {
|
||||
left = left._redone
|
||||
break
|
||||
}
|
||||
left = left._left
|
||||
}
|
||||
while (right !== null) {
|
||||
if (right._redone !== null && right._redone._parent === parent) {
|
||||
right = right._redone
|
||||
}
|
||||
right = right._right
|
||||
}
|
||||
}
|
||||
struct._origin = left
|
||||
struct._left = left
|
||||
struct._right = right
|
||||
struct._right_origin = right
|
||||
struct._parent = parent
|
||||
struct._parentSub = this._parentSub
|
||||
struct._integrate(y)
|
||||
this._redone = struct
|
||||
return struct
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the last content address of this Item.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
get _lastId () {
|
||||
return new ID(this._id.user, this._id.clock + this._length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the length of this Item.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
get _length () {
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return false if this Item is some kind of meta information
|
||||
* (e.g. format information).
|
||||
*
|
||||
* * Whether this Item should be addressable via `yarray.get(i)`
|
||||
* * Whether this Item should be counted when computing yarray.length
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
get _countable () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits this Item so that another Items can be inserted in-between.
|
||||
* This must be overwritten if _length > 1
|
||||
* Returns right part after split
|
||||
* * diff === 0 => this
|
||||
* * diff === length => this._right
|
||||
* * otherwise => split _content and return right part of split
|
||||
* (see {@link ItemJSON}/{@link ItemString} for implementation)
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_splitAt (y, diff) {
|
||||
if (diff === 0) {
|
||||
return this
|
||||
}
|
||||
return this._right
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this Item as deleted.
|
||||
*
|
||||
* @param {Y} y The Yjs instance
|
||||
* @param {boolean} createDelete Whether to propagate a message that this
|
||||
* Type was deleted.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_delete (y, createDelete = true) {
|
||||
if (!this._deleted) {
|
||||
this._deleted = true
|
||||
y.ds.mark(this._id, this._length, false)
|
||||
let del = new Delete()
|
||||
del._targetID = this._id
|
||||
del._length = this._length
|
||||
if (createDelete) {
|
||||
// broadcast and persists Delete
|
||||
del._integrate(y, true)
|
||||
} else if (y.persistence !== null) {
|
||||
// only persist Delete
|
||||
y.persistence.saveStruct(y, del)
|
||||
}
|
||||
transactionTypeChanged(y, this._parent, this._parentSub)
|
||||
y._transaction.deletedStructs.add(this)
|
||||
}
|
||||
}
|
||||
|
||||
_gcChildren (y) {}
|
||||
|
||||
_gc (y) {
|
||||
const gc = new GC()
|
||||
gc._id = this._id
|
||||
gc._length = this._length
|
||||
y.os.delete(this._id)
|
||||
gc._integrate(y)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called right before this Item receives any children.
|
||||
* It can be overwritten to apply pending changes before applying remote changes
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_beforeChange () {
|
||||
// nop
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrates this Item into the shared structure.
|
||||
*
|
||||
* This method actually applies the change to the Yjs instance. In case of
|
||||
* Item it connects _left and _right to this Item and calls the
|
||||
* {@link Item#beforeChange} method.
|
||||
*
|
||||
* * Integrate the struct so that other types/structs can see it
|
||||
* * Add this struct to y.os
|
||||
* * Check if this is struct deleted
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_integrate (y) {
|
||||
y._transaction.newTypes.add(this)
|
||||
const parent = this._parent
|
||||
const selfID = this._id
|
||||
const user = selfID === null ? y.userID : selfID.user
|
||||
const userState = y.ss.getState(user)
|
||||
if (selfID === null) {
|
||||
this._id = y.ss.getNextID(this._length)
|
||||
} else if (selfID.user === RootFakeUserID) {
|
||||
// nop
|
||||
} else if (selfID.clock < userState) {
|
||||
// already applied..
|
||||
return []
|
||||
} else if (selfID.clock === userState) {
|
||||
y.ss.setState(selfID.user, userState + this._length)
|
||||
} else {
|
||||
// missing content from user
|
||||
throw new Error('Can not apply yet!')
|
||||
}
|
||||
if (!parent._deleted && !y._transaction.changedTypes.has(parent) && !y._transaction.newTypes.has(parent)) {
|
||||
// this is the first time parent is updated
|
||||
// or this types is new
|
||||
this._parent._beforeChange()
|
||||
}
|
||||
|
||||
/*
|
||||
# $this has to find a unique position between origin and the next known character
|
||||
# case 1: $origin equals $o.origin: the $creator parameter decides if left or right
|
||||
# let $OL= [o1,o2,o3,o4], whereby $this is to be inserted between o1 and o4
|
||||
# o2,o3 and o4 origin is 1 (the position of o2)
|
||||
# there is the case that $this.creator < o2.creator, but o3.creator < $this.creator
|
||||
# then o2 knows o3. Since on another client $OL could be [o1,o3,o4] the problem is complex
|
||||
# therefore $this would be always to the right of o3
|
||||
# case 2: $origin < $o.origin
|
||||
# if current $this insert_position > $o origin: $this ins
|
||||
# else $insert_position will not change
|
||||
# (maybe we encounter case 1 later, then this will be to the right of $o)
|
||||
# case 3: $origin > $o.origin
|
||||
# $this insert_position is to the left of $o (forever!)
|
||||
*/
|
||||
// handle conflicts
|
||||
let o
|
||||
// set o to the first conflicting item
|
||||
if (this._left !== null) {
|
||||
o = this._left._right
|
||||
} else if (this._parentSub !== null) {
|
||||
o = this._parent._map.get(this._parentSub) || null
|
||||
} else {
|
||||
o = this._parent._start
|
||||
}
|
||||
let conflictingItems = new Set()
|
||||
let itemsBeforeOrigin = new Set()
|
||||
// Let c in conflictingItems, b in itemsBeforeOrigin
|
||||
// ***{origin}bbbb{this}{c,b}{c,b}{o}***
|
||||
// Note that conflictingItems is a subset of itemsBeforeOrigin
|
||||
while (o !== null && o !== this._right) {
|
||||
itemsBeforeOrigin.add(o)
|
||||
conflictingItems.add(o)
|
||||
if (this._origin === o._origin) {
|
||||
// case 1
|
||||
if (o._id.user < this._id.user) {
|
||||
this._left = o
|
||||
conflictingItems.clear()
|
||||
}
|
||||
} else if (itemsBeforeOrigin.has(o._origin)) {
|
||||
// case 2
|
||||
if (!conflictingItems.has(o._origin)) {
|
||||
this._left = o
|
||||
conflictingItems.clear()
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
// TODO: try to use right_origin instead.
|
||||
// Then you could basically omit conflictingItems!
|
||||
// Note: you probably can't use right_origin in every case.. only when setting _left
|
||||
o = o._right
|
||||
}
|
||||
// reconnect left/right + update parent map/start if necessary
|
||||
const parentSub = this._parentSub
|
||||
if (this._left === null) {
|
||||
let right
|
||||
if (parentSub !== null) {
|
||||
const pmap = parent._map
|
||||
right = pmap.get(parentSub) || null
|
||||
pmap.set(parentSub, this)
|
||||
} else {
|
||||
right = parent._start
|
||||
parent._start = this
|
||||
}
|
||||
this._right = right
|
||||
if (right !== null) {
|
||||
right._left = this
|
||||
}
|
||||
} else {
|
||||
const left = this._left
|
||||
const right = left._right
|
||||
this._right = right
|
||||
left._right = this
|
||||
if (right !== null) {
|
||||
right._left = this
|
||||
}
|
||||
}
|
||||
if (parent._deleted) {
|
||||
this._delete(y, false)
|
||||
}
|
||||
y.os.put(this)
|
||||
transactionTypeChanged(y, parent, parentSub)
|
||||
if (this._id.user !== RootFakeUserID) {
|
||||
if (y.connector !== null && (y.connector._forwardAppliedStructs || this._id.user === y.userID)) {
|
||||
y.connector.broadcastStruct(this)
|
||||
}
|
||||
if (y.persistence !== null) {
|
||||
y.persistence.saveStruct(y, this)
|
||||
}
|
||||
writeStructToTransaction(y._transaction, this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform the properties of this type to binary and write it to an
|
||||
* BinaryEncoder.
|
||||
*
|
||||
* This is called when this Item is sent to a remote peer.
|
||||
*
|
||||
* @param {BinaryEncoder} encoder The encoder to write data to.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_toBinary (encoder) {
|
||||
encoder.writeUint8(getStructReference(this.constructor))
|
||||
let info = 0
|
||||
if (this._origin !== null) {
|
||||
info += 0b1 // origin is defined
|
||||
}
|
||||
// TODO: remove
|
||||
/* no longer send _left
|
||||
if (this._left !== this._origin) {
|
||||
info += 0b10 // do not copy origin to left
|
||||
}
|
||||
*/
|
||||
if (this._right_origin !== null) {
|
||||
info += 0b100
|
||||
}
|
||||
if (this._parentSub !== null) {
|
||||
info += 0b1000
|
||||
}
|
||||
encoder.writeUint8(info)
|
||||
encoder.writeID(this._id)
|
||||
if (info & 0b1) {
|
||||
encoder.writeID(this._origin._lastId)
|
||||
}
|
||||
// TODO: remove
|
||||
/* see above
|
||||
if (info & 0b10) {
|
||||
encoder.writeID(this._left._lastId)
|
||||
}
|
||||
*/
|
||||
if (info & 0b100) {
|
||||
encoder.writeID(this._right_origin._id)
|
||||
}
|
||||
if ((info & 0b101) === 0) {
|
||||
// neither origin nor right is defined
|
||||
encoder.writeID(this._parent._id)
|
||||
}
|
||||
if (info & 0b1000) {
|
||||
encoder.writeVarString(JSON.stringify(this._parentSub))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the next Item in a Decoder and fill this Item with the read data.
|
||||
*
|
||||
* This is called when data is received from a remote peer.
|
||||
*
|
||||
* @param {Y} y The Yjs instance that this Item belongs to.
|
||||
* @param {BinaryDecoder} decoder The decoder object to read data from.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_fromBinary (y, decoder) {
|
||||
let missing = []
|
||||
const info = decoder.readUint8()
|
||||
const id = decoder.readID()
|
||||
this._id = id
|
||||
// read origin
|
||||
if (info & 0b1) {
|
||||
// origin != null
|
||||
const originID = decoder.readID()
|
||||
// we have to query for left again because it might have been split/merged..
|
||||
const origin = y.os.getItemCleanEnd(originID)
|
||||
if (origin === null) {
|
||||
missing.push(originID)
|
||||
} else {
|
||||
this._origin = origin
|
||||
this._left = this._origin
|
||||
}
|
||||
}
|
||||
// read right
|
||||
if (info & 0b100) {
|
||||
// right != null
|
||||
const rightID = decoder.readID()
|
||||
// we have to query for right again because it might have been split/merged..
|
||||
const right = y.os.getItemCleanStart(rightID)
|
||||
if (right === null) {
|
||||
missing.push(rightID)
|
||||
} else {
|
||||
this._right = right
|
||||
this._right_origin = right
|
||||
}
|
||||
}
|
||||
// read parent
|
||||
if ((info & 0b101) === 0) {
|
||||
// neither origin nor right is defined
|
||||
const parentID = decoder.readID()
|
||||
// parent does not change, so we don't have to search for it again
|
||||
if (this._parent === null) {
|
||||
let parent
|
||||
if (parentID.constructor === RootID) {
|
||||
parent = y.os.get(parentID)
|
||||
} else {
|
||||
parent = y.os.getItem(parentID)
|
||||
}
|
||||
if (parent === null) {
|
||||
missing.push(parentID)
|
||||
} else {
|
||||
this._parent = parent
|
||||
}
|
||||
}
|
||||
} else if (this._parent === null) {
|
||||
if (this._origin !== null) {
|
||||
if (this._origin.constructor === GC) {
|
||||
// if origin is a gc, set parent also gc'd
|
||||
this._parent = this._origin
|
||||
} else {
|
||||
this._parent = this._origin._parent
|
||||
}
|
||||
} else if (this._right_origin !== null) {
|
||||
// if origin is a gc, set parent also gc'd
|
||||
if (this._right_origin.constructor === GC) {
|
||||
this._parent = this._right_origin
|
||||
} else {
|
||||
this._parent = this._right_origin._parent
|
||||
}
|
||||
}
|
||||
}
|
||||
if (info & 0b1000) {
|
||||
// TODO: maybe put this in read parent condition (you can also read parentsub from left/right)
|
||||
this._parentSub = JSON.parse(decoder.readVarString())
|
||||
}
|
||||
if (y.ss.getState(id.user) < id.clock) {
|
||||
missing.push(new ID(id.user, id.clock - 1))
|
||||
}
|
||||
return missing
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import Item from './Item.mjs'
|
||||
import { logItemHelper } from '../MessageHandler/messageToString.mjs'
|
||||
|
||||
export default class ItemEmbed extends Item {
|
||||
constructor () {
|
||||
super()
|
||||
this.embed = null
|
||||
}
|
||||
_copy (undeleteChildren, copyPosition) {
|
||||
let struct = super._copy(undeleteChildren, copyPosition)
|
||||
struct.embed = this.embed
|
||||
return struct
|
||||
}
|
||||
get _length () {
|
||||
return 1
|
||||
}
|
||||
_fromBinary (y, decoder) {
|
||||
const missing = super._fromBinary(y, decoder)
|
||||
this.embed = JSON.parse(decoder.readVarString())
|
||||
return missing
|
||||
}
|
||||
_toBinary (encoder) {
|
||||
super._toBinary(encoder)
|
||||
encoder.writeVarString(JSON.stringify(this.embed))
|
||||
}
|
||||
/**
|
||||
* Transform this YXml Type to a readable format.
|
||||
* Useful for logging as all Items and Delete implement this method.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_logString () {
|
||||
return logItemHelper('ItemEmbed', this, `embed:${JSON.stringify(this.embed)}`)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user