Compare commits
52 Commits
ydb-integr
...
v13.0.0-77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"source": "./src",
|
||||
"source": ".",
|
||||
"destination": "./docs",
|
||||
"excludes": ["build", "node_modules", "tests-lib", "test"],
|
||||
"plugins": [{
|
||||
"name": "esdoc-standard-plugin",
|
||||
"option": {
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,7 +2,7 @@ node_modules
|
||||
bower_components
|
||||
docs
|
||||
/y.*
|
||||
/examples/yjs-dist.js*
|
||||
/examples_all/*/index.dist.*
|
||||
.vscode
|
||||
.yjsPersisted
|
||||
build
|
||||
build
|
||||
|
||||
50
.jsdoc.json
Normal file
50
.jsdoc.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"sourceType": "module",
|
||||
"tags": {
|
||||
"allowUnknownTags": true,
|
||||
"dictionaries": ["jsdoc"]
|
||||
},
|
||||
"source": {
|
||||
"include": ["./structs/Type.js", "./types", "./utils/UndoManager.js", "./utils/YEvent.js", "./utils/Y.js", "./provider", "./bindings"],
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
# 
|
||||
# 
|
||||
|
||||
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
|
||||
@@ -66,7 +66,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>
|
||||
|
||||
367
README.v13.md
Normal file
367
README.v13.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# 
|
||||
> A CRDT library with a powerful abstraction of shared data
|
||||
|
||||
Yjs is a CRDT implementatation that exposes its internal structure as actual data types that can be manipulated and fire changes when remote or local changes happen. While Yjs can be used for all kinds of state management, we lay a special focus on shared editing.
|
||||
|
||||
* Chat: [https://gitter.im/y-js/yjs](https://gitter.im/y-js/yjs)
|
||||
* Demos: [https://yjs.website/tutorial-prosemirror.html](https://yjs.website/tutorial-prosemirror.html)
|
||||
* API Docs: [https://yjs.website/](https://yjs.website/)
|
||||
|
||||
### Supported Editors:
|
||||
|
||||
| Name | Cursors | Demo |
|
||||
|---|:-:|---|
|
||||
| [ProseMirror](https://prosemirror.net/) | ✔ | [link](https://yjs.website/tutorial-prosemirror.html) |
|
||||
| [Quill](https://quilljs.com/) | | [link](https://yjs.website/tutorial-quill.html) |
|
||||
| [CodeMirror](https://codemirror.net/) | ✔ | [link](https://yjs.website/tutorial-codemirror.html) |
|
||||
| [Ace](https://ace.c9.io/) | | [link]() |
|
||||
| [Monaco](https://microsoft.github.io/monaco-editor/) | | [link]() |
|
||||
|
||||
### Distinguishing Features
|
||||
|
||||
* **Binary Encoding:**
|
||||
* **Undo/Redo:**
|
||||
* **Types:**
|
||||
* **Offline:** Yjs is designed to support offline editing. Read [this section](#Offline) about the limitations of offline editing in Yjs. The only provider supporting full offline editing is Ydb.
|
||||
* **Network-agnostic:** Yjs ships with many providers that handle connection and distribution of updates to other peers. Yjs itself is network-agnostic and does not depend on a central source of truth that distributes updates to other peers. Check [this section](#Create-a-Custom-Provider) to find out how the sync mechanism works and how to implement your custom provider.
|
||||
|
||||
# Table of Contents
|
||||
|
||||
* [Getting Started](#Getting-Started)
|
||||
* [Tutorial](#Short-Tutorial)
|
||||
* [Providers](#Providers)
|
||||
* [Websocket](#Websocket)
|
||||
* [Ydb](#Ydb)
|
||||
* [Create a Custom Provider](#Create-a-Custom-Provider)
|
||||
* [Shared Types](#Shared-Types)
|
||||
* [YArray](#Yarray)
|
||||
* [YMap](#YMap)
|
||||
* [YText](#YText)
|
||||
* [YXmlFragment and YXmlElement](#YXmlFragment-and-YXmlElement)
|
||||
* [Create a Custom Type](#Create-a-Custom-Type)
|
||||
* [Bindings](#Bindings)
|
||||
* [PromeMirror](#ProseMirror)
|
||||
* [Quill](#Quill)
|
||||
* [CodeMirror](#CodeMirror)
|
||||
* [Ace](#Ace)
|
||||
* [Monaco](#Monace)
|
||||
* [DOM](#DOM)
|
||||
* [Textarea](#Textarea)
|
||||
* [Create a Custom Binding](#Create-a-Custom-Binding)
|
||||
* [Transaction](#Transaction)
|
||||
* [Offline Editing](#Offline-Editing)
|
||||
* [Awareness](#Awareness)
|
||||
* [Working with Yjs](#Working-with-Yjs)
|
||||
* [Typescript Declarations](#Typescript-Declarations)
|
||||
* [Binary Protocols](#Binary-Protocols)
|
||||
* [Sync Protocol](#Sync-Protocols)
|
||||
* [Awareness Protocol](#Awareness-Protocols)
|
||||
* [Auth Protocol](#Auth-Protocol)
|
||||
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
|
||||
* [Evaluation](#Evaluation)
|
||||
* [Existing shared editing libraries](#Exisisting-Javascript-Products)
|
||||
* [CRDT Algorithms](#CRDT-Algorithms)
|
||||
* [Comparison of CRDT with OT](#Comparing-CRDT-with-OT)
|
||||
* [Comparison of CRDT Algorithms](#Comparing-CRDT-Algorithms)
|
||||
* [Comparison of Yjs with other Implementations](#Comparing-Yjs-with-other-Implementations)
|
||||
* [License and Author](#License-and-Author)
|
||||
|
||||
## Getting Started
|
||||
|
||||
Yjs does not hava any dependencies. Install this package with your favorite package manager, or just copy the files into your project.
|
||||
|
||||
```sh
|
||||
npm i yjs
|
||||
```
|
||||
|
||||
##### Tutorial
|
||||
|
||||
In this *short* tutorial I will give an overview of the basic concepts in Yjs.
|
||||
|
||||
Yjs itself only knows how to do conflict resolution. You need to choose a provider, that handles how document updates are distributed over the network.
|
||||
|
||||
We will start by running a websocket server (part of the [websocket provider](#Websocket-Provider)):
|
||||
|
||||
```sh
|
||||
PORT=1234 node ./node_modules/yjs/provider/websocket/server.js
|
||||
```
|
||||
|
||||
The following client-side code connects to the websocket server and opens a shared document.
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
|
||||
const provider = new WebsocketProvider('http://localhost:1234')
|
||||
const sharedDocument = provider.get('my-favourites')
|
||||
```
|
||||
|
||||
All content created in a shared document is shared among all peers that request the same document. Now we define types on the shared document:
|
||||
|
||||
```js
|
||||
sharedDocument.define('movie-ratings', Y.Map)
|
||||
sharedDocument.define('favourite-food', Y.Array)
|
||||
```
|
||||
|
||||
All clients that define `'movie-ratings'` as `Y.Map` on the shared document named `'my-favourites'` have access to the same shared type. Example:
|
||||
|
||||
**Client 1:**
|
||||
|
||||
```js
|
||||
sharedDocument.define('movie-ratings', Y.Map)
|
||||
sharedDocument.define('favourite-food', Y.Array)
|
||||
|
||||
const movies = sharedDocument.get('movie-ratings')
|
||||
const food = sharedDocument.get('fovourite-food')
|
||||
|
||||
movies.set('deadpool', 10)
|
||||
food.insert(0, ['burger'])
|
||||
```
|
||||
|
||||
**Client 2:**
|
||||
|
||||
```js
|
||||
sharedDocument.define('movie-ratings', Y.Map)
|
||||
sharedDocument.define('favourite-food', Y.Map) // <- note that this definition differs from client1
|
||||
|
||||
const movies = sharedDocument.get('movie-ratings')
|
||||
const food = sharedDocument.get('fovourite-food')
|
||||
|
||||
movies.set('yt rewind', -10)
|
||||
food.set('pancake', 10)
|
||||
|
||||
// after some time, when client1 and client2 synced, the movie list will be merged:
|
||||
movies.toJSON() // => { 'deadpool': 10, 'yt rewind': -10 }
|
||||
|
||||
// But since client1 and client2 defined the types differently,
|
||||
// they do not have access to each others food list.
|
||||
food.toJSON() // => { pancake: 10 }
|
||||
```
|
||||
|
||||
Now you understand how types are defined on a shared document. Next you can jump to one of the [tutorials on our website](https://yjs.website/tutorial-prosemirror.html) or continue reading about [Providers](#Providers), [Shared Types](#Shared-Types), and [Bindings](#Bindings).
|
||||
|
||||
## Providers
|
||||
|
||||
In Yjs, a provider handles the communication channel to *authenticate*, *authorize*, and *exchange document updates*. Yjs ships with some existing providers.
|
||||
|
||||
### Websocket Provider
|
||||
|
||||
The websocket provider implements a classical client server model. Clients connect to a single endpoint over websocket. The server distributes awareness information and document updates among clients.
|
||||
|
||||
The Websocket Provider is a solid choice if you want a central source that handles authentication and authorization. Websockets also send header information and cookies, so you can use existing authentication mechanisms with this server. I recommend that you slightly adapt the server in `./provider/websocket/server.js` to your needs.
|
||||
|
||||
* Supports cross-tab communication. When you open the same document in the same browser, changes on the document are exchanged via cross-tab communication ([Broadcast Channel](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API) and [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) as fallback).
|
||||
* Supports exange of awareness information (e.g. cursors)
|
||||
|
||||
##### Start a Websocket Server:
|
||||
|
||||
```sh
|
||||
PORT=1234 node ./node_modules/yjs/provider/websocket/server.js
|
||||
```
|
||||
|
||||
**Websocket Server with Persistence**
|
||||
|
||||
Persist document updates in a LevelDB database.
|
||||
|
||||
See [LevelDB Persistence](#LevelDB Persistence) for more info.
|
||||
|
||||
```sh
|
||||
PORT=1234 YPERSISTENCE=./dbDir node ./node_modules/yjs/provider/websocket/server.js
|
||||
```
|
||||
|
||||
##### Client Code:
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
|
||||
const provider = new WebsocketProvider('http://localhost:1234')
|
||||
|
||||
// open a websocket connection to http://localhost:1234/my-document-name
|
||||
const sharedDocument = provider.get('my-document-name')
|
||||
|
||||
sharedDocument.on('status', event => {
|
||||
console.log(event.status) // logs "connected" or "disconnected"
|
||||
})
|
||||
```
|
||||
|
||||
#### Scaling
|
||||
|
||||
These are mere suggestions how you could scale your server environment.
|
||||
|
||||
**Option 1:** Websocket servers communicate with each other via a PubSub server. A room is represented by a PubSub channel. The downside of this approach is that the same shared document may be handled by many servers. But the upside is that this approach is fault tolerant, does not have a single point of failure, and is perfectly fit for route balancing.
|
||||
|
||||
**Option 2:** Sharding with *consistent hashing*. Each document is handled by a unique server. This patterns requires an entity, like etcd, that performs regular health checks and manages servers. Based on the list of available servers (which is managed by etcd) a proxy calculates which server is responsible for each requested document. The disadvantage of this approach is that it is that load distribution may not be fair. Still, this approach may be the preferred solution if you want to store the shared document in a database - e.g. for indexing.
|
||||
|
||||
### Ydb Provider
|
||||
|
||||
TODO
|
||||
|
||||
### Create Custom Provider
|
||||
|
||||
A provider is only a concept. I encourage you to implement the same provider interface found above. This makes it easy to exchange communication protocols.
|
||||
|
||||
Since providers handle the communication channel, they will necessarily interact with the [binary protocols](#Binary-Protocols). I suggest that you build upon the existing protocols. But you may also implement a custom communication protocol.
|
||||
|
||||
Read section [Sync Protocol](#Sync-Protocol) to learn how syncing works.
|
||||
|
||||
## Shared Types
|
||||
|
||||
A shared type is just a normal data type like [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) or [Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array). But a shared type may also be modified by a remote client. Conflicts are automatically resolved by the rules described in this section - but please note that this is only a rough overview of how conflict resolution works. Please read the [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm) section for an in-depth description of the conflict resolution approach.
|
||||
|
||||
As explained in [Tutorial](#Tutorial), a shared type is shared among all peers when they are defined with the same name on the same shared document. I.e.
|
||||
|
||||
```js
|
||||
sharedDocument.define('my-array', Y.Array)
|
||||
|
||||
const myArray = sharedDocument.get('my-array')
|
||||
```
|
||||
|
||||
You may define a shared types several times, as long as you don't change the type definition.
|
||||
|
||||
```js
|
||||
sharedDocument.define('my-array', Y.Array)
|
||||
|
||||
const myArray = sharedDocument.get('my-array')
|
||||
|
||||
const alsoMyArray = sharedDocument.define('my-array', Y.Array)
|
||||
|
||||
console.log(myArray === alsoMyArray) // => true
|
||||
```
|
||||
|
||||
All shared types have an `type.observe(event => ..)` method that allows you to observe any changes. You may also observe all changes on a type and any of its children with the `type.observeDeep(events => ..)` method. Here, `events` is the [Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) of events that were fired on type, or any of its children.
|
||||
|
||||
All Events inherit from [YEvent](https://yjs.website/module-utils.YEvent.html).
|
||||
|
||||
### YMap
|
||||
> Complete API docs: [https://yjs.website/module-types.ymap](https://yjs.website/module-types.ymap)
|
||||
|
||||
The YMap type is very similar to the JavaScript [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map).
|
||||
|
||||
YMap fires [YMapEvents](https://yjs.website/module-types.YMapEvent.html).
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
|
||||
const ymap = new Y.Map()
|
||||
|
||||
ymap.observe(event => {
|
||||
console.log('ymap keys changed:', event.keysChanged, event.remote)
|
||||
})
|
||||
|
||||
ymap.set('key', 'value') // => ymap keys changed: Set{ 'key' } false
|
||||
ymap.delete('key') // => ymap keys changed: Set{ 'key' }
|
||||
|
||||
const ymap2 = new YMap()
|
||||
ymap2.set(1, 'two')
|
||||
|
||||
ymap.set('type', ymap2) // => ymap keys changed: Set{ 'type' }
|
||||
```
|
||||
|
||||
##### Concurrent YMap changes
|
||||
|
||||
* Concurrent edits on different keys do not affect each other. E.g. if client1 does `ymap.set('a', 1)` and client2 does `ymap.set('b', 2)`, both clients will end up with `YMap{ a: 1, b: 2 }`
|
||||
* If client1 and client2 `set` the same property at the same time, the edit from the client with the smaller userID will prevail (`sharedDocument.userID`)
|
||||
* If client1 sets a property `ymap.set('a', 1)` and client2 deletes a property `ymap.delete('a')`, the set operation always prevails.
|
||||
|
||||
### YArray
|
||||
> Complete API docs: [https://yjs.website/module-types.yarray](https://yjs.website/module-types.yarray)
|
||||
|
||||
YArray fires [YArrayEvents](https://yjs.website/module-types.YMapEvent.html).
|
||||
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
|
||||
const yarray = new Y.Array()
|
||||
|
||||
yarray.observe(event => {
|
||||
console.log('yarray changed:', event.addedElements, event.removedElements, event.remote)
|
||||
})
|
||||
|
||||
// insert two elements at position 0
|
||||
yarray.insert(0, ['a', 1]) // => yarray changed: Set{Item{'a'}, Item{1}}, Set{}, false
|
||||
console.log(yarray.toArray()) // => ['a', 1]
|
||||
yarray.delete(1, 1) // yarray changed: Set{}, Set{Item{1}}, false
|
||||
|
||||
yarray.insert(1, new Y.Map()) // => yarray changed: Set{YMap{}}, Set{}, false
|
||||
|
||||
// The difference between .toArray and .toJSON:
|
||||
console.log(yarray.toArray()) // => ['a', YMap{}]
|
||||
console.log(yarray.toJSON()) // => ['a', {}]
|
||||
```
|
||||
|
||||
As you can see from the above example, primitive data is wrapped into an Item. This makes it possible to find the exact location of the change.
|
||||
|
||||
##### Concurrent YArray changes
|
||||
|
||||
* YArray internally represents the data as a doubly linked list. The Array `['a', YMap{}, 1]` is internally represented as `Item{'a'} <-> YMap{} <-> Item{1}`. Accordingly, the insert operation `yarray.insert(1, ['b'])` is internally transformed to `insert Item{'b'} between Item{'a'} and YMap{}`.
|
||||
* When an Item is deleted, it is only marked as deleted. Only its content is garbage collected and freed from memory.
|
||||
* Therefore, the remote operation `insert x between a and b` can still be fulfilled when item `a` or item `b` are deleted.
|
||||
* In case that two clients insert content between the same items (a concurrent insertion), the order of the insertions is decided based on the `sharedDocument.userID`.
|
||||
|
||||
### YText
|
||||
> Complete API docs: [https://yjs.website/module-types.ytext](https://yjs.website/module-types.ytext)
|
||||
|
||||
A YText is basically a [YArray](#YArray) that is optimized for text content.
|
||||
|
||||
### YXmlFragment and YXmlElement
|
||||
> Complete API docs: [https://yjs.website/module-types.yxmlfragment](https://yjs.website/module-types.yxmlfragment) and [https://yjs.website/module-types.yxmlelement](https://yjs.website/module-types.yxmlelement)
|
||||
|
||||
### Custom Types
|
||||
|
||||
## Bindings
|
||||
|
||||
## Transaction
|
||||
|
||||
|
||||
|
||||
## Binary Protocols
|
||||
|
||||
### Sync Protocol
|
||||
|
||||
Sync steps
|
||||
|
||||
### Awareness Protocol
|
||||
|
||||
### Auth Protocol
|
||||
|
||||
## Offline Editing
|
||||
|
||||
It is trivial with Yjs to persist the local state to indexeddb, so it is always available when working offline. But there are two non-trivial questions that need to answered when implementing a professional offline editing app:
|
||||
|
||||
1. How does a client sync down all rooms that were modified while offline?
|
||||
2. How does a client sync up all rooms that were modified while offline?
|
||||
|
||||
Assuming 5000 documents are stored on each client for offline usage. How do we sync up/down each of those documents after a client comes online? It would be inefficient to sync each of those rooms separately. The only provider that currently supports syncing many rooms efficiently is Ydb, because its database layer is optimized to sync many rooms with each client.
|
||||
|
||||
If you do not care about 1. and 2. you can use `/persistences/indexeddb.js` to mirror the local state to indexeddb.
|
||||
|
||||
## Working with Yjs
|
||||
|
||||
### Typescript Declarations
|
||||
|
||||
Until [this](https://github.com/Microsoft/TypeScript/issues/7546) is fixed, the only way to get type declarations is by adding Yjs to the list of checked files:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
..
|
||||
},
|
||||
"include": [
|
||||
"./node_modules/yjs/"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## CRDT Algorithm
|
||||
|
||||
## License and Author
|
||||
|
||||
Yjs and all related projects are [**MIT licensed**](./LICENSE). Some files also contain an additional copyright notice that allows you to copy and modify the code without shipping the copyright notice (e.g. `./provider/websocket/WebsocketProvider.js` and `./provider/websocket/server.js`)
|
||||
|
||||
Yjs is based on the research I did as a student at the RWTH i5. I am working on Yjs in my spare time. Please help me by donating or hiring me for consulting, so I can continue to work on this project.
|
||||
|
||||
kevin.jahns@protonmail.com
|
||||
180
bindings/codemirror.js
Normal file
180
bindings/codemirror.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @module bindings/textarea
|
||||
*/
|
||||
|
||||
import { createMutex } from '../lib/mutex.js'
|
||||
import * as math from '../lib/math.js'
|
||||
import * as ypos from '../utils/relativePosition.js'
|
||||
|
||||
const typeObserver = (binding, event) => {
|
||||
binding._mux(() => {
|
||||
const cm = binding.target
|
||||
cm.operation(() => {
|
||||
const delta = event.delta
|
||||
let index = 0
|
||||
for (let i = 0; i < event.delta.length; i++) {
|
||||
const d = delta[i]
|
||||
if (d.retain) {
|
||||
index += d.retain
|
||||
} else if (d.insert) {
|
||||
const pos = cm.posFromIndex(index)
|
||||
cm.replaceRange(d.insert, pos, pos, 'prosemirror-binding')
|
||||
index += d.insert.length
|
||||
} else if (d.delete) {
|
||||
const start = cm.posFromIndex(index)
|
||||
const end = cm.posFromIndex(index + d.delete)
|
||||
cm.replaceRange('', start, end, 'prosemirror-binding')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const targetObserver = (binding, change) => {
|
||||
binding._mux(() => {
|
||||
const start = binding.target.indexFromPos(change.from)
|
||||
const delLen = change.removed.map(s => s.length).reduce(math.add) + change.removed.length - 1
|
||||
if (delLen > 0) {
|
||||
binding.type.delete(start, delLen)
|
||||
}
|
||||
if (change.text.length > 0) {
|
||||
binding.type.insert(start, change.text.join('\n'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const createRemoteCaret = (username, color) => {
|
||||
const caret = document.createElement('span')
|
||||
caret.classList.add('remote-caret')
|
||||
caret.setAttribute('style', `border-color: ${color}`)
|
||||
const userDiv = document.createElement('div')
|
||||
userDiv.setAttribute('style', `background-color: ${color}`)
|
||||
userDiv.insertBefore(document.createTextNode(username), null)
|
||||
caret.insertBefore(userDiv, null)
|
||||
return caret
|
||||
}
|
||||
|
||||
const updateRemoteSelection = (y, cm, type, cursors, clientId) => {
|
||||
// destroy current text mark
|
||||
const m = cursors.get(clientId)
|
||||
if (m !== undefined) {
|
||||
m.caret.clear()
|
||||
if (m.sel !== null) {
|
||||
m.sel.clear()
|
||||
}
|
||||
cursors.delete(clientId)
|
||||
}
|
||||
// redraw caret and selection for clientId
|
||||
const aw = y.awareness.get(clientId)
|
||||
if (aw === undefined) {
|
||||
return
|
||||
}
|
||||
const user = aw.user || {}
|
||||
if (user.color == null) {
|
||||
user.color = '#ffa500'
|
||||
}
|
||||
if (user.name == null) {
|
||||
user.name = `User: ${clientId}`
|
||||
}
|
||||
const cursor = aw.cursor
|
||||
if (cursor == null || cursor.anchor == null || cursor.head == null) {
|
||||
return
|
||||
}
|
||||
const anchor = ypos.fromRelativePosition(y, cursor.anchor || null)
|
||||
const head = ypos.fromRelativePosition(y, cursor.head || null)
|
||||
if (anchor !== null && head !== null && anchor.type === type && head.type === type) {
|
||||
const headpos = cm.posFromIndex(head.offset)
|
||||
const anchorpos = cm.posFromIndex(anchor.offset)
|
||||
let from, to
|
||||
if (head.offset < anchor.offset) {
|
||||
from = headpos
|
||||
to = anchorpos
|
||||
} else {
|
||||
from = anchorpos
|
||||
to = headpos
|
||||
}
|
||||
const caretEl = createRemoteCaret(user.name, user.color)
|
||||
const caret = cm.setBookmark(headpos, { widget: caretEl, insertLeft: true })
|
||||
let sel = null
|
||||
if (head.offset !== anchor.offset) {
|
||||
sel = cm.markText(from, to, { css: `background-color: ${user.color}70`, inclusiveRight: true, inclusiveLeft: false })
|
||||
}
|
||||
cursors.set(clientId, { caret, sel })
|
||||
}
|
||||
}
|
||||
|
||||
const prosemirrorCursorActivity = (y, cm, type) => {
|
||||
if (!cm.hasFocus()) {
|
||||
return
|
||||
}
|
||||
const aw = y.getLocalAwarenessInfo()
|
||||
const anchor = ypos.getRelativePosition(type, cm.indexFromPos(cm.getCursor('anchor')))
|
||||
const head = ypos.getRelativePosition(type, cm.indexFromPos(cm.getCursor('head')))
|
||||
if (aw.cursor == null || !ypos.equal(aw.cursor.anchor, anchor) || !ypos.equal(aw.cursor.head, head)) {
|
||||
y.setAwarenessField('cursor', {
|
||||
anchor, head
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A binding that binds a YText to a CodeMirror editor.
|
||||
*
|
||||
* @example
|
||||
* const ytext = ydocument.define('codemirror', Y.Text)
|
||||
* const editor = new CodeMirror(document.querySelector('#container'), {
|
||||
* mode: 'javascript',
|
||||
* lineNumbers: true
|
||||
* })
|
||||
* const binding = new CodeMirrorBinding(editor)
|
||||
*
|
||||
*/
|
||||
export class CodeMirrorBinding {
|
||||
/**
|
||||
* @param {YText} textType
|
||||
* @param {CodeMirror} codeMirror
|
||||
* @param {Object} [options={cursors: true}]
|
||||
*/
|
||||
constructor (textType, codeMirror, { cursors = true } = {}) {
|
||||
const y = textType._y
|
||||
this.type = textType
|
||||
this.target = codeMirror
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
this._mux = createMutex()
|
||||
// set initial value
|
||||
codeMirror.setValue(textType.toString())
|
||||
// observe type and target
|
||||
this._typeObserver = event => typeObserver(this, event)
|
||||
this._targetObserver = (_, change) => targetObserver(this, change)
|
||||
this._cursors = new Map()
|
||||
this._awarenessListener = event => {
|
||||
const f = clientId => updateRemoteSelection(y, codeMirror, textType, this._cursors, clientId)
|
||||
event.added.forEach(f)
|
||||
event.removed.forEach(f)
|
||||
event.updated.forEach(f)
|
||||
}
|
||||
this._cursorListener = () => prosemirrorCursorActivity(y, codeMirror, textType)
|
||||
this._blurListeer = () =>
|
||||
y.setAwarenessField('cursor', null)
|
||||
textType.observe(this._typeObserver)
|
||||
codeMirror.on('change', this._targetObserver)
|
||||
if (cursors) {
|
||||
y.on('awareness', this._awarenessListener)
|
||||
codeMirror.on('cursorActivity', this._cursorListener)
|
||||
codeMirror.on('blur', this._blurListeer)
|
||||
codeMirror.on('focus', this._cursorListener)
|
||||
}
|
||||
}
|
||||
destroy () {
|
||||
this.type.unobserve(this._typeObserver)
|
||||
this.target.off('change', this._targetObserver)
|
||||
this.type.off('awareness', this._awarenessListener)
|
||||
this.target.off('cursorActivity', this._cursorListener)
|
||||
this.target.off('focus', this._cursorListener)
|
||||
this.target.off('blur', this._blurListeer)
|
||||
this.type = null
|
||||
this.target = null
|
||||
}
|
||||
}
|
||||
1
bindings/dom.js
Normal file
1
bindings/dom.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dom/DomBinding.js'
|
||||
@@ -1,15 +1,23 @@
|
||||
/**
|
||||
* @module bindings/dom
|
||||
*/
|
||||
|
||||
/* global MutationObserver, getSelection */
|
||||
|
||||
import { fromRelativePosition } from '../../Util/relativePosition.js'
|
||||
import Binding from '../Binding.js'
|
||||
import { fromRelativePosition } from '../../utils/relativePosition.js'
|
||||
import { createMutex } from '../../lib/mutex.js'
|
||||
import { createAssociation, removeAssociation } from './util.js'
|
||||
import { beforeTransactionSelectionFixer, afterTransactionSelectionFixer, getCurrentRelativeSelection } from './selection.js'
|
||||
import { defaultFilter, applyFilterOnType } from './filter.js'
|
||||
import typeObserver from './typeObserver.js'
|
||||
import domObserver from './domObserver.js'
|
||||
import { typeObserver } from './typeObserver.js'
|
||||
import { domObserver } from './domObserver.js'
|
||||
import { YXmlFragment } from '../../types/YXmlElement.js' // eslint-disable-line
|
||||
|
||||
/**
|
||||
* @typedef {import('./filter.js').DomFilter} DomFilter
|
||||
* @callback DomFilter
|
||||
* @param {string} nodeName
|
||||
* @param {Map<string, string>} attrs
|
||||
* @return {Map | null}
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -22,8 +30,9 @@ import domObserver from './domObserver.js'
|
||||
* const type = y.define('xml', Y.XmlFragment)
|
||||
* const binding = new Y.QuillBinding(type, div)
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
export default class DomBinding extends Binding {
|
||||
export class DomBinding {
|
||||
/**
|
||||
* @param {YXmlFragment} type The bind source. This is the ultimate source of
|
||||
* truth.
|
||||
@@ -31,10 +40,26 @@ export default class DomBinding extends Binding {
|
||||
* @param {Object} [opts] Optional configurations
|
||||
|
||||
* @param {DomFilter} [opts.filter=defaultFilter] The filter function to use.
|
||||
* @param {Document} [opts.document=document] The filter function to use.
|
||||
* @param {Object} [opts.hooks] The filter function to use.
|
||||
* @param {Element} [opts.scrollingElement=null] The filter function to use.
|
||||
*/
|
||||
constructor (type, target, opts = {}) {
|
||||
// Binding handles textType as this.type and domTextarea as this.target
|
||||
super(type, target)
|
||||
/**
|
||||
* The Yjs type that is bound to `target`
|
||||
* @type {YXmlFragment}
|
||||
*/
|
||||
this.type = type
|
||||
/**
|
||||
* The target that `type` is bound to.
|
||||
* @type {Element}
|
||||
*/
|
||||
this.target = target
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
this._mutualExclude = createMutex()
|
||||
this.opts = opts
|
||||
opts.document = opts.document || document
|
||||
opts.hooks = opts.hooks || {}
|
||||
@@ -81,16 +106,16 @@ export default class DomBinding extends Binding {
|
||||
this.y = y
|
||||
// Force flush dom changes before Type changes are applied (they might
|
||||
// modify the dom)
|
||||
this._beforeTransactionHandler = (y, transaction, remote) => {
|
||||
this._beforeTransactionHandler = y => {
|
||||
this._domObserver(this._mutationObserver.takeRecords())
|
||||
this._mutualExclude(() => {
|
||||
beforeTransactionSelectionFixer(this, remote)
|
||||
beforeTransactionSelectionFixer(this)
|
||||
})
|
||||
}
|
||||
y.on('beforeTransaction', this._beforeTransactionHandler)
|
||||
this._afterTransactionHandler = (y, transaction, remote) => {
|
||||
this._afterTransactionHandler = (y, transaction) => {
|
||||
this._mutualExclude(() => {
|
||||
afterTransactionSelectionFixer(this, remote)
|
||||
afterTransactionSelectionFixer(this)
|
||||
})
|
||||
// remove associations
|
||||
// TODO: this could be done more efficiently
|
||||
@@ -121,8 +146,15 @@ export default class DomBinding extends Binding {
|
||||
createAssociation(this, target, type)
|
||||
}
|
||||
|
||||
flushDomChanges () {
|
||||
this._domObserver(this._mutationObserver.takeRecords())
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: currently does not apply filter to existing elements!
|
||||
* NOTE:
|
||||
* * does not apply filter to existing elements!
|
||||
* * only guarantees that changes are filtered locally. Remote sites may see different content.
|
||||
*
|
||||
* @param {DomFilter} filter The filter function to use from now on.
|
||||
*/
|
||||
setFilter (filter) {
|
||||
@@ -199,13 +231,18 @@ export default class DomBinding extends Binding {
|
||||
y.off('beforeObserverCalls', this._beforeObserverCallsHandler)
|
||||
y.off('afterTransaction', this._afterTransactionHandler)
|
||||
document.removeEventListener('selectionchange', this._selectionchange)
|
||||
super.destroy()
|
||||
this.type = null
|
||||
this.target = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @callback FilterFunction
|
||||
* @param {string} nodeName
|
||||
* @param {Map} attrs
|
||||
* @return {Map|null}
|
||||
*/
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* @module bindings/dom
|
||||
*/
|
||||
|
||||
import YXmlHook from '../../Types/YXml/YXmlHook.js'
|
||||
import { YXmlHook } from '../../types/YXmlHook.js'
|
||||
import {
|
||||
iterateUntilUndeleted,
|
||||
removeAssociation,
|
||||
insertNodeHelper } from './util.js'
|
||||
import diff from '../../../lib/simpleDiff.js'
|
||||
import YXmlFragment from '../../Types/YXml/YXmlFragment.js'
|
||||
import { simpleDiff } from '../../lib/diff.js'
|
||||
import { YXmlFragment } from '../../types/YXmlElement.js'
|
||||
|
||||
/**
|
||||
* 1. Check if any of the nodes was deleted
|
||||
@@ -17,9 +20,11 @@ import YXmlFragment from '../../Types/YXml/YXmlFragment.js'
|
||||
* 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
|
||||
*
|
||||
* @function
|
||||
* @private
|
||||
*/
|
||||
function applyChangesFromDom (binding, dom, yxml, _document) {
|
||||
const applyChangesFromDom = (binding, dom, yxml, _document) => {
|
||||
if (yxml == null || yxml === false || yxml.constructor === YXmlHook) {
|
||||
return
|
||||
}
|
||||
@@ -32,7 +37,7 @@ function applyChangesFromDom (binding, dom, yxml, _document) {
|
||||
}
|
||||
}
|
||||
// 1. Check if any of the nodes was deleted
|
||||
yxml.forEach(function (childType) {
|
||||
yxml.forEach(childType => {
|
||||
if (knownChildren.has(childType) === false) {
|
||||
childType._delete(y)
|
||||
removeAssociation(binding, binding.typeToDom.get(childType), childType)
|
||||
@@ -82,8 +87,9 @@ function applyChangesFromDom (binding, dom, yxml, _document) {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export default function domObserver (mutations, _document) {
|
||||
export function domObserver (mutations, _document) {
|
||||
this._mutualExclude(() => {
|
||||
this.type._y.transact(() => {
|
||||
let diffChildren = new Set()
|
||||
@@ -107,7 +113,7 @@ export default function domObserver (mutations, _document) {
|
||||
}
|
||||
switch (mutation.type) {
|
||||
case 'characterData':
|
||||
var change = diff(yxml.toString(), dom.nodeValue)
|
||||
var change = simpleDiff(yxml.toString(), dom.nodeValue)
|
||||
yxml.delete(change.pos, change.remove)
|
||||
yxml.insert(change.pos, change.insert)
|
||||
break
|
||||
@@ -1,18 +1,26 @@
|
||||
/**
|
||||
* @module bindings/dom
|
||||
*/
|
||||
|
||||
/* eslint-env browser */
|
||||
import YXmlText from '../../Types/YXml/YXmlText.js'
|
||||
import YXmlHook from '../../Types/YXml/YXmlHook.js'
|
||||
import YXmlElement from '../../Types/YXml/YXmlElement.js'
|
||||
import { YXmlText } from '../../types/YXmlText.js'
|
||||
import { YXmlHook } from '../../types/YXmlHook.js'
|
||||
import { YXmlElement } from '../../types/YXmlElement.js'
|
||||
import { createAssociation, domsToTypes } from './util.js'
|
||||
import { filterDomAttributes, defaultFilter } from './filter.js'
|
||||
import { DomBinding } from './DomBinding.js' // eslint-disable-line
|
||||
|
||||
/**
|
||||
* @typedef {import('./filter.js').DomFilter} DomFilter
|
||||
* @typedef {import('./DomBinding.js').default} DomBinding
|
||||
* @callback DomFilter
|
||||
* @param {string} nodeName
|
||||
* @param {Map<string, string>} attrs
|
||||
* @return {Map | null}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a Yjs type (YXml) based on the contents of a DOM Element.
|
||||
*
|
||||
* @function
|
||||
* @param {Element|Text} element The DOM Element
|
||||
* @param {?Document} _document Optional. Provide the global document object
|
||||
* @param {Object<string, any>} [hooks = {}] Optional. Set of Yjs Hooks
|
||||
@@ -20,7 +28,7 @@ import { filterDomAttributes, defaultFilter } from './filter.js'
|
||||
* @param {?DomBinding} binding Warning: This property is for internal use only!
|
||||
* @return {YXmlElement | YXmlText | false}
|
||||
*/
|
||||
export default function domToType (element, _document = document, hooks = {}, filter = defaultFilter, binding) {
|
||||
export const domToType = (element, _document = document, hooks = {}, filter = defaultFilter, binding) => {
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
@@ -1,29 +1,33 @@
|
||||
import isParentOf from '../../Util/isParentOf.js'
|
||||
|
||||
/**
|
||||
* @callback DomFilter
|
||||
* @param {string} nodeName
|
||||
* @param {Map<string, string>} attrs
|
||||
* @return {Map | null}
|
||||
* @module bindings/dom
|
||||
*/
|
||||
|
||||
import { Y } from '../../utils/Y.js' // eslint-disable-line
|
||||
import { YXmlElement, YXmlFragment } from '../../types/YXmlElement.js' // eslint-disable-line
|
||||
import { isParentOf } from '../../utils/isParentOf.js'
|
||||
import { DomBinding } from './DomBinding.js' // eslint-disable-line
|
||||
|
||||
/**
|
||||
* Default filter method (does nothing).
|
||||
*
|
||||
* @function
|
||||
* @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) {
|
||||
export const defaultFilter = (nodeName, attrs) => {
|
||||
// TODO: implement basic filter that filters out dangerous properties!
|
||||
return attrs
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
* @param {Element} dom
|
||||
* @param {Function} filter
|
||||
*/
|
||||
export function filterDomAttributes (dom, filter) {
|
||||
export const filterDomAttributes = (dom, filter) => {
|
||||
const attrs = new Map()
|
||||
for (let i = dom.attributes.length - 1; i >= 0; i--) {
|
||||
const attr = dom.attributes[i]
|
||||
@@ -35,14 +39,14 @@ export function filterDomAttributes (dom, filter) {
|
||||
/**
|
||||
* Applies a filter on a type.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
* @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)) {
|
||||
export const applyFilterOnType = (y, binding, type) => {
|
||||
if (isParentOf(binding.type, type) && type instanceof YXmlElement) {
|
||||
const nodeName = type.nodeName
|
||||
let attributes = new Map()
|
||||
if (type.getAttributes !== undefined) {
|
||||
@@ -53,7 +57,7 @@ export function applyFilterOnType (y, binding, type) {
|
||||
}
|
||||
const filteredAttributes = binding.filter(nodeName, new Map(attributes))
|
||||
if (filteredAttributes === null) {
|
||||
type._delete(y)
|
||||
type._delete(y, true)
|
||||
} else {
|
||||
// iterate original attributes
|
||||
attributes.forEach((value, key) => {
|
||||
@@ -1,10 +1,17 @@
|
||||
/**
|
||||
* @module bindings/dom
|
||||
*/
|
||||
|
||||
/* globals getSelection */
|
||||
|
||||
import { getRelativePosition } from '../../Util/relativePosition.js'
|
||||
import { getRelativePosition } from '../../utils/relativePosition.js'
|
||||
|
||||
let relativeSelection = null
|
||||
|
||||
function _getCurrentRelativeSelection (domBinding) {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
const _getCurrentRelativeSelection = domBinding => {
|
||||
const { baseNode, baseOffset, extentNode, extentOffset } = getSelection()
|
||||
const baseNodeType = domBinding.domToType.get(baseNode)
|
||||
const extentNodeType = domBinding.domToType.get(extentNode)
|
||||
@@ -17,18 +24,25 @@ function _getCurrentRelativeSelection (domBinding) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export const getCurrentRelativeSelection = typeof getSelection !== 'undefined' ? _getCurrentRelativeSelection : domBinding => null
|
||||
|
||||
export function beforeTransactionSelectionFixer (domBinding) {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export const beforeTransactionSelectionFixer = domBinding => {
|
||||
relativeSelection = getCurrentRelativeSelection(domBinding)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the browser range after every transaction.
|
||||
* This prevents any collapsing issues with the local selection.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function afterTransactionSelectionFixer (domBinding) {
|
||||
export const afterTransactionSelectionFixer = domBinding => {
|
||||
if (relativeSelection !== null) {
|
||||
domBinding.restoreSelection(relativeSelection)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* @module bindings/dom
|
||||
*/
|
||||
|
||||
/* eslint-env browser */
|
||||
/* global getSelection */
|
||||
|
||||
import YXmlText from '../../Types/YXml/YXmlText.js'
|
||||
import YXmlHook from '../../Types/YXml/YXmlHook.js'
|
||||
import { YXmlText } from '../../types/YXmlText.js'
|
||||
import { YXmlHook } from '../../types/YXmlHook.js'
|
||||
import { removeDomChildrenUntilElementFound } from './util.js'
|
||||
|
||||
function findScrollReference (scrollingElement) {
|
||||
const findScrollReference = scrollingElement => {
|
||||
if (scrollingElement !== null) {
|
||||
let anchor = getSelection().anchorNode
|
||||
if (anchor == null) {
|
||||
@@ -34,7 +38,7 @@ function findScrollReference (scrollingElement) {
|
||||
return null
|
||||
}
|
||||
|
||||
function fixScroll (scrollingElement, ref) {
|
||||
const fixScroll = (scrollingElement, ref) => {
|
||||
if (ref !== null) {
|
||||
const { elem, top } = ref
|
||||
const currentTop = elem.getBoundingClientRect().top
|
||||
@@ -48,7 +52,7 @@ function fixScroll (scrollingElement, ref) {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export default function typeObserver (events) {
|
||||
export const typeObserver = function (events) {
|
||||
this._mutualExclude(() => {
|
||||
const scrollRef = findScrollReference(this.scrollingElement)
|
||||
events.forEach(event => {
|
||||
@@ -1,19 +1,16 @@
|
||||
|
||||
import domToType from './domToType.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('../../Types/YXml/YXmlText.js').default} YXmlText
|
||||
* @typedef {import('../../Types/YXml/YXmlElement.js').default} YXmlElement
|
||||
* @typedef {import('../../Types/YXml/YXmlHook.js').default} YXmlHook
|
||||
* @typedef {import('./DomBinding.js').default} DomBinding
|
||||
* @module bindings/dom
|
||||
*/
|
||||
|
||||
import { domToType } from './domToType.js'
|
||||
import { DomBinding } from './DomBinding.js' // eslint-disable-line
|
||||
|
||||
/**
|
||||
* Iterates items until an undeleted item is found.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function iterateUntilUndeleted (item) {
|
||||
export const iterateUntilUndeleted = item => {
|
||||
while (item !== null && item._deleted) {
|
||||
item = item._right
|
||||
}
|
||||
@@ -24,12 +21,14 @@ export function iterateUntilUndeleted (item) {
|
||||
* Removes an association (the information that a DOM element belongs to a
|
||||
* type).
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
* @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) {
|
||||
export const removeAssociation = (domBinding, dom, type) => {
|
||||
domBinding.domToType.delete(dom)
|
||||
domBinding.typeToDom.delete(type)
|
||||
}
|
||||
@@ -38,12 +37,14 @@ export function removeAssociation (domBinding, dom, type) {
|
||||
* Creates an association (the information that a DOM element belongs to a
|
||||
* type).
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
* @param {DomBinding} domBinding The binding object
|
||||
* @param {DocumentFragment|Element|Text} dom The dom that is to be associated with type
|
||||
* @param {YXmlElement|YXmlHook|YXmlText} type The type that is to be associated with dom
|
||||
* @param {YXmlFragment|YXmlElement|YXmlHook|YXmlText} type The type that is to be associated with dom
|
||||
*
|
||||
*/
|
||||
export function createAssociation (domBinding, dom, type) {
|
||||
export const createAssociation = (domBinding, dom, type) => {
|
||||
if (domBinding !== undefined) {
|
||||
domBinding.domToType.set(dom, type)
|
||||
domBinding.typeToDom.set(type, dom)
|
||||
@@ -54,11 +55,13 @@ export function createAssociation (domBinding, dom, type) {
|
||||
* 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.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
* @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) {
|
||||
export const switchAssociation = (domBinding, oldDom, newDom) => {
|
||||
if (domBinding !== undefined) {
|
||||
const type = domBinding.domToType.get(oldDom)
|
||||
if (type !== undefined) {
|
||||
@@ -73,6 +76,8 @@ export function switchAssociation (domBinding, oldDom, newDom) {
|
||||
* The Dom elements will be bound to a new YXmlElement and inserted at the
|
||||
* specified position.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
* @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
|
||||
@@ -81,15 +86,13 @@ export function switchAssociation (domBinding, oldDom, newDom) {
|
||||
* @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) {
|
||||
export const 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) {
|
||||
export const domsToTypes = (doms, _document, hooks, filter, binding) => {
|
||||
const types = []
|
||||
for (let dom of doms) {
|
||||
const t = domToType(dom, _document, hooks, filter, binding)
|
||||
@@ -102,8 +105,9 @@ export function domsToTypes (doms, _document, hooks, filter, binding) {
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @function
|
||||
*/
|
||||
export function insertNodeHelper (yxml, prevExpectedNode, child, _document, binding) {
|
||||
export const insertNodeHelper = (yxml, prevExpectedNode, child, _document, binding) => {
|
||||
let insertedNodes = insertDomElementsAfter(yxml, prevExpectedNode, [child], _document, binding)
|
||||
if (insertedNodes.length > 0) {
|
||||
return insertedNodes[0]
|
||||
@@ -115,14 +119,14 @@ export function insertNodeHelper (yxml, prevExpectedNode, child, _document, bind
|
||||
/**
|
||||
* Remove children until `elem` is found.
|
||||
*
|
||||
* @private
|
||||
* @function
|
||||
* @param {Element} parent The parent of `elem` and `currentChild`.
|
||||
* @param {Element} currentChild Start removing elements with `currentChild`. If
|
||||
* @param {Node} 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) {
|
||||
export const removeDomChildrenUntilElementFound = (parent, currentChild, elem) => {
|
||||
while (currentChild !== elem) {
|
||||
const del = currentChild
|
||||
currentChild = currentChild.nextSibling
|
||||
752
bindings/prosemirror.js
Normal file
752
bindings/prosemirror.js
Normal file
@@ -0,0 +1,752 @@
|
||||
/**
|
||||
* @module bindings/prosemirror
|
||||
*/
|
||||
|
||||
import { YText } from '../types/YText.js' // eslint-disable-line
|
||||
import { YXmlElement, YXmlFragment } from '../types/YXmlElement.js' // eslint-disable-line
|
||||
import { createMutex } from '../lib/mutex.js'
|
||||
import * as PModel from 'prosemirror-model'
|
||||
import { EditorView, Decoration, DecorationSet } from 'prosemirror-view' // eslint-disable-line
|
||||
import { Plugin, PluginKey, EditorState, TextSelection } from 'prosemirror-state' // eslint-disable-line
|
||||
import * as math from '../lib/math.js'
|
||||
import * as object from '../lib/object.js'
|
||||
import * as YPos from '../utils/relativePosition.js'
|
||||
import { isVisible } from '../utils/snapshot.js'
|
||||
import { simpleDiff } from '../lib/diff.js'
|
||||
|
||||
/**
|
||||
* @typedef {Map<YText | YXmlElement | YXmlFragment, PModel.Node>} ProsemirrorMapping
|
||||
*/
|
||||
|
||||
/**
|
||||
* The unique prosemirror plugin key for prosemirrorPlugin.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const prosemirrorPluginKey = new PluginKey('yjs')
|
||||
|
||||
/**
|
||||
* This plugin listens to changes in prosemirror view and keeps yXmlState and view in sync.
|
||||
*
|
||||
* This plugin also keeps references to the type and the shared document so other plugins can access it.
|
||||
* @param {YXmlFragment} yXmlFragment
|
||||
* @return {Plugin} Returns a prosemirror plugin that binds to this type
|
||||
*/
|
||||
export const prosemirrorPlugin = yXmlFragment => {
|
||||
let changedInitialContent = false
|
||||
const plugin = new Plugin({
|
||||
props: {
|
||||
editable: (state) => prosemirrorPluginKey.getState(state).snapshot == null
|
||||
},
|
||||
key: prosemirrorPluginKey,
|
||||
state: {
|
||||
init: (initargs, state) => {
|
||||
return {
|
||||
type: yXmlFragment,
|
||||
y: yXmlFragment._y,
|
||||
binding: null,
|
||||
snapshot: null
|
||||
}
|
||||
},
|
||||
apply: (tr, pluginState) => {
|
||||
const change = tr.getMeta(prosemirrorPluginKey)
|
||||
if (change !== undefined) {
|
||||
pluginState = Object.assign({}, pluginState)
|
||||
for (let key in change) {
|
||||
pluginState[key] = change[key]
|
||||
}
|
||||
}
|
||||
if (pluginState.binding !== null) {
|
||||
if (change !== undefined && change.snapshot !== undefined) {
|
||||
// snapshot changed, rerender next
|
||||
setTimeout(() => {
|
||||
if (change.restore == null) {
|
||||
pluginState.binding._renderSnapshot(change.snapshot, change.prevSnapshot)
|
||||
} else {
|
||||
pluginState.binding._renderSnapshot(change.snapshot, change.snapshot)
|
||||
// reset to current prosemirror state
|
||||
delete pluginState.restore
|
||||
delete pluginState.snapshot
|
||||
delete pluginState.prevSnapshot
|
||||
pluginState.binding._prosemirrorChanged(pluginState.binding.prosemirrorView.state.doc)
|
||||
}
|
||||
}, 0)
|
||||
} else if (pluginState.snapshot == null) {
|
||||
// only apply if no snapshot active
|
||||
// update Yjs state when apply is called. We need to do this here to compute the correct cursor decorations with the cursor plugin
|
||||
if (changedInitialContent || tr.doc.content.size > 4) {
|
||||
changedInitialContent = true
|
||||
pluginState.binding._prosemirrorChanged(tr.doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pluginState
|
||||
}
|
||||
},
|
||||
view: view => {
|
||||
const binding = new ProsemirrorBinding(yXmlFragment, view)
|
||||
view.dispatch(view.state.tr.setMeta(prosemirrorPluginKey, { binding }))
|
||||
return {
|
||||
update: () => {
|
||||
const pluginState = plugin.getState(view.state)
|
||||
if (pluginState.snapshot == null) {
|
||||
if (changedInitialContent || view.state.doc.content.size > 4) {
|
||||
changedInitialContent = true
|
||||
binding._prosemirrorChanged(view.state.doc)
|
||||
}
|
||||
}
|
||||
},
|
||||
destroy: () => {
|
||||
binding.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return plugin
|
||||
}
|
||||
|
||||
/**
|
||||
* The unique prosemirror plugin key for cursorPlugin.type
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const cursorPluginKey = new PluginKey('yjs-cursor')
|
||||
|
||||
/**
|
||||
* A prosemirror plugin that listens to awareness information on Yjs.
|
||||
* This requires that a `prosemirrorPlugin` is also bound to the prosemirror.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const cursorPlugin = new Plugin({
|
||||
key: cursorPluginKey,
|
||||
props: {
|
||||
decorations: state => {
|
||||
const ystate = prosemirrorPluginKey.getState(state)
|
||||
const y = ystate.y
|
||||
const awareness = y.getAwarenessInfo()
|
||||
const decorations = []
|
||||
if (ystate.snapshot != null) {
|
||||
// do not render cursors while snapshot is active
|
||||
return
|
||||
}
|
||||
awareness.forEach((aw, userID) => {
|
||||
if (userID === y.userID) {
|
||||
return
|
||||
}
|
||||
if (aw.cursor != null) {
|
||||
let user = aw.user || {}
|
||||
if (user.color == null) {
|
||||
user.color = '#ffa500'
|
||||
}
|
||||
if (user.name == null) {
|
||||
user.name = `User: ${userID}`
|
||||
}
|
||||
let anchor = relativePositionToAbsolutePosition(ystate.type, aw.cursor.anchor || null, ystate.binding.mapping)
|
||||
let head = relativePositionToAbsolutePosition(ystate.type, aw.cursor.head || null, ystate.binding.mapping)
|
||||
if (anchor !== null && head !== null) {
|
||||
let maxsize = math.max(state.doc.content.size - 1, 0)
|
||||
anchor = math.min(anchor, maxsize)
|
||||
head = math.min(head, maxsize)
|
||||
decorations.push(Decoration.widget(head, () => {
|
||||
const cursor = document.createElement('span')
|
||||
cursor.classList.add('ProseMirror-yjs-cursor')
|
||||
cursor.setAttribute('style', `border-color: ${user.color}`)
|
||||
const userDiv = document.createElement('div')
|
||||
userDiv.setAttribute('style', `background-color: ${user.color}`)
|
||||
userDiv.insertBefore(document.createTextNode(user.name), null)
|
||||
cursor.insertBefore(userDiv, null)
|
||||
return cursor
|
||||
}, { key: userID + '' }))
|
||||
const from = math.min(anchor, head)
|
||||
const to = math.max(anchor, head)
|
||||
decorations.push(Decoration.inline(from, to, { style: `background-color: ${user.color}70` }))
|
||||
}
|
||||
}
|
||||
})
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
}
|
||||
},
|
||||
view: view => {
|
||||
const ystate = prosemirrorPluginKey.getState(view.state)
|
||||
const y = ystate.y
|
||||
const awarenessListener = () => {
|
||||
view.updateState(view.state)
|
||||
}
|
||||
const updateCursorInfo = () => {
|
||||
const current = y.getLocalAwarenessInfo()
|
||||
if (view.hasFocus() && ystate.binding !== null) {
|
||||
const anchor = absolutePositionToRelativePosition(view.state.selection.anchor, ystate.type, ystate.binding.mapping)
|
||||
const head = absolutePositionToRelativePosition(view.state.selection.head, ystate.type, ystate.binding.mapping)
|
||||
if (current.cursor == null || !YPos.equal(current.cursor.anchor, anchor) || !YPos.equal(current.cursor.head, head)) {
|
||||
y.setAwarenessField('cursor', {
|
||||
anchor, head
|
||||
})
|
||||
}
|
||||
} else if (current.cursor !== null) {
|
||||
y.setAwarenessField('cursor', null)
|
||||
}
|
||||
}
|
||||
y.on('awareness', awarenessListener)
|
||||
view.dom.addEventListener('focusin', updateCursorInfo)
|
||||
view.dom.addEventListener('focusout', updateCursorInfo)
|
||||
return {
|
||||
update: updateCursorInfo,
|
||||
destroy: () => {
|
||||
const y = prosemirrorPluginKey.getState(view.state).y
|
||||
y.setAwarenessField('cursor', null)
|
||||
y.off('awareness', awarenessListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Transforms a Prosemirror based absolute position to a Yjs based relative position.
|
||||
*
|
||||
* @param {number} pos
|
||||
* @param {YXmlFragment} type
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
* @return {any} relative position
|
||||
*/
|
||||
export const absolutePositionToRelativePosition = (pos, type, mapping) => {
|
||||
if (pos === 0) {
|
||||
return YPos.getRelativePosition(type, 0)
|
||||
}
|
||||
let n = type._first
|
||||
if (n !== null) {
|
||||
while (type !== n) {
|
||||
const pNodeSize = (mapping.get(n) || { nodeSize: 0 }).nodeSize
|
||||
if (n.constructor === YText) {
|
||||
if (n.length >= pos) {
|
||||
return YPos.getRelativePosition(n, pos)
|
||||
} else {
|
||||
pos -= n.length
|
||||
}
|
||||
if (n._next !== null) {
|
||||
n = n._next
|
||||
} else {
|
||||
do {
|
||||
n = n._parent
|
||||
pos--
|
||||
} while (n._next === null && n !== type)
|
||||
if (n !== type) {
|
||||
n = n._next
|
||||
}
|
||||
}
|
||||
} else if (n._first !== null && pos < pNodeSize) {
|
||||
n = n._first
|
||||
pos--
|
||||
} else {
|
||||
if (pos === 1 && n.length === 0 && pNodeSize > 1) {
|
||||
// edge case, should end in this paragraph
|
||||
return ['endof', n._id.user, n._id.clock, null, null]
|
||||
}
|
||||
pos -= pNodeSize
|
||||
if (n._next !== null) {
|
||||
n = n._next
|
||||
} else {
|
||||
if (pos === 0) {
|
||||
n = n._parent
|
||||
return ['endof', n._id.user, n._id.clock || null, n._id.name || null, n._id.type || null]
|
||||
}
|
||||
do {
|
||||
n = n._parent
|
||||
pos--
|
||||
} while (n._next === null && n !== type)
|
||||
if (n !== type) {
|
||||
n = n._next
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pos === 0 && n.constructor !== YText && n !== type) { // TODO: set to <= 0
|
||||
return [n._id.user, n._id.clock]
|
||||
}
|
||||
}
|
||||
}
|
||||
return YPos.getRelativePosition(type, type.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {YXmlFragment} yDoc Top level type that is bound to pView
|
||||
* @param {any} relPos Encoded Yjs based relative position
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
*/
|
||||
export const relativePositionToAbsolutePosition = (yDoc, relPos, mapping) => {
|
||||
const decodedPos = YPos.fromRelativePosition(yDoc._y, relPos)
|
||||
if (decodedPos === null) {
|
||||
return null
|
||||
}
|
||||
let type = decodedPos.type
|
||||
let pos = 0
|
||||
if (type.constructor === YText) {
|
||||
pos = decodedPos.offset
|
||||
} else if (!type._deleted) {
|
||||
let n = type._first
|
||||
let i = 0
|
||||
while (i < type.length && i < decodedPos.offset && n !== null) {
|
||||
i++
|
||||
pos += mapping.get(n).nodeSize
|
||||
n = n._next
|
||||
}
|
||||
pos += 1 // increase because we go out of n
|
||||
}
|
||||
while (type !== yDoc) {
|
||||
const parent = type._parent
|
||||
if (!parent._deleted) {
|
||||
pos += 1 // the start tag
|
||||
let n = parent._first
|
||||
// now iterate until we found type
|
||||
while (n !== null) {
|
||||
if (n === type) {
|
||||
break
|
||||
}
|
||||
pos += mapping.get(n).nodeSize
|
||||
n = n._next
|
||||
}
|
||||
}
|
||||
type = parent
|
||||
}
|
||||
return pos - 1 // we don't count the most outer tag, because it is a fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Binding for prosemirror.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
export class ProsemirrorBinding {
|
||||
/**
|
||||
* @param {YXmlFragment} yXmlFragment The bind source
|
||||
* @param {EditorView} prosemirrorView The target binding
|
||||
*/
|
||||
constructor (yXmlFragment, prosemirrorView) {
|
||||
this.type = yXmlFragment
|
||||
this.prosemirrorView = prosemirrorView
|
||||
this.mux = createMutex()
|
||||
/**
|
||||
* @type {ProsemirrorMapping}
|
||||
*/
|
||||
this.mapping = new Map()
|
||||
this._observeFunction = this._typeChanged.bind(this)
|
||||
this.y = yXmlFragment._y
|
||||
/**
|
||||
* current selection as relative positions in the Yjs model
|
||||
*/
|
||||
this._relSelection = null
|
||||
this.y.on('beforeTransaction', e => {
|
||||
this._relSelection = {
|
||||
anchor: absolutePositionToRelativePosition(this.prosemirrorView.state.selection.anchor, yXmlFragment, this.mapping),
|
||||
head: absolutePositionToRelativePosition(this.prosemirrorView.state.selection.head, yXmlFragment, this.mapping)
|
||||
}
|
||||
})
|
||||
yXmlFragment.observeDeep(this._observeFunction)
|
||||
}
|
||||
_forceRerender () {
|
||||
this.mapping = new Map()
|
||||
this.mux(() => {
|
||||
const fragmentContent = this.type.toArray().map(t => createNodeFromYElement(t, this.prosemirrorView.state.schema, this.mapping)).filter(n => n !== null)
|
||||
const tr = this.prosemirrorView.state.tr.replace(0, this.prosemirrorView.state.doc.content.size, new PModel.Slice(new PModel.Fragment(fragmentContent), 0, 0))
|
||||
this.prosemirrorView.dispatch(tr)
|
||||
})
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {*} snapshot
|
||||
* @param {*} prevSnapshot
|
||||
*/
|
||||
_renderSnapshot (snapshot, prevSnapshot) {
|
||||
// clear mapping because we are going to rerender
|
||||
this.mapping = new Map()
|
||||
this.mux(() => {
|
||||
const fragmentContent = this.type.toArray({ sm: snapshot.sm, ds: prevSnapshot.ds}).map(t => createNodeFromYElement(t, this.prosemirrorView.state.schema, new Map(), snapshot, prevSnapshot)).filter(n => n !== null)
|
||||
const tr = this.prosemirrorView.state.tr.replace(0, this.prosemirrorView.state.doc.content.size, new PModel.Slice(new PModel.Fragment(fragmentContent), 0, 0))
|
||||
this.prosemirrorView.dispatch(tr)
|
||||
})
|
||||
}
|
||||
_typeChanged (events, transaction) {
|
||||
if (events.length === 0 || prosemirrorPluginKey.getState(this.prosemirrorView.state).snapshot != null) {
|
||||
// drop out if snapshot is active
|
||||
return
|
||||
}
|
||||
console.info('new types:', transaction.newTypes.size, 'deleted types:', transaction.deletedStructs.size, transaction.newTypes, transaction.deletedStructs)
|
||||
this.mux(() => {
|
||||
const delStruct = (_, struct) => this.mapping.delete(struct)
|
||||
transaction.deletedStructs.forEach(struct => this.mapping.delete(struct))
|
||||
transaction.changedTypes.forEach(delStruct)
|
||||
transaction.changedParentTypes.forEach(delStruct)
|
||||
const fragmentContent = this.type.toArray().map(t => createNodeIfNotExists(t, this.prosemirrorView.state.schema, this.mapping)).filter(n => n !== null)
|
||||
const tr = this.prosemirrorView.state.tr.replace(0, this.prosemirrorView.state.doc.content.size, new PModel.Slice(new PModel.Fragment(fragmentContent), 0, 0))
|
||||
const relSel = this._relSelection
|
||||
if (relSel !== null && relSel.anchor !== null && relSel.head !== null) {
|
||||
const anchor = relativePositionToAbsolutePosition(this.type, relSel.anchor, this.mapping)
|
||||
const head = relativePositionToAbsolutePosition(this.type, relSel.head, this.mapping)
|
||||
if (anchor !== null && head !== null) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, anchor, head))
|
||||
}
|
||||
}
|
||||
this.prosemirrorView.dispatch(tr)
|
||||
})
|
||||
}
|
||||
_prosemirrorChanged (doc) {
|
||||
this.mux(() => {
|
||||
updateYFragment(this.type, doc.content, this.mapping)
|
||||
})
|
||||
}
|
||||
destroy () {
|
||||
this.type.unobserveDeep(this._observeFunction)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {YXmlElement} el
|
||||
* @param {PModel.Schema} schema
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
* @param {HistorySnapshot} [snapshot]
|
||||
* @param {HistorySnapshot} [prevSnapshot]
|
||||
* @return {PModel.Node}
|
||||
*/
|
||||
export const createNodeIfNotExists = (el, schema, mapping, snapshot, prevSnapshot) => {
|
||||
const node = mapping.get(el)
|
||||
if (node === undefined) {
|
||||
return createNodeFromYElement(el, schema, mapping, snapshot, prevSnapshot)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {YXmlElement} el
|
||||
* @param {PModel.Schema} schema
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
* @param {import('../protocols/history.js').HistorySnapshot} [snapshot]
|
||||
* @param {import('../protocols/history.js').HistorySnapshot} [prevSnapshot]
|
||||
* @return {PModel.Node | null} Returns node if node could be created. Otherwise it deletes the yjs type and returns null
|
||||
*/
|
||||
export const createNodeFromYElement = (el, schema, mapping, snapshot, prevSnapshot) => {
|
||||
let _snapshot = snapshot
|
||||
let _prevSnapshot = prevSnapshot
|
||||
if (snapshot !== undefined && prevSnapshot !== undefined) {
|
||||
if (!isVisible(el, snapshot)) {
|
||||
// if this element is already rendered as deleted (ychange), then do not render children as deleted
|
||||
_snapshot = {sm: snapshot.sm, ds: prevSnapshot.ds}
|
||||
_prevSnapshot = _snapshot
|
||||
} else if (!isVisible(el, prevSnapshot)) {
|
||||
_prevSnapshot = _snapshot
|
||||
}
|
||||
}
|
||||
const children = []
|
||||
const createChildren = type => {
|
||||
if (type.constructor === YXmlElement) {
|
||||
const n = createNodeIfNotExists(type, schema, mapping, _snapshot, _prevSnapshot)
|
||||
if (n !== null) {
|
||||
children.push(n)
|
||||
}
|
||||
} else {
|
||||
const ns = createTextNodesFromYText(type, schema, mapping, _snapshot, _prevSnapshot)
|
||||
if (ns !== null) {
|
||||
ns.forEach(textchild => {
|
||||
if (textchild !== null) {
|
||||
children.push(textchild)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (snapshot === undefined || prevSnapshot === undefined) {
|
||||
el.toArray().forEach(createChildren)
|
||||
} else {
|
||||
el.toArray({sm: snapshot.sm, ds: prevSnapshot.ds}).forEach(createChildren)
|
||||
}
|
||||
let node
|
||||
try {
|
||||
const attrs = el.getAttributes(_snapshot)
|
||||
if (snapshot !== undefined) {
|
||||
if (!isVisible(el, snapshot)) {
|
||||
attrs.ychange = { user: el._id.user, state: 'removed' }
|
||||
} else if (!isVisible(el, prevSnapshot)) {
|
||||
attrs.ychange = { user: el._id.user, state: 'added' }
|
||||
}
|
||||
}
|
||||
node = schema.node(el.nodeName.toLowerCase(), attrs, children)
|
||||
} catch (e) {
|
||||
// an error occured while creating the node. This is probably a result because of a concurrent action.
|
||||
// ignore the node while rendering
|
||||
/* do not delete anymore
|
||||
el._y.transact(() => {
|
||||
el._delete(el._y, true)
|
||||
})
|
||||
*/
|
||||
return null
|
||||
}
|
||||
mapping.set(el, node)
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {YText} text
|
||||
* @param {PModel.Schema} schema
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
* @param {HistorySnapshot} [snapshot]
|
||||
* @param {HistorySnapshot} [prevSnapshot]
|
||||
* @return {Array<PModel.Node>}
|
||||
*/
|
||||
export const createTextNodesFromYText = (text, schema, mapping, snapshot, prevSnapshot) => {
|
||||
const nodes = []
|
||||
const deltas = text.toDelta(snapshot, prevSnapshot)
|
||||
try {
|
||||
for (let i = 0; i < deltas.length; i++) {
|
||||
const delta = deltas[i]
|
||||
const marks = []
|
||||
for (let markName in delta.attributes) {
|
||||
marks.push(schema.mark(markName, delta.attributes[markName]))
|
||||
}
|
||||
nodes.push(schema.text(delta.insert, marks))
|
||||
}
|
||||
if (nodes.length > 0) {
|
||||
mapping.set(text, nodes[0]) // only map to first child, all following children are also considered bound to this type
|
||||
}
|
||||
} catch (e) {
|
||||
/*
|
||||
text._y.transact(() => {
|
||||
text._delete(text._y, true)
|
||||
})
|
||||
*/
|
||||
return null
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {PModel.Node} node
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
* @return {YXmlElement | YText}
|
||||
*/
|
||||
export const createTypeFromNode = (node, mapping) => {
|
||||
let type
|
||||
if (node.isText) {
|
||||
type = new YText()
|
||||
const attrs = {}
|
||||
node.marks.forEach(mark => {
|
||||
if (mark.type.name !== 'ychange') {
|
||||
attrs[mark.type.name] = mark.attrs
|
||||
}
|
||||
})
|
||||
type.insert(0, node.text, attrs)
|
||||
} else {
|
||||
type = new YXmlElement(node.type.name)
|
||||
for (let key in node.attrs) {
|
||||
const val = node.attrs[key]
|
||||
if (val !== null && key !== 'ychange') {
|
||||
type.setAttribute(key, val)
|
||||
}
|
||||
}
|
||||
const ins = []
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
ins.push(createTypeFromNode(node.child(i), mapping))
|
||||
}
|
||||
type.insert(0, ins)
|
||||
}
|
||||
mapping.set(type, node)
|
||||
return type
|
||||
}
|
||||
|
||||
const equalAttrs = (pattrs, yattrs) => {
|
||||
const keys = Object.keys(pattrs).filter(key => pattrs[key] === null)
|
||||
let eq = keys.length === Object.keys(yattrs).filter(key => yattrs[key] === null).length
|
||||
for (let i = 0; i < keys.length && eq; i++) {
|
||||
const key = keys[i]
|
||||
const l = pattrs[key]
|
||||
const r = yattrs[key]
|
||||
eq = key === 'ychange' || l === r || (typeof l === 'object' && typeof r === 'object' && equalAttrs(l, r))
|
||||
}
|
||||
return eq
|
||||
}
|
||||
|
||||
const equalYTextPText = (ytext, ptext) => {
|
||||
const d = ytext.toDelta()[0]
|
||||
return d.insert === ptext.text && object.keys(d.attributes || {}).length === ptext.marks.length && ptext.marks.every(mark => equalAttrs(d.attributes[mark.type.name], mark.attrs))
|
||||
}
|
||||
|
||||
const equalYTypePNode = (ytype, pnode) =>
|
||||
ytype.constructor === YText
|
||||
? equalYTextPText(ytype, pnode)
|
||||
: (matchNodeName(ytype, pnode) && ytype.length === pnode.childCount && equalAttrs(ytype.getAttributes(), pnode.attrs) && ytype.toArray().every((ychild, i) => equalYTypePNode(ychild, pnode.child(i))))
|
||||
|
||||
const computeChildEqualityFactor = (ytype, pnode, mapping) => {
|
||||
const yChildren = ytype.toArray()
|
||||
const pChildCnt = pnode.childCount
|
||||
const yChildCnt = yChildren.length
|
||||
const minCnt = math.min(yChildCnt, pChildCnt)
|
||||
let left = 0
|
||||
let right = 0
|
||||
let foundMappedChild = false
|
||||
for (; left < minCnt; left++) {
|
||||
const leftY = yChildren[left]
|
||||
const leftP = pnode.child(left)
|
||||
if (mapping.get(leftY) === leftP) {
|
||||
foundMappedChild = true// definite (good) match!
|
||||
} else if (!equalYTypePNode(leftY, leftP)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
for (; left + right < minCnt; right++) {
|
||||
const rightY = yChildren[yChildCnt - right - 1]
|
||||
const rightP = pnode.child(pChildCnt - right - 1)
|
||||
if (mapping.get(rightY) !== rightP) {
|
||||
foundMappedChild = true
|
||||
} else if (!equalYTypePNode(rightP, rightP)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return {
|
||||
equalityFactor: left + right,
|
||||
foundMappedChild
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {YXmlFragment} yDomFragment
|
||||
* @param {PModel.Node} pContent
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
*/
|
||||
const updateYFragment = (yDomFragment, pContent, mapping) => {
|
||||
if (yDomFragment instanceof YXmlElement && yDomFragment.nodeName.toLowerCase() !== pContent.type.name) {
|
||||
throw new Error('node name mismatch!')
|
||||
}
|
||||
mapping.set(yDomFragment, pContent)
|
||||
// update attributes
|
||||
if (yDomFragment instanceof YXmlElement) {
|
||||
const yDomAttrs = yDomFragment.getAttributes()
|
||||
const pAttrs = pContent.attrs
|
||||
for (let key in pAttrs) {
|
||||
if (pAttrs[key] !== null) {
|
||||
if (yDomAttrs[key] !== pAttrs[key] && key !== 'ychange') {
|
||||
yDomFragment.setAttribute(key, pAttrs[key])
|
||||
}
|
||||
} else {
|
||||
yDomFragment.removeAttribute(key)
|
||||
}
|
||||
}
|
||||
// remove all keys that are no longer in pAttrs
|
||||
for (let key in yDomAttrs) {
|
||||
if (pAttrs[key] === undefined) {
|
||||
yDomFragment.removeAttribute(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
// update children
|
||||
const pChildCnt = pContent.childCount
|
||||
const yChildren = yDomFragment.toArray()
|
||||
const yChildCnt = yChildren.length
|
||||
const minCnt = math.min(pChildCnt, yChildCnt)
|
||||
let left = 0
|
||||
let right = 0
|
||||
// find number of matching elements from left
|
||||
for (;left < minCnt; left++) {
|
||||
const leftY = yChildren[left]
|
||||
const leftP = pContent.child(left)
|
||||
if (mapping.get(leftY) !== leftP) {
|
||||
if (equalYTypePNode(leftY, leftP)) {
|
||||
// update mapping
|
||||
mapping.set(leftY, leftP)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// find number of matching elements from right
|
||||
for (;right + left < minCnt; right++) {
|
||||
const rightY = yChildren[yChildCnt - right - 1]
|
||||
const rightP = pContent.child(pChildCnt - right - 1)
|
||||
if (mapping.get(rightY) !== rightP) {
|
||||
if (equalYTypePNode(rightY, rightP)) {
|
||||
// update mapping
|
||||
mapping.set(rightY, rightP)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
yDomFragment._y.transact(() => {
|
||||
// try to compare and update
|
||||
while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) {
|
||||
const leftY = yChildren[left]
|
||||
const leftP = pContent.child(left)
|
||||
const rightY = yChildren[yChildCnt - right - 1]
|
||||
const rightP = pContent.child(pChildCnt - right - 1)
|
||||
if (leftY.constructor === YText && leftP.isText) {
|
||||
if (!equalYTextPText(leftY, leftP)) {
|
||||
// try to apply diff. Only if attrs don't match, delete insert
|
||||
// TODO: use a single ytext to hold all following Prosemirror Text nodes
|
||||
const pattrs = {}
|
||||
leftP.marks.forEach(mark => {
|
||||
if (mark.type.name !== 'ychange') {
|
||||
pattrs[mark.type.name] = mark.attrs
|
||||
}
|
||||
})
|
||||
const delta = leftY.toDelta()
|
||||
if (delta.length === 1 && delta[0].insert && equalAttrs(pattrs, delta[0].attributes || {})) {
|
||||
const diff = simpleDiff(delta[0].insert, leftP.text)
|
||||
leftY.delete(diff.pos, diff.remove)
|
||||
leftY.insert(diff.pos, diff.insert)
|
||||
} else {
|
||||
yDomFragment.delete(left, 1)
|
||||
yDomFragment.insert(left, [createTypeFromNode(leftP, mapping)])
|
||||
}
|
||||
}
|
||||
left += 1
|
||||
} else {
|
||||
let updateLeft = matchNodeName(leftY, leftP)
|
||||
let updateRight = matchNodeName(rightY, rightP)
|
||||
if (updateLeft && updateRight) {
|
||||
// decide which which element to update
|
||||
const equalityLeft = computeChildEqualityFactor(leftY, leftP, mapping)
|
||||
const equalityRight = computeChildEqualityFactor(rightY, rightP, mapping)
|
||||
if (equalityLeft.foundMappedChild && !equalityRight.foundMappedChild) {
|
||||
updateRight = false
|
||||
} else if (!equalityLeft.foundMappedChild && equalityRight.foundMappedChild) {
|
||||
updateLeft = false
|
||||
} else if (equalityLeft.equalityFactor < equalityRight.equalityFactor) {
|
||||
updateLeft = false
|
||||
} else {
|
||||
updateRight = false
|
||||
}
|
||||
}
|
||||
if (updateLeft) {
|
||||
updateYFragment(leftY, leftP, mapping)
|
||||
left += 1
|
||||
} else if (updateRight) {
|
||||
updateYFragment(rightY, rightP, mapping)
|
||||
right += 1
|
||||
} else {
|
||||
yDomFragment.delete(left, 1)
|
||||
yDomFragment.insert(left, [createTypeFromNode(leftP, mapping)])
|
||||
left += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
const yDelLen = yChildCnt - left - right
|
||||
if (yDelLen > 0) {
|
||||
yDomFragment.delete(left, yDelLen)
|
||||
}
|
||||
if (left + right < pChildCnt) {
|
||||
const ins = []
|
||||
for (let i = left; i < pChildCnt - right; i++) {
|
||||
ins.push(createTypeFromNode(pContent.child(i), mapping))
|
||||
}
|
||||
yDomFragment.insert(left, ins)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {YXmlElement} yElement
|
||||
* @param {any} pNode Prosemirror Node
|
||||
*/
|
||||
const matchNodeName = (yElement, pNode) => yElement.nodeName === pNode.type.name.toUpperCase()
|
||||
@@ -1,10 +1,14 @@
|
||||
import Binding from '../Binding.js'
|
||||
/**
|
||||
* @module bindings/quill
|
||||
*/
|
||||
|
||||
function typeObserver (event) {
|
||||
import { createMutex } from '../lib/mutex.js'
|
||||
|
||||
const typeObserver = function (event) {
|
||||
const quill = this.target
|
||||
// Force flush Quill changes.
|
||||
quill.update('yjs')
|
||||
this._mutualExclude(function () {
|
||||
this._mutualExclude(() => {
|
||||
// Apply computed delta.
|
||||
quill.updateContents(event.delta, 'yjs')
|
||||
// Force flush Quill changes. Ignore applied changes.
|
||||
@@ -12,7 +16,7 @@ function typeObserver (event) {
|
||||
})
|
||||
}
|
||||
|
||||
function quillObserver (delta) {
|
||||
const quillObserver = function (delta) {
|
||||
this._mutualExclude(() => {
|
||||
this.type.applyDelta(delta.ops)
|
||||
})
|
||||
@@ -28,14 +32,27 @@ function quillObserver (delta) {
|
||||
* // Now modifications on the DOM will be reflected in the Type, and the other
|
||||
* // way around!
|
||||
*/
|
||||
export default class QuillBinding extends Binding {
|
||||
export class QuillBinding {
|
||||
/**
|
||||
* @param {YText} textType
|
||||
* @param {Quill} quill
|
||||
*/
|
||||
constructor (textType, quill) {
|
||||
// Binding handles textType as this.type and quill as this.target.
|
||||
super(textType, quill)
|
||||
/**
|
||||
* The Yjs type that is bound to `target`
|
||||
* @type {YText}
|
||||
*/
|
||||
this.type = textType
|
||||
/**
|
||||
* The target that `type` is bound to.
|
||||
* @type {Quill}
|
||||
*/
|
||||
this.target = quill
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
this._mutualExclude = createMutex()
|
||||
// Set initial value.
|
||||
quill.setContents(textType.toDelta(), 'yjs')
|
||||
// Observers are handled by this class.
|
||||
@@ -48,6 +65,7 @@ export default class QuillBinding extends Binding {
|
||||
// Remove everything that is handled by this class.
|
||||
this.type.unobserve(this._typeObserver)
|
||||
this.target.off('text-change', this._quillObserver)
|
||||
super.destroy()
|
||||
this.type = null
|
||||
this.target = null
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @module bindings/textarea
|
||||
*/
|
||||
|
||||
import Binding from '../Binding.js'
|
||||
import simpleDiff from '../../Util/simpleDiff.js'
|
||||
import { getRelativePosition, fromRelativePosition } from '../../Util/relativePosition.js'
|
||||
import { simpleDiff } from '../lib/diff.js'
|
||||
import { getRelativePosition, fromRelativePosition } from '../utils/relativePosition.js'
|
||||
import { createMutex } from '../lib/mutex.js'
|
||||
|
||||
function typeObserver () {
|
||||
this._mutualExclude(() => {
|
||||
@@ -35,10 +38,22 @@ function domObserver () {
|
||||
* const binding = new Y.QuillBinding(type, textarea)
|
||||
*
|
||||
*/
|
||||
export default class TextareaBinding extends Binding {
|
||||
export class TextareaBinding {
|
||||
constructor (textType, domTextarea) {
|
||||
// Binding handles textType as this.type and domTextarea as this.target
|
||||
super(textType, domTextarea)
|
||||
/**
|
||||
* The Yjs type that is bound to `target`
|
||||
* @type {Type}
|
||||
*/
|
||||
this.type = textType
|
||||
/**
|
||||
* The target that `type` is bound to.
|
||||
* @type {*}
|
||||
*/
|
||||
this.target = domTextarea
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
this._mutualExclude = createMutex()
|
||||
// set initial value
|
||||
domTextarea.value = textType.toString()
|
||||
// Observers are handled by this class
|
||||
@@ -51,6 +66,7 @@ export default class TextareaBinding extends Binding {
|
||||
// Remove everything that is handled by this class
|
||||
this.type.unobserve(this._typeObserver)
|
||||
this.target.unobserve(this._domObserver)
|
||||
super.destroy()
|
||||
this.type = null
|
||||
this.target = null
|
||||
}
|
||||
}
|
||||
1
examples/.gitignore
vendored
Normal file
1
examples/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
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">
|
||||
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>
|
||||
23
examples/codemirror.js
Normal file
23
examples/codemirror.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as Y from '../index.js'
|
||||
import { WebsocketProvider } from '../provider/websocket.js'
|
||||
import { CodeMirrorBinding } from '../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
|
||||
}
|
||||
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">
|
||||
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 '../index.js'
|
||||
import { WebsocketProvider } from '../provider/websocket.js'
|
||||
import { DomBinding } from '../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
|
||||
}
|
||||
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,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,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 '../index.js'
|
||||
import { prosemirrorPluginKey } from '../bindings/prosemirror.js'
|
||||
import * as encoding from '../lib/encoding.js'
|
||||
import * as decoding from '../lib/decoding.js'
|
||||
import * as historyProtocol from '../protocols/history.js'
|
||||
|
||||
const niceColors = ['#3cb44b', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#008080', '#9a6324', '#800000', '#808000', '#000075', '#808080']
|
||||
|
||||
const createUserCSS = (userid, username, color = 'rgb(250, 129, 0)', color2 = 'rgba(250, 129, 0, .41)') => `
|
||||
[ychange_state][ychange_user="${userid}"]:hover::before {
|
||||
content: "${username}" !important;
|
||||
background-color: ${color} !important;
|
||||
}
|
||||
[ychange_state="added"][ychange_user="${userid}"] {
|
||||
background-color: ${color2} !important;
|
||||
}
|
||||
[ychange_state="removed"][ychange_user="${userid}"] {
|
||||
color: ${color} !important;
|
||||
}
|
||||
`
|
||||
|
||||
export const noteHistoryPlugin = new Plugin({
|
||||
state: {
|
||||
init (initargs, state) {
|
||||
return new NoteHistoryPlugin()
|
||||
},
|
||||
apply (tr, pluginState) {
|
||||
return pluginState
|
||||
}
|
||||
},
|
||||
view (editorView) {
|
||||
const hstate = noteHistoryPlugin.getState(editorView.state)
|
||||
hstate.init(editorView)
|
||||
return {
|
||||
destroy: hstate.destroy.bind(hstate)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const wrapper = crel('div', { style: 'display: flex;' })
|
||||
const historyContainer = crel('div', { style: 'align-self: baseline; flex-basis: 250px;', class: 'shared-history' })
|
||||
wrapper.insertBefore(historyContainer, null)
|
||||
const userStyleContainer = crel('style')
|
||||
wrapper.insertBefore(userStyleContainer, null)
|
||||
return { wrapper, historyContainer, userStyleContainer }
|
||||
}
|
||||
|
||||
class NoteHistoryPlugin {
|
||||
init (editorView) {
|
||||
this.editorView = editorView
|
||||
const { historyContainer, wrapper, userStyleContainer } = createWrapper()
|
||||
this.userStyleContainer = userStyleContainer
|
||||
this.wrapper = wrapper
|
||||
this.historyContainer = historyContainer
|
||||
const n = editorView.dom.parentNode.parentNode
|
||||
n.parentNode.replaceChild(this.wrapper, n)
|
||||
n.style['flex-grow'] = '1'
|
||||
wrapper.insertBefore(n, this.wrapper.firstChild)
|
||||
this.render()
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array)
|
||||
history.observe(this.render.bind(this))
|
||||
}
|
||||
destroy () {
|
||||
this.wrapper.parentNode.replaceChild(this.wrapper.firstChild, this.wrapper)
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array)
|
||||
history.unobserve(this.render)
|
||||
}
|
||||
render () {
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array).toArray()
|
||||
const fragment = document.createDocumentFragment()
|
||||
const snapshotBtn = crel('button', { type: 'button' }, ['snapshot'])
|
||||
fragment.insertBefore(snapshotBtn, null)
|
||||
let _prevSnap = null // empty
|
||||
snapshotBtn.addEventListener('click', () => {
|
||||
const awareness = y.getAwarenessInfo()
|
||||
const userMap = new Map()
|
||||
const aw = y.getLocalAwarenessInfo()
|
||||
userMap.set(y.userID, aw.name || 'unknown')
|
||||
awareness.forEach((a, userID) => {
|
||||
userMap.set(userID, a.name || 'Unknown')
|
||||
})
|
||||
this.snapshot(userMap)
|
||||
})
|
||||
history.forEach(buf => {
|
||||
const decoder = decoding.createDecoder(buf)
|
||||
const snapshot = historyProtocol.readHistorySnapshot(decoder)
|
||||
const date = new Date(decoding.readUint32(decoder) * 1000)
|
||||
const restoreBtn = crel('button', { type: 'button' }, ['restore'])
|
||||
const a = crel('a', [
|
||||
'• ' + date.toUTCString(), restoreBtn
|
||||
])
|
||||
const el = crel('div', [ a ])
|
||||
let prevSnapshot = _prevSnap // rebind to new variable
|
||||
restoreBtn.addEventListener('click', event => {
|
||||
if (prevSnapshot === null) {
|
||||
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
|
||||
}
|
||||
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot, restore: true }))
|
||||
event.stopPropagation()
|
||||
})
|
||||
a.addEventListener('click', () => {
|
||||
console.log('setting snapshot')
|
||||
if (prevSnapshot === null) {
|
||||
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
|
||||
}
|
||||
this.renderSnapshot(snapshot, prevSnapshot)
|
||||
})
|
||||
fragment.insertBefore(el, null)
|
||||
_prevSnap = snapshot
|
||||
})
|
||||
this.historyContainer.innerHTML = ''
|
||||
this.historyContainer.insertBefore(fragment, null)
|
||||
}
|
||||
renderSnapshot (snapshot, prevSnapshot) {
|
||||
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot }))
|
||||
/**
|
||||
* @type {Array<string|null>}
|
||||
*/
|
||||
let colors = niceColors.slice()
|
||||
let style = ''
|
||||
snapshot.userMap.forEach((name, userid) => {
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
const randInt = name.split('').map(s => s.charCodeAt(0)).reduce((a, b) => a + b)
|
||||
let color = null
|
||||
let i = 0
|
||||
for (; i < colors.length && color === null; i++) {
|
||||
color = colors[(randInt + i) % colors.length]
|
||||
}
|
||||
if (color === null) {
|
||||
colors = niceColors.slice()
|
||||
i = 0
|
||||
color = colors[randInt % colors.length]
|
||||
}
|
||||
colors[randInt % colors.length] = null
|
||||
style += createUserCSS(userid, name, color, color + '69')
|
||||
})
|
||||
this.userStyleContainer.innerHTML = style
|
||||
}
|
||||
/**
|
||||
* @param {Map<number, string>} [updatedUserMap] Maps from userid (yjs model) to account name (e.g. mail address)
|
||||
*/
|
||||
snapshot (updatedUserMap = new Map()) {
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array)
|
||||
const encoder = encoding.createEncoder()
|
||||
historyProtocol.writeHistorySnapshot(encoder, y, updatedUserMap)
|
||||
encoding.writeUint32(encoder, Math.floor(Date.now() / 1000))
|
||||
history.push([encoding.toBuffer(encoder)])
|
||||
}
|
||||
}
|
||||
117
examples/prosemirror.html
Normal file
117
examples/prosemirror.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<!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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>This example shows how to bind a YXmlFragment type to a <a href="http://prosemirror.net">Prosemirror</a> editor.</p>
|
||||
<p>The content of this editor is shared with every client who visits this domain.</p>
|
||||
<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">
|
||||
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>
|
||||
25
examples/prosemirror.js
Normal file
25
examples/prosemirror.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as Y from '../index.js'
|
||||
import { WebsocketProvider } from '../provider/websocket.js'
|
||||
import { prosemirrorPlugin, cursorPlugin } from '../bindings/prosemirror.js'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import { EditorState } from 'prosemirror-state'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { DOMParser, Schema } 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.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 /* noteHistoryPlugin */])
|
||||
})
|
||||
})
|
||||
|
||||
window.example = { provider, ydocument, type, prosemirrorView }
|
||||
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">
|
||||
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 '../index.js'
|
||||
import { WebsocketProvider } from '../provider/websocket.js'
|
||||
import { QuillBinding } from '../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)
|
||||
@@ -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.js',
|
||||
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}
|
||||
*/
|
||||
`
|
||||
}
|
||||
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">
|
||||
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>
|
||||
15
examples/textarea.js
Normal file
15
examples/textarea.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as Y from '../index.js'
|
||||
import { WebsocketProvider } from '../provider/websocket.js'
|
||||
import { TextareaBinding } from '../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
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<textarea style="width:80%;" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +0,0 @@
|
||||
/* eslint-env browser */
|
||||
import * as Y from '../../src/index.js'
|
||||
import WebsocketProvider from '../../provider/websocket/WebSocketProvider.js'
|
||||
|
||||
const provider = new WebsocketProvider('ws://localhost:1234/')
|
||||
const ydocument = provider.get('textarea')
|
||||
const type = ydocument.define('textarea', Y.Text)
|
||||
const textarea = document.querySelector('textarea')
|
||||
const binding = new Y.TextareaBinding(type, textarea)
|
||||
|
||||
window.textareaExample = {
|
||||
provider, ydocument, type, textarea, binding
|
||||
}
|
||||
@@ -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)
|
||||
@@ -14,7 +14,7 @@ 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) {
|
||||
const appendMessage = (message, position) => {
|
||||
var p = document.createElement('p')
|
||||
var uname = document.createElement('span')
|
||||
uname.appendChild(document.createTextNode(message.username + ': '))
|
||||
@@ -25,7 +25,7 @@ function appendMessage (message, position) {
|
||||
|
||||
// This function makes sure that only 7 messages exist in the chat history.
|
||||
// The rest is deleted
|
||||
function cleanupChat () {
|
||||
const cleanupChat = () => {
|
||||
if (chatprotocol.length > 7) {
|
||||
chatprotocol.delete(0, chatprotocol.length - 7)
|
||||
}
|
||||
@@ -36,7 +36,7 @@ cleanupChat()
|
||||
chatprotocol.toArray().forEach(appendMessage)
|
||||
|
||||
// whenever content changes, make sure to reflect the changes in the DOM
|
||||
chatprotocol.observe(function (event) {
|
||||
chatprotocol.observe(event => {
|
||||
// concurrent insertions may result in a history > 7, so cleanup here
|
||||
cleanupChat()
|
||||
chatcontainer.innerHTML = ''
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import YWebsocketsConnector from '../../src/Connectors/WebsocketsConnector/WebsocketsConnector.js'
|
||||
import Y from '../../src/Y.js'
|
||||
import DomBinding from '../../src/Bindings/DomBinding/DomBinding.js'
|
||||
import DomBinding from '../../bindings/DomBinding/DomBinding.js'
|
||||
import UndoManager from '../../src/Util/UndoManager.js'
|
||||
import YXmlFragment from '../../src/Types/YXml/YXmlFragment.js'
|
||||
import YXmlText from '../../src/Types/YXml/YXmlText.js'
|
||||
@@ -18,7 +18,6 @@
|
||||
</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>
|
||||
@@ -3,7 +3,7 @@
|
||||
import { createYdbClient } from '../../YdbClient/index.js'
|
||||
import Y from '../../src/Y.dist.js'
|
||||
import * as ydb from '../../YdbClient/YdbClient.js'
|
||||
import DomBinding from '../../src/Bindings/DomBinding/DomBinding.js'
|
||||
import DomBinding from '../../bindings/DomBinding/DomBinding.js'
|
||||
|
||||
const uuidv4 = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = Math.random() * 16 | 0
|
||||
@@ -36,13 +36,13 @@ let quill = new Quill('#quill-container', {
|
||||
|
||||
let cursors = quill.getModule('cursors')
|
||||
|
||||
function drawCursors () {
|
||||
const 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 lastUpdated = new Date(user.get('last updated')).getTime()
|
||||
if (lastUpdated != null && new Date().getTime() - 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 }
|
||||
57
index.js
Normal file
57
index.js
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
import './structs/Item.js'
|
||||
import { Delete } from './structs/Delete.js'
|
||||
import { ItemJSON } from './structs/ItemJSON.js'
|
||||
import { ItemString } from './structs/ItemString.js'
|
||||
import { ItemFormat } from './structs/ItemFormat.js'
|
||||
import { ItemEmbed } from './structs/ItemEmbed.js'
|
||||
import { ItemBinary } from './structs/ItemBinary.js'
|
||||
import { GC } from './structs/GC.js'
|
||||
|
||||
import { YArray } from './types/YArray.js'
|
||||
import { YMap } from './types/YMap.js'
|
||||
import { YText } from './types/YText.js'
|
||||
import { YXmlText } from './types/YXmlText.js'
|
||||
import { YXmlHook } from './types/YXmlHook.js'
|
||||
import { YXmlElement, YXmlFragment } from './types/YXmlElement.js'
|
||||
|
||||
import { registerStruct } from './utils/structReferences.js'
|
||||
|
||||
import * as decoding from './lib/decoding.js'
|
||||
import * as encoding from './lib/encoding.js'
|
||||
import * as awarenessProtocol from './protocols/awareness.js'
|
||||
import * as syncProtocol from './protocols/sync.js'
|
||||
import * as authProtocol from './protocols/auth.js'
|
||||
|
||||
export { decoding, encoding, awarenessProtocol, syncProtocol, authProtocol }
|
||||
|
||||
export { Y } from './utils/Y.js'
|
||||
export { UndoManager } from './utils/UndoManager.js'
|
||||
export { Transaction } from './utils/Transaction.js'
|
||||
|
||||
export { YArray as Array } from './types/YArray.js'
|
||||
export { YMap as Map } from './types/YMap.js'
|
||||
export { YText as Text } from './types/YText.js'
|
||||
export { YXmlText as XmlText } from './types/YXmlText.js'
|
||||
export { YXmlHook as XmlHook } from './types/YXmlHook.js'
|
||||
export { YXmlElement as XmlElement, YXmlFragment as XmlFragment } from './types/YXmlElement.js'
|
||||
|
||||
export { getRelativePosition, fromRelativePosition } from './utils/relativePosition.js'
|
||||
export { registerStruct } from './utils/structReferences.js'
|
||||
export * from './lib/mutex.js'
|
||||
|
||||
registerStruct(0, GC)
|
||||
registerStruct(1, ItemJSON)
|
||||
registerStruct(2, ItemString)
|
||||
registerStruct(3, ItemFormat)
|
||||
registerStruct(4, Delete)
|
||||
|
||||
registerStruct(5, YArray)
|
||||
registerStruct(6, YMap)
|
||||
registerStruct(7, YText)
|
||||
registerStruct(8, YXmlFragment)
|
||||
registerStruct(9, YXmlElement)
|
||||
registerStruct(10, YXmlText)
|
||||
registerStruct(11, YXmlHook)
|
||||
registerStruct(12, ItemEmbed)
|
||||
registerStruct(13, ItemBinary)
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Handles named events.
|
||||
*/
|
||||
export default class NamedEventHandler {
|
||||
export class NamedEventHandler {
|
||||
constructor () {
|
||||
this._eventListener = new Map()
|
||||
this._stateListener = new Map()
|
||||
@@ -57,7 +57,7 @@ export default class NamedEventHandler {
|
||||
let state = this._stateListener.get(name)
|
||||
if (state === undefined) {
|
||||
state = {}
|
||||
state.promise = new Promise(function (resolve) {
|
||||
state.promise = new Promise(resolve => {
|
||||
state.resolve = resolve
|
||||
})
|
||||
this._stateListener.set(name, state)
|
||||
|
||||
19
lib/Tree.js
19
lib/Tree.js
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* @module tree
|
||||
*/
|
||||
|
||||
function rotate (tree, parent, newParent, n) {
|
||||
const rotate = (tree, parent, newParent, n) => {
|
||||
if (parent === null) {
|
||||
tree.root = newParent
|
||||
newParent._parent = null
|
||||
@@ -111,10 +114,16 @@ class N {
|
||||
}
|
||||
}
|
||||
|
||||
const isBlack = node =>
|
||||
node !== null ? node.isBlack() : true
|
||||
|
||||
const isRed = (node) =>
|
||||
node !== null ? node.isRed() : false
|
||||
|
||||
/*
|
||||
* This is a Red Black Tree implementation
|
||||
*/
|
||||
export default class Tree {
|
||||
export class Tree {
|
||||
constructor () {
|
||||
this.root = null
|
||||
this.length = 0
|
||||
@@ -310,12 +319,6 @@ export default class Tree {
|
||||
}
|
||||
}
|
||||
_fixDelete (n) {
|
||||
function isBlack (node) {
|
||||
return node !== null ? node.isBlack() : true
|
||||
}
|
||||
function isRed (node) {
|
||||
return node !== null ? node.isRed() : false
|
||||
}
|
||||
if (n.parent === null) {
|
||||
// this can only be called after the first iteration of fixDelete.
|
||||
return
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
/**
|
||||
* @module binary
|
||||
*/
|
||||
|
||||
import * as string from './string.js'
|
||||
import * as globals from './globals.js'
|
||||
|
||||
export const BITS32 = 0xFFFFFFFF
|
||||
export const BITS21 = (1 << 21) - 1
|
||||
@@ -5,3 +13,28 @@ export const BITS16 = (1 << 16) - 1
|
||||
|
||||
export const BIT26 = 1 << 26
|
||||
export const BIT32 = 1 << 32
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} bytes
|
||||
* @return {string}
|
||||
*/
|
||||
export const toBase64 = bytes => {
|
||||
let s = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
s += string.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
export const fromBase64 = s => {
|
||||
const a = atob(s)
|
||||
const bytes = globals.createUint8ArrayFromLen(a.length)
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
bytes[i] = a.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
72
lib/broadcastchannel.js
Normal file
72
lib/broadcastchannel.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
import * as binary from './binary.js'
|
||||
import * as globals from './globals.js'
|
||||
|
||||
/**
|
||||
* @typedef {Object} Channel
|
||||
* @property {Set<Function>} Channel.subs
|
||||
* @property {BC} Channel.bc
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {Map<string, Channel>}
|
||||
*/
|
||||
const channels = new Map()
|
||||
|
||||
class LocalStoragePolyfill {
|
||||
constructor (room) {
|
||||
this.room = room
|
||||
this.onmessage = null
|
||||
addEventListener('storage', e => e.key === room && this.onmessage !== null && this.onmessage({ data: binary.fromBase64(e.newValue) }))
|
||||
}
|
||||
/**
|
||||
* @param {ArrayBuffer} data
|
||||
*/
|
||||
postMessage (buf) {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(this.room, binary.toBase64(globals.createUint8ArrayFromArrayBuffer(buf)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use BroadcastChannel or Polyfill
|
||||
const BC = typeof BroadcastChannel === 'undefined' ? LocalStoragePolyfill : BroadcastChannel
|
||||
|
||||
/**
|
||||
* @param {string} room
|
||||
* @return {Channel}
|
||||
*/
|
||||
const getChannel = room => {
|
||||
let c = channels.get(room)
|
||||
if (c === undefined) {
|
||||
const subs = new Set()
|
||||
const bc = new BC(room)
|
||||
bc.onmessage = e => subs.forEach(sub => sub(e.data))
|
||||
c = {
|
||||
bc, subs
|
||||
}
|
||||
channels.set(room, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {string} room
|
||||
* @param {Function} f
|
||||
*/
|
||||
export const subscribe = (room, f) => getChannel(room).subs.add(f)
|
||||
|
||||
/**
|
||||
* Publish data to all subscribers (including subscribers on this tab)
|
||||
*
|
||||
* @function
|
||||
* @param {string} room
|
||||
* @param {ArrayBuffer} data
|
||||
*/
|
||||
export const publish = (room, data) => {
|
||||
const c = getChannel(room)
|
||||
c.bc.postMessage(data)
|
||||
c.subs.forEach(sub => sub(data))
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @module decoding
|
||||
*/
|
||||
|
||||
/* global Buffer */
|
||||
|
||||
@@ -17,17 +20,26 @@ export class Decoder {
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @return {Decoder}
|
||||
*/
|
||||
export const createDecoder = buffer => new Decoder(buffer)
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {Decoder} decoder
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const hasContent = decoder => decoder.pos !== decoder.arr.length
|
||||
|
||||
/**
|
||||
* Clone a decoder instance.
|
||||
* Optionally set a new position parameter.
|
||||
*
|
||||
* @function
|
||||
* @param {Decoder} decoder The decoder instance
|
||||
* @param {number} [newPos] Defaults to current position
|
||||
* @return {Decoder} A clone of `decoder`
|
||||
*/
|
||||
export const clone = (decoder, newPos = decoder.pos) => {
|
||||
@@ -38,6 +50,7 @@ export const clone = (decoder, newPos = decoder.pos) => {
|
||||
|
||||
/**
|
||||
* Read `len` bytes as an ArrayBuffer.
|
||||
* @function
|
||||
* @param {Decoder} decoder The decoder instance
|
||||
* @param {number} len The length of bytes to read
|
||||
* @return {ArrayBuffer}
|
||||
@@ -52,6 +65,7 @@ export const readArrayBuffer = (decoder, len) => {
|
||||
|
||||
/**
|
||||
* Read variable length payload as ArrayBuffer
|
||||
* @function
|
||||
* @param {Decoder} decoder
|
||||
* @return {ArrayBuffer}
|
||||
*/
|
||||
@@ -59,6 +73,7 @@ export const readPayload = decoder => readArrayBuffer(decoder, readVarUint(decod
|
||||
|
||||
/**
|
||||
* Read the rest of the content as an ArrayBuffer
|
||||
* @function
|
||||
* @param {Decoder} decoder
|
||||
* @return {ArrayBuffer}
|
||||
*/
|
||||
@@ -66,6 +81,7 @@ export const readTail = decoder => readArrayBuffer(decoder, decoder.arr.length -
|
||||
|
||||
/**
|
||||
* Skip one byte, jump to the next position.
|
||||
* @function
|
||||
* @param {Decoder} decoder The decoder instance
|
||||
* @return {number} The next position
|
||||
*/
|
||||
@@ -73,6 +89,7 @@ export const skip8 = decoder => decoder.pos++
|
||||
|
||||
/**
|
||||
* Read one byte as unsigned integer.
|
||||
* @function
|
||||
* @param {Decoder} decoder The decoder instance
|
||||
* @return {number} Unsigned 8-bit integer
|
||||
*/
|
||||
@@ -81,6 +98,7 @@ export const readUint8 = decoder => decoder.arr[decoder.pos++]
|
||||
/**
|
||||
* Read 4 bytes as unsigned integer.
|
||||
*
|
||||
* @function
|
||||
* @param {Decoder} decoder
|
||||
* @return {number} An unsigned integer.
|
||||
*/
|
||||
@@ -98,6 +116,7 @@ export const readUint32 = decoder => {
|
||||
* Look ahead without incrementing position.
|
||||
* to the next byte and read it as unsigned integer.
|
||||
*
|
||||
* @function
|
||||
* @param {Decoder} decoder
|
||||
* @return {number} An unsigned integer.
|
||||
*/
|
||||
@@ -109,6 +128,7 @@ export const peekUint8 = decoder => decoder.arr[decoder.pos]
|
||||
* * numbers < 2^7 is stored in one bytlength
|
||||
* * numbers < 2^14 is stored in two bylength
|
||||
*
|
||||
* @function
|
||||
* @param {Decoder} decoder
|
||||
* @return {number} An unsigned integer.length
|
||||
*/
|
||||
@@ -128,6 +148,20 @@ export const readVarUint = decoder => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look ahead and read varUint without incrementing position
|
||||
*
|
||||
* @function
|
||||
* @param {Decoder} decoder
|
||||
* @return {number}
|
||||
*/
|
||||
export const peekVarUint = decoder => {
|
||||
let pos = decoder.pos
|
||||
let s = readVarUint(decoder)
|
||||
decoder.pos = pos
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* Read string of variable length
|
||||
* * varUint is used to store the length of the string
|
||||
@@ -137,6 +171,7 @@ export const readVarUint = decoder => {
|
||||
* But most environments have a maximum number of arguments per functions.
|
||||
* For effiency reasons we apply a maximum of 10000 characters at once.
|
||||
*
|
||||
* @function
|
||||
* @param {Decoder} decoder
|
||||
* @return {String} The read String.
|
||||
*/
|
||||
@@ -157,6 +192,8 @@ export const readVarString = decoder => {
|
||||
|
||||
/**
|
||||
* Look ahead and read varString without incrementing position
|
||||
*
|
||||
* @function
|
||||
* @param {Decoder} decoder
|
||||
* @return {string}
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @module diff
|
||||
*/
|
||||
|
||||
/**
|
||||
* A SimpleDiff describes a change on a String.
|
||||
@@ -27,7 +30,7 @@
|
||||
* @param {String} b The updated version of the string
|
||||
* @return {SimpleDiff} The diff description.
|
||||
*/
|
||||
export default function simpleDiff (a, b) {
|
||||
export const simpleDiff = (a, b) => {
|
||||
let left = 0 // number of same characters counting from left
|
||||
let right = 0 // number of same characters counting from right
|
||||
while (left < a.length && left < b.length && a[left] === b[left]) {
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
/**
|
||||
* @module encoding
|
||||
*/
|
||||
import * as globals from './globals.js'
|
||||
|
||||
const bits7 = 0b1111111
|
||||
@@ -15,10 +17,18 @@ export class Encoder {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @return {Encoder}
|
||||
*/
|
||||
export const createEncoder = () => new Encoder()
|
||||
|
||||
/**
|
||||
* The current length of the encoded data.
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @return {number}
|
||||
*/
|
||||
export const length = encoder => {
|
||||
let len = encoder.cpos
|
||||
@@ -30,6 +40,8 @@ export const length = encoder => {
|
||||
|
||||
/**
|
||||
* Transform to ArrayBuffer. TODO: rename to .toArrayBuffer
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @return {ArrayBuffer} The created ArrayBuffer.
|
||||
*/
|
||||
@@ -48,6 +60,7 @@ export const toBuffer = encoder => {
|
||||
/**
|
||||
* Write one byte to the encoder.
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} num The byte that is to be encoded.
|
||||
*/
|
||||
@@ -64,6 +77,7 @@ export const write = (encoder, num) => {
|
||||
* Write one byte at a specific position.
|
||||
* Position must already be written (i.e. encoder.length > pos)
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} pos Position to which to write data
|
||||
* @param {number} num Unsigned 8-bit integer
|
||||
@@ -89,6 +103,7 @@ export const set = (encoder, pos, num) => {
|
||||
/**
|
||||
* Write one byte as an unsigned integer.
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
@@ -97,6 +112,7 @@ export const writeUint8 = (encoder, num) => write(encoder, num & bits8)
|
||||
/**
|
||||
* Write one byte as an unsigned Integer at a specific location.
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} pos The location where the data will be written.
|
||||
* @param {number} num The number that is to be encoded.
|
||||
@@ -106,6 +122,7 @@ export const setUint8 = (encoder, pos, num) => set(encoder, pos, num & bits8)
|
||||
/**
|
||||
* Write two bytes as an unsigned integer.
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
@@ -116,6 +133,7 @@ export const writeUint16 = (encoder, num) => {
|
||||
/**
|
||||
* Write two bytes as an unsigned integer at a specific location.
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} pos The location where the data will be written.
|
||||
* @param {number} num The number that is to be encoded.
|
||||
@@ -128,6 +146,7 @@ export const setUint16 = (encoder, pos, num) => {
|
||||
/**
|
||||
* Write two bytes as an unsigned integer
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
@@ -141,6 +160,7 @@ export const writeUint32 = (encoder, num) => {
|
||||
/**
|
||||
* Write two bytes as an unsigned integer at a specific location.
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} pos The location where the data will be written.
|
||||
* @param {number} num The number that is to be encoded.
|
||||
@@ -157,6 +177,7 @@ export const setUint32 = (encoder, pos, num) => {
|
||||
*
|
||||
* Encodes integers in the range from [0, 4294967295] / [0, 0xffffffff]. (max 32 bit unsigned integer)
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
@@ -171,6 +192,7 @@ export const writeVarUint = (encoder, num) => {
|
||||
/**
|
||||
* Write a variable length string.
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {String} str The string that is to be encoded.
|
||||
*/
|
||||
@@ -188,6 +210,7 @@ export const writeVarString = (encoder, str) => {
|
||||
*
|
||||
* TODO: can be improved!
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder The enUint8Arr
|
||||
* @param {Encoder} append The BinaryEncoder to be written.
|
||||
*/
|
||||
@@ -196,6 +219,7 @@ export const writeBinaryEncoder = (encoder, append) => writeArrayBuffer(encoder,
|
||||
/**
|
||||
* Append an arrayBuffer to the encoder.
|
||||
*
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {ArrayBuffer} arrayBuffer
|
||||
*/
|
||||
@@ -209,6 +233,7 @@ export const writeArrayBuffer = (encoder, arrayBuffer) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {Encoder} encoder
|
||||
* @param {ArrayBuffer} arrayBuffer
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* @module globals
|
||||
*/
|
||||
|
||||
/* eslint-env browser */
|
||||
|
||||
export const Uint8Array_ = Uint8Array
|
||||
@@ -54,8 +58,6 @@ export const until = (timeout, check) => createPromise((resolve, reject) => {
|
||||
|
||||
export const error = description => new Error(description)
|
||||
|
||||
export const max = (a, b) => a > b ? a : b
|
||||
|
||||
/**
|
||||
* @param {number} t Time to wait
|
||||
* @return {Promise} Promise that is resolved after t ms
|
||||
|
||||
11
lib/idb.js
11
lib/idb.js
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* @module lib/idb
|
||||
*/
|
||||
|
||||
/* eslint-env browser */
|
||||
|
||||
import * as globals from './globals.js'
|
||||
@@ -12,6 +16,8 @@ export const rtop = request => globals.createPromise((resolve, reject) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {Function} initDB Called when the database is first created
|
||||
* @return {Promise<IDBDatabase>}
|
||||
*/
|
||||
export const openDB = (name, initDB) => globals.createPromise((resolve, reject) => {
|
||||
@@ -115,7 +121,7 @@ export const getAllKeysValues = (store, range) =>
|
||||
* Iterate on keys and values
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {IDBKeyRange?} keyrange
|
||||
* @param {function(any, any)} f Return true in order to continue the cursor
|
||||
* @param {Function} f Return true in order to continue the cursor
|
||||
*/
|
||||
export const iterate = (store, keyrange, f) => globals.createPromise((resolve, reject) => {
|
||||
const request = store.openCursor(keyrange)
|
||||
@@ -135,9 +141,10 @@ export const iterate = (store, keyrange, f) => globals.createPromise((resolve, r
|
||||
|
||||
/**
|
||||
* Iterate on the keys (no values)
|
||||
*
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {IDBKeyRange} keyrange
|
||||
* @param {function(IDBCursor)} f Call `idbcursor.continue()` to iterate further
|
||||
* @param {function} f Call `idbcursor.continue()` to iterate further
|
||||
*/
|
||||
export const iterateKeys = (store, keyrange, f) => {
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as test from './test.js'
|
||||
import * as test from './testing.js'
|
||||
import * as idb from './idb.js'
|
||||
import * as logging from './logging.js'
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @module logging
|
||||
*/
|
||||
|
||||
import * as globals from './globals.js'
|
||||
|
||||
|
||||
28
lib/math.js
28
lib/math.js
@@ -1,2 +1,28 @@
|
||||
|
||||
/**
|
||||
* @module math
|
||||
*/
|
||||
export const floor = Math.floor
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @return {number} The sum of a and b
|
||||
*/
|
||||
export const add = (a, b) => a + b
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @return {number} The smaller element of a and b
|
||||
*/
|
||||
export const min = (a, b) => a < b ? a : b
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @return {number} The bigger element of a and b
|
||||
*/
|
||||
export const max = (a, b) => a > b ? a : b
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
*
|
||||
* @example
|
||||
* const mutex = createMutex()
|
||||
* mutex(function () {
|
||||
* mutex(() => {
|
||||
* // This function is immediately executed
|
||||
* mutex(function () {
|
||||
* // This function is never executed, as it is called with the same
|
||||
* // mutex function
|
||||
* mutex(() => {
|
||||
* // This function is not executed, as the mutex is already active.
|
||||
* })
|
||||
* })
|
||||
*
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
/**
|
||||
* @module number
|
||||
*/
|
||||
|
||||
export const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER
|
||||
export const MIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER
|
||||
|
||||
14
lib/object.js
Normal file
14
lib/object.js
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
export const create = Object.create(null)
|
||||
|
||||
export const keys = Object.keys
|
||||
|
||||
export const equalFlat = (a, b) => {
|
||||
const keys = Object.keys(a)
|
||||
let eq = keys.length === Object.keys(b).length
|
||||
for (let i = 0; i < keys.length && eq; i++) {
|
||||
const key = keys[i]
|
||||
eq = a[key] === b[key]
|
||||
}
|
||||
return eq
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* @module prng
|
||||
*/
|
||||
const N = 624
|
||||
const M = 397
|
||||
|
||||
function twist (u, v) {
|
||||
return ((((u & 0x80000000) | (v & 0x7fffffff)) >>> 1) ^ ((v & 1) ? 0x9908b0df : 0))
|
||||
}
|
||||
const twist = (u, v) => ((((u & 0x80000000) | (v & 0x7fffffff)) >>> 1) ^ ((v & 1) ? 0x9908b0df : 0))
|
||||
|
||||
function nextState (state) {
|
||||
const nextState = (state) => {
|
||||
let p = 0
|
||||
let j
|
||||
for (j = N - M + 1; --j; p++) {
|
||||
@@ -29,7 +30,7 @@ function nextState (state) {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export default class Mt19937 {
|
||||
export class Mt19937 {
|
||||
/**
|
||||
* @param {Number} seed The starting point for the random number generation. If you use the same seed, the generator will return the same sequence of random numbers.
|
||||
*/
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* @module prng
|
||||
*/
|
||||
|
||||
import Mt19937 from './Mt19937.js'
|
||||
import Xoroshiro128plus from './Xoroshiro128plus.js'
|
||||
import Xorshift32 from './Xorshift32.js'
|
||||
import { Mt19937 } from './Mt19937.js'
|
||||
import { Xoroshiro128plus } from './Xoroshiro128plus.js'
|
||||
import { Xorshift32 } from './Xorshift32.js'
|
||||
import * as time from '../../time.js'
|
||||
|
||||
const DIAMETER = 300
|
||||
const NUMBERS = 10000
|
||||
|
||||
function runPRNG (name, Gen) {
|
||||
const runPRNG = (name, Gen) => {
|
||||
console.log('== ' + name + ' ==')
|
||||
const gen = new Gen(1234)
|
||||
let head = 0
|
||||
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* @module prng
|
||||
*/
|
||||
|
||||
import Xorshift32 from './Xorshift32.js'
|
||||
import { Xorshift32 } from './Xorshift32.js'
|
||||
|
||||
/**
|
||||
* This is a variant of xoroshiro128plus - the fastest full-period generator passing BigCrush without systematic failures.
|
||||
@@ -14,7 +17,7 @@ import Xorshift32 from './Xorshift32.js'
|
||||
*
|
||||
* [Reference implementation](http://vigna.di.unimi.it/xorshift/xoroshiro128plus.c)
|
||||
*/
|
||||
export default class Xoroshiro128plus {
|
||||
export class Xoroshiro128plus {
|
||||
constructor (seed) {
|
||||
this.seed = seed
|
||||
// This is a variant of Xoroshiro128plus to fill the initial state
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* @module prng
|
||||
*/
|
||||
|
||||
/**
|
||||
* Xorshift32 is a very simple but elegang PRNG with a period of `2^32-1`.
|
||||
*/
|
||||
export default class Xorshift32 {
|
||||
export class Xorshift32 {
|
||||
/**
|
||||
* @param {number} seed The starting point for the random number generation. If you use the same seed, the generator will return the same sequence of random numbers.
|
||||
*/
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* @module prng
|
||||
*/
|
||||
|
||||
import * as binary from '../binary.js'
|
||||
import { fromCharCode, fromCodePoint } from '../string.js'
|
||||
import { MAX_SAFE_INTEGER, MIN_SAFE_INTEGER } from '../number.js'
|
||||
import * as math from '../math.js'
|
||||
|
||||
import DefaultPRNG from './PRNG/Xoroshiro128plus.js'
|
||||
import { Xoroshiro128plus as DefaultPRNG } from './PRNG/Xoroshiro128plus.js'
|
||||
|
||||
/**
|
||||
* Description of the function
|
||||
@@ -1,13 +1,17 @@
|
||||
/**
|
||||
* @module prng
|
||||
*/
|
||||
|
||||
/**
|
||||
*TODO: enable tests
|
||||
import * as rt from '../rich-text/formatters.mjs'
|
||||
import { test } from '../test/test.mjs'
|
||||
import Xoroshiro128plus from './PRNG/Xoroshiro128plus.mjs'
|
||||
import Xorshift32 from './PRNG/Xorshift32.mjs'
|
||||
import MT19937 from './PRNG/Mt19937.mjs'
|
||||
import { generateBool, generateInt, generateInt32, generateReal, generateChar } from './random.mjs'
|
||||
import { MAX_SAFE_INTEGER } from '../number/constants.mjs'
|
||||
import { BIT32 } from '../binary/constants.mjs'
|
||||
import * as rt from '../rich-text/formatters.js''
|
||||
import { test } from '../test/test.js''
|
||||
import Xoroshiro128plus from './PRNG/Xoroshiro128plus.js''
|
||||
import Xorshift32 from './PRNG/Xorshift32.js''
|
||||
import MT19937 from './PRNG/Mt19937.js''
|
||||
import { generateBool, generateInt, generateInt32, generateReal, generateChar } from './random.js''
|
||||
import { MAX_SAFE_INTEGER } from '../number/constants.js''
|
||||
import { BIT32 } from '../binary/constants.js''
|
||||
|
||||
function init (Gen) {
|
||||
return {
|
||||
3
lib/random.js
Normal file
3
lib/random.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/**
|
||||
* @module random
|
||||
*/
|
||||
@@ -1,2 +1,6 @@
|
||||
/**
|
||||
* @module string
|
||||
*/
|
||||
|
||||
export const fromCharCode = String.fromCharCode
|
||||
export const fromCodePoint = String.fromCodePoint
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/**
|
||||
* @module testing
|
||||
*/
|
||||
|
||||
import * as logging from './logging.js'
|
||||
import simpleDiff from './simpleDiff.js'
|
||||
import { simpleDiff } from './diff.js'
|
||||
|
||||
export const run = async (name, f) => {
|
||||
console.log(`%cStart:%c ${name}`, 'color:blue;', '')
|
||||
1197
package-lock.json
generated
1197
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
85
package.json
85
package.json
@@ -1,30 +1,55 @@
|
||||
{
|
||||
"name": "yjs",
|
||||
"version": "13.0.0-66",
|
||||
"description": "A framework for real-time p2p shared editing on any data",
|
||||
"main": "./y.node.js",
|
||||
"browser": "./y.js",
|
||||
"module": "./src/index.js",
|
||||
"version": "13.0.0-77",
|
||||
"description": "A ",
|
||||
"main": "./build/yjs.js",
|
||||
"module": "./index.js'",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"test": "npm run lint",
|
||||
"debug": "concurrently 'rollup -wc rollup.test.js' 'cutest-serve y.test.js -o'",
|
||||
"lint": "standard src/**/*.js test/**/*.js tests-lib/**/*.js",
|
||||
"docs": "esdoc",
|
||||
"build": "rm -rf build examples/build && PRODUCTION=1 rollup -c",
|
||||
"watch": "rollup -wc",
|
||||
"debug": "concurrently 'npm run watch' 'cutest-serve build/y.test.js -o'",
|
||||
"lint": "standard **/*.js",
|
||||
"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"
|
||||
"postversion": "npm run build",
|
||||
"websocket-server": "node ./provider/websocket/server.js",
|
||||
"now-start": "npm run websocket-server"
|
||||
},
|
||||
"files": [
|
||||
"y.*",
|
||||
"src/*",
|
||||
".esdoc.json",
|
||||
"docs/*"
|
||||
"build/*",
|
||||
"bindings/*",
|
||||
"docs/*",
|
||||
"examples/*",
|
||||
"lib/*",
|
||||
"persistences/*",
|
||||
"protocols/*",
|
||||
"provider/*",
|
||||
"bindings/*",
|
||||
"structs/*",
|
||||
"tests/*",
|
||||
"types/*",
|
||||
"utils/*",
|
||||
"index.js",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"dictionaries": {
|
||||
"doc": "docs",
|
||||
"example": "examples",
|
||||
"test": "tests",
|
||||
"lib": "./"
|
||||
},
|
||||
"bin": {
|
||||
"y-websocket-server": "provider/websocket/server.js"
|
||||
},
|
||||
"standard": {
|
||||
"ignore": [
|
||||
"/y.js",
|
||||
"/y.js.map"
|
||||
"/build",
|
||||
"/node_modules",
|
||||
"/rollup.test.js",
|
||||
"/rollup.test.js"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
@@ -32,13 +57,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",
|
||||
@@ -48,6 +67,7 @@
|
||||
},
|
||||
"homepage": "http://y-js.org",
|
||||
"devDependencies": {
|
||||
"@types/ws": "^6.0.1",
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-plugin-external-helpers": "^6.22.0",
|
||||
"babel-plugin-transform-regenerator": "^6.26.0",
|
||||
@@ -57,20 +77,31 @@
|
||||
"cutest": "^0.1.9",
|
||||
"esdoc": "^1.1.0",
|
||||
"esdoc-standard-plugin": "^1.0.0",
|
||||
"jsdoc": "^3.5.5",
|
||||
"prosemirror-example-setup": "^1.0.1",
|
||||
"prosemirror-schema-basic": "^1.0.0",
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-view": "^1.6.5",
|
||||
"quill": "^1.3.6",
|
||||
"quill-cursors": "^1.0.3",
|
||||
"rollup": "^0.58.2",
|
||||
"rollup-cli": "^1.0.9",
|
||||
"rollup-plugin-babel": "^2.7.1",
|
||||
"rollup-plugin-commonjs": "^8.4.1",
|
||||
"rollup-plugin-inject": "^2.2.0",
|
||||
"rollup-plugin-multi-entry": "^2.0.2",
|
||||
"rollup-plugin-node-resolve": "^3.4.0",
|
||||
"rollup-plugin-uglify": "^1.0.2",
|
||||
"rollup-plugin-uglify": "^6.0.0",
|
||||
"rollup-plugin-uglify-es": "0.0.1",
|
||||
"rollup-regenerator-runtime": "^6.23.1",
|
||||
"rollup-watch": "^3.2.2",
|
||||
"standard": "^11.0.1"
|
||||
"standard": "^11.0.1",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"codemirror": "^5.42.0",
|
||||
"crel": "^3.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"optionalDependencies": {
|
||||
"level": "^4.0.0",
|
||||
"ws": "^6.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/*
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import * as encoding from '../../lib/encoding.js'
|
||||
import * as decoding from '../../lib/decoding.js'
|
||||
import { createMutualExclude } from '../../lib/mutualExclude.js'
|
||||
import * as encoding from '../lib/encoding.js'
|
||||
import * as decoding from '../lib/decoding.js'
|
||||
import { createMutex } from '../lib/mutex.js'
|
||||
import { encodeUpdate, encodeStructsDS, decodePersisted } from './decodePersisted.js'
|
||||
|
||||
function createFilePath (persistence, roomName) {
|
||||
@@ -10,10 +11,10 @@ function createFilePath (persistence, roomName) {
|
||||
return path.join(persistence.dir, roomName)
|
||||
}
|
||||
|
||||
export default class FilePersistence {
|
||||
export class FilePersistence {
|
||||
constructor (dir) {
|
||||
this.dir = dir
|
||||
this._mutex = createMutualExclude()
|
||||
this._mutex = createMutex()
|
||||
}
|
||||
setRemoteUpdateCounter (roomName, remoteUpdateCounter) {
|
||||
// TODO: implement
|
||||
@@ -70,3 +71,4 @@ export default class FilePersistence {
|
||||
})
|
||||
}
|
||||
}
|
||||
*/
|
||||
553
persistence/IndexedDBPersistence.js
Normal file
553
persistence/IndexedDBPersistence.js
Normal file
@@ -0,0 +1,553 @@
|
||||
/*
|
||||
import { Y } from '../utils/Y.js'
|
||||
import { createMutex } from '../lib/mutex.js'
|
||||
import { decodePersisted, encodeStructsDS, encodeUpdate, PERSIST_STRUCTS_DS, PERSIST_UPDATE } from './decodePersisted.js'
|
||||
|
||||
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 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: createMutex(),
|
||||
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
|
||||
import { Y } from '../utils/Y.js'
|
||||
import { createMutex } from '../lib/mutex.js'
|
||||
import { decodePersisted, encodeStructsDS, encodeUpdate, PERSIST_STRUCTS_DS, PERSIST_UPDATE } from './decodePersisted.js'
|
||||
|
||||
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 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: createMutex(),
|
||||
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))
|
||||
}
|
||||
}
|
||||
{
|
||||
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,3 +1,4 @@
|
||||
/*
|
||||
import { integrateRemoteStructs } from '../MessageHandler/integrateRemoteStructs.js'
|
||||
import { writeStructs } from '../MessageHandler/syncStep1.js'
|
||||
import { writeDeleteSet, readDeleteSet } from '../MessageHandler/deleteSet.js'
|
||||
@@ -6,10 +7,10 @@ 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) {
|
||||
* @param {Y} y A Yjs instance
|
||||
* @param {Encoder} updateEncoder I.e. transaction.encodedStructs
|
||||
*
|
||||
export const encodeUpdate = (y, updateEncoder, encoder) => {
|
||||
encoder.writeVarUint(PERSIST_UPDATE)
|
||||
encoder.writeBinaryEncoder(updateEncoder)
|
||||
}
|
||||
@@ -19,10 +20,10 @@ 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) {
|
||||
* @param {Y} y A Yjs instance
|
||||
* @param {Encoder} encoder An encoder to write to
|
||||
*
|
||||
export const encodeStructsDS = (y, encoder) => {
|
||||
encoder.writeVarUint(PERSIST_STRUCTS_DS)
|
||||
writeStructs(y, encoder, new Map())
|
||||
writeDeleteSet(y, encoder)
|
||||
@@ -30,10 +31,10 @@ export function encodeStructsDS (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) {
|
||||
* @param {Y} y A Yjs instance.
|
||||
* @param {Decoder} decoder A Decoder instance that holds the file content.
|
||||
*
|
||||
export const decodePersisted = (y, decoder) => {
|
||||
y.transact(() => {
|
||||
while (decoder.hasContent()) {
|
||||
const contentType = decoder.readVarUint()
|
||||
@@ -49,3 +50,4 @@ export function decodePersisted (y, decoder) {
|
||||
}
|
||||
}, true)
|
||||
}
|
||||
*/
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user