Compare commits
100 Commits
v13.0.0-71
...
v13-refact
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa3c92f44c | ||
|
|
cd82de7742 | ||
|
|
07a6a0044b | ||
|
|
4582832a71 | ||
|
|
07ac1d03e3 | ||
|
|
cbcf1facb8 | ||
|
|
31ff7ac78c | ||
|
|
ed3b31e58f | ||
|
|
759ecb21f7 | ||
|
|
9c29d820c8 | ||
|
|
2ef11a5344 | ||
|
|
9fe47e98d5 | ||
|
|
654510f3ff | ||
|
|
52ec698635 | ||
|
|
1b06f59d1c | ||
|
|
12bcc4d080 | ||
|
|
e1a9f314a7 | ||
|
|
7a111de186 | ||
|
|
90b3fa9dd9 | ||
|
|
c635963747 | ||
|
|
1b17b5e400 | ||
|
|
61d9d96d15 | ||
|
|
7d0c048708 | ||
|
|
8a7416ad50 | ||
|
|
e56899a02c | ||
|
|
30bf3742c9 | ||
|
|
8dbd2c4696 | ||
|
|
6578727c9c | ||
|
|
92ca001cdc | ||
|
|
415de1cc4c | ||
|
|
e23582b1cd | ||
|
|
73c28952c2 | ||
|
|
1bc1e88d6a | ||
|
|
c188f813a4 | ||
|
|
ff981a8697 | ||
|
|
d9ab593b07 | ||
|
|
293527e62b | ||
|
|
5a42a94cf4 | ||
|
|
040808300c | ||
|
|
57975d409e | ||
|
|
306b2c64f3 | ||
|
|
585265e9a5 | ||
|
|
777ae9503a | ||
|
|
4c1798e5fa | ||
|
|
f4d85e2a3e | ||
|
|
a0f0c9c377 | ||
|
|
95ec2a435a | ||
|
|
da9836fe59 | ||
|
|
3a7411f9e8 | ||
|
|
39cee7c6e7 | ||
|
|
0a5753c191 | ||
|
|
76b7d0b651 | ||
|
|
99e3e95a00 | ||
|
|
93ee4ee287 | ||
|
|
c5cc403a29 | ||
|
|
75f4a0a5f0 | ||
|
|
591df5c00a | ||
|
|
f6b4819ae3 | ||
|
|
d483d9cc83 | ||
|
|
453407b93d | ||
|
|
e699f92333 | ||
|
|
6ff47719ef | ||
|
|
3a0694c35c | ||
|
|
74e5243742 | ||
|
|
dcf43b9797 | ||
|
|
77e479c03b | ||
|
|
ec58a99748 | ||
|
|
f1eb66655b | ||
|
|
7f4ae9fe14 | ||
|
|
c0ba56a21f | ||
|
|
4063e28b5e | ||
|
|
b6f7cd7869 | ||
|
|
1a79e429ed | ||
|
|
04066a5678 | ||
|
|
e09ef15349 | ||
|
|
3d70eee959 | ||
|
|
582095e5a3 | ||
|
|
c9ea3a412e | ||
|
|
a2c51c36e9 | ||
|
|
ab3dba5b06 | ||
|
|
3ddff186c2 | ||
|
|
9bd199a6e7 | ||
|
|
01d0825ae6 | ||
|
|
e2f98525d2 | ||
|
|
70a0a03130 | ||
|
|
656d85c62e | ||
|
|
e168dd48fb | ||
|
|
12d43199d5 | ||
|
|
539fa8b21d | ||
|
|
f572f94586 | ||
|
|
c12d00b227 | ||
|
|
e4a5f2caec | ||
|
|
9f9f465238 | ||
|
|
8450ff86d7 | ||
|
|
70139262c5 | ||
|
|
9c0da271eb | ||
|
|
ade3e1949d | ||
|
|
eec63a008f | ||
|
|
52abcdd043 | ||
|
|
f94653424a |
10
.esdoc.json
10
.esdoc.json
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"source": "./src",
|
||||
"destination": "./docs",
|
||||
"plugins": [{
|
||||
"name": "esdoc-standard-plugin",
|
||||
"option": {
|
||||
"accessor": {"access": ["public"], "autoPrivate": true}
|
||||
}
|
||||
}]
|
||||
}
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,8 +1,4 @@
|
||||
node_modules
|
||||
bower_components
|
||||
docs
|
||||
/y.*
|
||||
/examples/*/index.dist.*
|
||||
dist
|
||||
.vscode
|
||||
.yjsPersisted
|
||||
build
|
||||
docs
|
||||
|
||||
50
.jsdoc.json
Normal file
50
.jsdoc.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"sourceType": "module",
|
||||
"tags": {
|
||||
"allowUnknownTags": true,
|
||||
"dictionaries": ["jsdoc"]
|
||||
},
|
||||
"source": {
|
||||
"include": ["./src"],
|
||||
"includePattern": ".js$"
|
||||
},
|
||||
"plugins": [
|
||||
"plugins/markdown"
|
||||
],
|
||||
"templates": {
|
||||
"referenceTitle": "Yjs",
|
||||
"disableSort": false,
|
||||
"useCollapsibles": true,
|
||||
"collapse": true,
|
||||
"resources": {
|
||||
"y-js.org": "yjs.website"
|
||||
},
|
||||
"logo": {
|
||||
"url": "https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png",
|
||||
"width": "162px",
|
||||
"height": "162px",
|
||||
"link": "/"
|
||||
},
|
||||
"tabNames": {
|
||||
"api": "API",
|
||||
"tutorials": "Examples"
|
||||
},
|
||||
"footerText": "Shared Editing",
|
||||
"css": [
|
||||
"./style.css"
|
||||
],
|
||||
"default": {
|
||||
"staticFiles": {
|
||||
"include": ["examples/"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"opts": {
|
||||
"destination": "./docs/",
|
||||
"encoding": "utf8",
|
||||
"private": false,
|
||||
"recurse": true,
|
||||
"template": "./node_modules/tui-jsdoc-template",
|
||||
"tutorials": "./examples"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -131,7 +131,7 @@ Here is a simple example of a shared textarea
|
||||
// name: 'xmpp'
|
||||
room: 'my-room' // clients connecting to the same room share data
|
||||
},
|
||||
sourceDir: '/bower_components', // location of the y-* modules (browser only)
|
||||
sourceDir: './bower_components', // location of the y-* modules (browser only)
|
||||
share: {
|
||||
textarea: 'Text' // y.share.textarea is of type y-text
|
||||
}
|
||||
|
||||
370
README.v13.md
Normal file
370
README.v13.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# 
|
||||
> The shared editing library
|
||||
|
||||
Yjs is a library for automatic conflict resolution on shared state. It implements an operation-based CRDT and exposes its internal CRDT model as shared types. Shared types are common data types like `Map` or `Array` with superpowers - changes are automatically distributed to other peers and merged without merge conflicts.
|
||||
|
||||
Yjs is **network agnostic** (p2p!), supports many existing **rich text editors**, **offline editing**, **version snapshots**, **shared cursors**, and encodes update messages using **binary protocol encoding**.
|
||||
|
||||
* 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 | Binding | Demo |
|
||||
|---|:-:|---|---|
|
||||
| [ProseMirror](https://prosemirror.net/) | ✔ | [y-prosemirror](http://github.com/y-js/y-prosemirror) | [link](https://yjs.website/tutorial-prosemirror.html) |
|
||||
| [Quill](https://quilljs.com/) | | [y-quill](http://github.com/y-js/y-quill) | [link](https://yjs.website/tutorial-quill.html) |
|
||||
| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](http://github.com/y-js/y-codemirror) | [link](https://yjs.website/tutorial-codemirror.html) |
|
||||
| [Ace](https://ace.c9.io/) | | [y-ace](http://github.com/y-js/y-ace) | [link]() |
|
||||
| [Monaco](https://microsoft.github.io/monaco-editor/) | | [y-monaco](http://github.com/y-js/y-monaco) | [link]() |
|
||||
|
||||
### Providers
|
||||
|
||||
Setting up the communication between clients, managing awareness information, and storing shared data for offline usage is quite a hassle. *Providers* manage all that for you and are a good off-the-shelf solution
|
||||
|
||||
* [y-websockets](http://github.com/y-js/y-websockets)
|
||||
* [y-webrtc](http://github.com/y-js/y-webrtc)
|
||||
* [y-dat](http://github.com/y-js/y-dat)
|
||||
|
||||
|
||||
### Shared Types
|
||||
|
||||
# Table of Contents
|
||||
|
||||
* [Getting Started](#Getting-Started)
|
||||
* [Tutorial](#Short-Tutorial)
|
||||
* [Providers](#Providers)
|
||||
* [Websocket](#Websocket)
|
||||
* [Ydb](#Ydb)
|
||||
* [Create a Custom Provider](#Create-a-Custom-Provider)
|
||||
* [Shared Types](#Shared-Types)
|
||||
* [YArray](#Yarray)
|
||||
* [YMap](#YMap)
|
||||
* [YText](#YText)
|
||||
* [YXmlFragment and YXmlElement](#YXmlFragment-and-YXmlElement)
|
||||
* [Create a Custom Type](#Create-a-Custom-Type)
|
||||
* [Bindings](#Bindings)
|
||||
* [PromeMirror](#ProseMirror)
|
||||
* [Quill](#Quill)
|
||||
* [CodeMirror](#CodeMirror)
|
||||
* [Ace](#Ace)
|
||||
* [Monaco](#Monace)
|
||||
* [DOM](#DOM)
|
||||
* [Textarea](#Textarea)
|
||||
* [Create a Custom Binding](#Create-a-Custom-Binding)
|
||||
* [Transaction](#Transaction)
|
||||
* [Offline Editing](#Offline-Editing)
|
||||
* [Awareness](#Awareness)
|
||||
* [Working with Yjs](#Working-with-Yjs)
|
||||
* [Typescript Declarations](#Typescript-Declarations)
|
||||
* [Binary Protocols](#Binary-Protocols)
|
||||
* [Sync Protocol](#Sync-Protocols)
|
||||
* [Awareness Protocol](#Awareness-Protocols)
|
||||
* [Auth Protocol](#Auth-Protocol)
|
||||
* [Yjs CRDT Algorithm](#Yjs-CRDT-Algorithm)
|
||||
* [Evaluation](#Evaluation)
|
||||
* [Existing shared editing libraries](#Exisisting-Javascript-Products)
|
||||
* [CRDT Algorithms](#CRDT-Algorithms)
|
||||
* [Comparison of CRDT with OT](#Comparing-CRDT-with-OT)
|
||||
* [Comparison of CRDT Algorithms](#Comparing-CRDT-Algorithms)
|
||||
* [Comparison of Yjs with other Implementations](#Comparing-Yjs-with-other-Implementations)
|
||||
* [License and Author](#License-and-Author)
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
##### Quickstart
|
||||
|
||||
Yjs itself only knows how to do conflict resolution. You need to choose a provider, that handles how document updates are distributed over the network.
|
||||
|
||||
We will start by running a websocket server (part of the [websocket provider](#Websocket-Provider)):
|
||||
|
||||
```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).
|
||||
|
||||
## API
|
||||
|
||||
## Providers
|
||||
|
||||
In Yjs, a provider handles the communication channel to *authenticate*, *authorize*, and *exchange document updates*. Yjs ships with some existing providers.
|
||||
|
||||
### Websocket Provider
|
||||
|
||||
The websocket provider implements a classical client server model. Clients connect to a single endpoint over websocket. The server distributes awareness information and document updates among clients.
|
||||
|
||||
The Websocket Provider is a solid choice if you want a central source that handles authentication and authorization. Websockets also send header information and cookies, so you can use existing authentication mechanisms with this server. I recommend that you slightly adapt the server in `./provider/websocket/server.js` to your needs.
|
||||
|
||||
* Supports cross-tab communication. When you open the same document in the same browser, changes on the document are exchanged via cross-tab communication ([Broadcast Channel](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API) and [localStorage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) as fallback).
|
||||
* Supports exange of awareness information (e.g. cursors)
|
||||
|
||||
##### Start a Websocket Server:
|
||||
|
||||
```sh
|
||||
PORT=1234 node ./node_modules/yjs/provider/websocket/server.js
|
||||
```
|
||||
|
||||
**Websocket Server with Persistence**
|
||||
|
||||
Persist document updates in a LevelDB database.
|
||||
|
||||
See [LevelDB Persistence](#LevelDB Persistence) for more info.
|
||||
|
||||
```sh
|
||||
PORT=1234 YPERSISTENCE=./dbDir node ./node_modules/yjs/provider/websocket/server.js
|
||||
```
|
||||
|
||||
##### Client Code:
|
||||
|
||||
```js
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
|
||||
const provider = new WebsocketProvider('http://localhost:1234')
|
||||
|
||||
// open a websocket connection to http://localhost:1234/my-document-name
|
||||
const sharedDocument = provider.get('my-document-name')
|
||||
|
||||
sharedDocument.on('status', event => {
|
||||
console.log(event.status) // logs "connected" or "disconnected"
|
||||
})
|
||||
```
|
||||
|
||||
#### Scaling
|
||||
|
||||
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,
|
||||
..
|
||||
},
|
||||
"maxNodeModuleJsDepth": 5
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -1,67 +0,0 @@
|
||||
|
||||
/**
|
||||
* Type that maps from Yjs type to Target type.
|
||||
* Used to implement double bindings.
|
||||
*
|
||||
* @template Y
|
||||
* @template T
|
||||
*/
|
||||
export default class BindMapping {
|
||||
/**
|
||||
*/
|
||||
constructor () {
|
||||
/**
|
||||
* @type Map<Y, T>
|
||||
*/
|
||||
this.yt = new Map()
|
||||
/**
|
||||
* @type Map<T, Y>
|
||||
*/
|
||||
this.ty = new Map()
|
||||
}
|
||||
/**
|
||||
* Map y to t. Removes all existing bindings from y and t
|
||||
* @param {Y} y
|
||||
* @param {T} t
|
||||
*/
|
||||
bind (y, t) {
|
||||
const existingT = this.yt.get(y)
|
||||
if (existingT !== undefined) {
|
||||
this.ty.delete(existingT)
|
||||
}
|
||||
const existingY = this.ty.get(t)
|
||||
if (existingY !== undefined) {
|
||||
this.yt.delete(existingY)
|
||||
}
|
||||
this.yt.set(y, t)
|
||||
this.ty.set(t, y)
|
||||
}
|
||||
/**
|
||||
* @param {Y} y
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasY (y) {
|
||||
return this.yt.has(y)
|
||||
}
|
||||
/**
|
||||
* @param {T} t
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasT (t) {
|
||||
return this.ty.has(t)
|
||||
}
|
||||
/**
|
||||
* @param {Y} y
|
||||
* @return {T}
|
||||
*/
|
||||
getY (y) {
|
||||
return this.yt.get(y)
|
||||
}
|
||||
/**
|
||||
* @param {T} t
|
||||
* @return {Y}
|
||||
*/
|
||||
getT (t) {
|
||||
return this.ty.get(t)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
|
||||
import { createMutex } from '../../lib/mutex.js'
|
||||
|
||||
/**
|
||||
* Abstract class for bindings.
|
||||
*
|
||||
* A binding handles data binding from a Yjs type to a data object. For example,
|
||||
* you can bind a Quill editor instance to a YText instance with the `QuillBinding` class.
|
||||
*
|
||||
* It is expected that a concrete implementation accepts two parameters
|
||||
* (type and binding target).
|
||||
*
|
||||
* @example
|
||||
* const quill = new Quill(document.createElement('div'))
|
||||
* const type = y.define('quill', Y.Text)
|
||||
* const binding = new Y.QuillBinding(quill, type)
|
||||
*
|
||||
*/
|
||||
export default class Binding {
|
||||
/**
|
||||
* @param {YType} type Yjs type.
|
||||
* @param {any} target Binding Target.
|
||||
*/
|
||||
constructor (type, target) {
|
||||
/**
|
||||
* The Yjs type that is bound to `target`
|
||||
* @type {YType}
|
||||
*/
|
||||
this.type = type
|
||||
/**
|
||||
* The target that `type` is bound to.
|
||||
* @type {*}
|
||||
*/
|
||||
this.target = target
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
this._mutualExclude = createMutex()
|
||||
}
|
||||
/**
|
||||
* Remove all data observers (both from the type and the target).
|
||||
*/
|
||||
destroy () {
|
||||
this.type = null
|
||||
this.target = null
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
/* global MutationObserver, getSelection */
|
||||
|
||||
import { fromRelativePosition } from '../../Util/relativePosition.js'
|
||||
import Binding from '../Binding.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'
|
||||
|
||||
/**
|
||||
* @typedef {import('./filter.js').DomFilter} DomFilter
|
||||
*/
|
||||
|
||||
/**
|
||||
* A binding that binds the children of a YXmlFragment to a DOM element.
|
||||
*
|
||||
* This binding is automatically destroyed when its parent is deleted.
|
||||
*
|
||||
* @example
|
||||
* const div = document.createElement('div')
|
||||
* const type = y.define('xml', Y.XmlFragment)
|
||||
* const binding = new Y.QuillBinding(type, div)
|
||||
*
|
||||
*/
|
||||
export default class DomBinding extends Binding {
|
||||
/**
|
||||
* @param {YXmlFragment} type The bind source. This is the ultimate source of
|
||||
* truth.
|
||||
* @param {Element} target The bind target. Mirrors the target.
|
||||
* @param {Object} [opts] Optional configurations
|
||||
|
||||
* @param {DomFilter} [opts.filter=defaultFilter] The filter function to use.
|
||||
*/
|
||||
constructor (type, target, opts = {}) {
|
||||
// Binding handles textType as this.type and domTextarea as this.target
|
||||
super(type, target)
|
||||
this.opts = opts
|
||||
opts.document = opts.document || document
|
||||
opts.hooks = opts.hooks || {}
|
||||
this.scrollingElement = opts.scrollingElement || null
|
||||
/**
|
||||
* Maps each DOM element to the type that it is associated with.
|
||||
* @type {Map}
|
||||
*/
|
||||
this.domToType = new Map()
|
||||
/**
|
||||
* Maps each YXml type to the DOM element that it is associated with.
|
||||
* @type {Map}
|
||||
*/
|
||||
this.typeToDom = new Map()
|
||||
/**
|
||||
* Defines which DOM attributes and elements to filter out.
|
||||
* Also filters remote changes.
|
||||
* @type {DomFilter}
|
||||
*/
|
||||
this.filter = opts.filter || defaultFilter
|
||||
// set initial value
|
||||
target.innerHTML = ''
|
||||
type.forEach(child => {
|
||||
target.insertBefore(child.toDom(opts.document, opts.hooks, this), null)
|
||||
})
|
||||
this._typeObserver = typeObserver.bind(this)
|
||||
this._domObserver = mutations => {
|
||||
domObserver.call(this, mutations, opts.document)
|
||||
}
|
||||
type.observeDeep(this._typeObserver)
|
||||
this._mutationObserver = new MutationObserver(this._domObserver)
|
||||
this._mutationObserver.observe(target, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
subtree: true
|
||||
})
|
||||
this._currentSel = null
|
||||
this._selectionchange = () => {
|
||||
this._currentSel = getCurrentRelativeSelection(this)
|
||||
}
|
||||
document.addEventListener('selectionchange', this._selectionchange)
|
||||
const y = type._y
|
||||
this.y = y
|
||||
// Force flush dom changes before Type changes are applied (they might
|
||||
// modify the dom)
|
||||
this._beforeTransactionHandler = (y, transaction, remote) => {
|
||||
this._domObserver(this._mutationObserver.takeRecords())
|
||||
this._mutualExclude(() => {
|
||||
beforeTransactionSelectionFixer(this, remote)
|
||||
})
|
||||
}
|
||||
y.on('beforeTransaction', this._beforeTransactionHandler)
|
||||
this._afterTransactionHandler = (y, transaction, remote) => {
|
||||
this._mutualExclude(() => {
|
||||
afterTransactionSelectionFixer(this, remote)
|
||||
})
|
||||
// remove associations
|
||||
// TODO: this could be done more efficiently
|
||||
// e.g. Always delete using the following approach, or removeAssociation
|
||||
// in dom/type-observer..
|
||||
transaction.deletedStructs.forEach(type => {
|
||||
const dom = this.typeToDom.get(type)
|
||||
if (dom !== undefined) {
|
||||
removeAssociation(this, dom, type)
|
||||
}
|
||||
})
|
||||
}
|
||||
y.on('afterTransaction', this._afterTransactionHandler)
|
||||
// Before calling observers, apply dom filter to all changed and new types.
|
||||
this._beforeObserverCallsHandler = (y, transaction) => {
|
||||
// Apply dom filter to new and changed types
|
||||
transaction.changedTypes.forEach((subs, type) => {
|
||||
// Only check attributes. New types are filtered below.
|
||||
if ((subs.size > 1 || (subs.size === 1 && subs.has(null) === false))) {
|
||||
applyFilterOnType(y, this, type)
|
||||
}
|
||||
})
|
||||
transaction.newTypes.forEach(type => {
|
||||
applyFilterOnType(y, this, type)
|
||||
})
|
||||
}
|
||||
y.on('beforeObserverCalls', this._beforeObserverCallsHandler)
|
||||
createAssociation(this, target, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: currently does not apply filter to existing elements!
|
||||
* @param {DomFilter} filter The filter function to use from now on.
|
||||
*/
|
||||
setFilter (filter) {
|
||||
this.filter = filter
|
||||
// TODO: apply filter to all elements
|
||||
}
|
||||
|
||||
_getUndoStackInfo () {
|
||||
return this.getSelection()
|
||||
}
|
||||
|
||||
_restoreUndoStackInfo (info) {
|
||||
this.restoreSelection(info)
|
||||
}
|
||||
|
||||
getSelection () {
|
||||
return this._currentSel
|
||||
}
|
||||
|
||||
restoreSelection (selection) {
|
||||
if (selection !== null) {
|
||||
const { to, from } = selection
|
||||
/**
|
||||
* There is little information on the difference between anchor/focus and base/extent.
|
||||
* MDN doesn't even mention base/extent anymore.. though you still have to call
|
||||
* setBaseAndExtent to change the selection..
|
||||
* I can observe that base/extend refer to notes higher up in the xml hierachy.
|
||||
* Espesially for undo/redo this is preferred. If this becomes a problem in the future,
|
||||
* we should probably go back to anchor/focus.
|
||||
*/
|
||||
const browserSelection = getSelection()
|
||||
let { baseNode, baseOffset, extentNode, extentOffset } = browserSelection
|
||||
if (from !== null) {
|
||||
let sel = fromRelativePosition(this.y, from)
|
||||
if (sel !== null) {
|
||||
let node = this.typeToDom.get(sel.type)
|
||||
let offset = sel.offset
|
||||
if (node !== baseNode || offset !== baseOffset) {
|
||||
baseNode = node
|
||||
baseOffset = offset
|
||||
}
|
||||
}
|
||||
}
|
||||
if (to !== null) {
|
||||
let sel = fromRelativePosition(this.y, to)
|
||||
if (sel !== null) {
|
||||
let node = this.typeToDom.get(sel.type)
|
||||
let offset = sel.offset
|
||||
if (node !== extentNode || offset !== extentOffset) {
|
||||
extentNode = node
|
||||
extentOffset = offset
|
||||
}
|
||||
}
|
||||
}
|
||||
browserSelection.setBaseAndExtent(
|
||||
baseNode,
|
||||
baseOffset,
|
||||
extentNode,
|
||||
extentOffset
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all properties that are handled by this class.
|
||||
*/
|
||||
destroy () {
|
||||
this.domToType = null
|
||||
this.typeToDom = null
|
||||
this.type.unobserveDeep(this._typeObserver)
|
||||
this._mutationObserver.disconnect()
|
||||
const y = this.type._y
|
||||
y.off('beforeTransaction', this._beforeTransactionHandler)
|
||||
y.off('beforeObserverCalls', this._beforeObserverCallsHandler)
|
||||
y.off('afterTransaction', this._afterTransactionHandler)
|
||||
document.removeEventListener('selectionchange', this._selectionchange)
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* A filter defines which elements and attributes to share.
|
||||
* Return null if the node should be filtered. Otherwise return the Map of
|
||||
* accepted attributes.
|
||||
*
|
||||
* @typedef {function(nodeName: String, attrs: Map): Map|null} FilterFunction
|
||||
*/
|
||||
@@ -1,144 +0,0 @@
|
||||
|
||||
import YXmlHook from '../../Types/YXml/YXmlHook.js'
|
||||
import {
|
||||
iterateUntilUndeleted,
|
||||
removeAssociation,
|
||||
insertNodeHelper } from './util.js'
|
||||
import diff from '../../../lib/simpleDiff.js'
|
||||
import YXmlFragment from '../../Types/YXml/YXmlFragment.js'
|
||||
|
||||
/**
|
||||
* 1. Check if any of the nodes was deleted
|
||||
* 2. Iterate over the children.
|
||||
* 2.1 If a node exists that is not yet bound to a type, insert a new node
|
||||
* 2.2 If _contents.length < dom.childNodes.length, fill the
|
||||
* rest of _content with childNodes
|
||||
* 2.3 If a node was moved, delete it and
|
||||
* recreate a new yxml element that is bound to that node.
|
||||
* You can detect that a node was moved because expectedId
|
||||
* !== actualId in the list
|
||||
* @private
|
||||
*/
|
||||
function applyChangesFromDom (binding, dom, yxml, _document) {
|
||||
if (yxml == null || yxml === false || yxml.constructor === YXmlHook) {
|
||||
return
|
||||
}
|
||||
const y = yxml._y
|
||||
const knownChildren = new Set()
|
||||
for (let i = dom.childNodes.length - 1; i >= 0; i--) {
|
||||
const type = binding.domToType.get(dom.childNodes[i])
|
||||
if (type !== undefined && type !== false) {
|
||||
knownChildren.add(type)
|
||||
}
|
||||
}
|
||||
// 1. Check if any of the nodes was deleted
|
||||
yxml.forEach(function (childType) {
|
||||
if (knownChildren.has(childType) === false) {
|
||||
childType._delete(y)
|
||||
removeAssociation(binding, binding.typeToDom.get(childType), childType)
|
||||
}
|
||||
})
|
||||
// 2. iterate
|
||||
const childNodes = dom.childNodes
|
||||
const len = childNodes.length
|
||||
let prevExpectedType = null
|
||||
let expectedType = iterateUntilUndeleted(yxml._start)
|
||||
for (let domCnt = 0; domCnt < len; domCnt++) {
|
||||
const childNode = childNodes[domCnt]
|
||||
const childType = binding.domToType.get(childNode)
|
||||
if (childType !== undefined) {
|
||||
if (childType === false) {
|
||||
// should be ignored or is going to be deleted
|
||||
continue
|
||||
}
|
||||
if (expectedType !== null) {
|
||||
if (expectedType !== childType) {
|
||||
// 2.3 Not expected node
|
||||
if (childType._parent !== yxml) {
|
||||
// child was moved from another parent
|
||||
// childType is going to be deleted by its previous parent
|
||||
removeAssociation(binding, childNode, childType)
|
||||
} else {
|
||||
// child was moved to a different position.
|
||||
removeAssociation(binding, childNode, childType)
|
||||
childType._delete(y)
|
||||
}
|
||||
prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode, _document, binding)
|
||||
} else {
|
||||
// Found expected node. Continue.
|
||||
prevExpectedType = expectedType
|
||||
expectedType = iterateUntilUndeleted(expectedType._right)
|
||||
}
|
||||
} else {
|
||||
// 2.2 Fill _content with child nodes
|
||||
prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode, _document, binding)
|
||||
}
|
||||
} else {
|
||||
// 2.1 A new node was found
|
||||
prevExpectedType = insertNodeHelper(yxml, prevExpectedType, childNode, _document, binding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export default function domObserver (mutations, _document) {
|
||||
this._mutualExclude(() => {
|
||||
this.type._y.transact(() => {
|
||||
let diffChildren = new Set()
|
||||
mutations.forEach(mutation => {
|
||||
const dom = mutation.target
|
||||
const yxml = this.domToType.get(dom)
|
||||
if (yxml === undefined) { // In case yxml is undefined, we double check if we forgot to bind the dom
|
||||
let parent = dom
|
||||
let yParent
|
||||
do {
|
||||
parent = parent.parentElement
|
||||
yParent = this.domToType.get(parent)
|
||||
} while (yParent === undefined && parent !== null)
|
||||
if (yParent !== false && yParent !== undefined && yParent.constructor !== YXmlHook) {
|
||||
diffChildren.add(parent)
|
||||
}
|
||||
return
|
||||
} else if (yxml === false || yxml.constructor === YXmlHook) {
|
||||
// dom element is filtered / a dom hook
|
||||
return
|
||||
}
|
||||
switch (mutation.type) {
|
||||
case 'characterData':
|
||||
var change = diff(yxml.toString(), dom.nodeValue)
|
||||
yxml.delete(change.pos, change.remove)
|
||||
yxml.insert(change.pos, change.insert)
|
||||
break
|
||||
case 'attributes':
|
||||
if (yxml.constructor === YXmlFragment) {
|
||||
break
|
||||
}
|
||||
let name = mutation.attributeName
|
||||
let val = dom.getAttribute(name)
|
||||
// check if filter accepts attribute
|
||||
let attributes = new Map()
|
||||
attributes.set(name, val)
|
||||
if (yxml.constructor !== YXmlFragment && this.filter(dom.nodeName, attributes).size > 0) {
|
||||
if (yxml.getAttribute(name) !== val) {
|
||||
if (val == null) {
|
||||
yxml.removeAttribute(name)
|
||||
} else {
|
||||
yxml.setAttribute(name, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'childList':
|
||||
diffChildren.add(mutation.target)
|
||||
break
|
||||
}
|
||||
})
|
||||
for (let dom of diffChildren) {
|
||||
const yxml = this.domToType.get(dom)
|
||||
applyChangesFromDom(this, dom, yxml, _document)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/* eslint-env browser */
|
||||
import YXmlText from '../../src/Types/YXml/YXmlText.js'
|
||||
import YXmlHook from '../../src/Types/YXml/YXmlHook.js'
|
||||
import YXmlElement from '../../src/Types/YXml/YXmlElement.js'
|
||||
import { createAssociation, domsToTypes } from './util.js'
|
||||
import { filterDomAttributes, defaultFilter } from './filter.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('./filter.js').DomFilter} DomFilter
|
||||
* @typedef {import('./DomBinding.js').default} DomBinding
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a Yjs type (YXml) based on the contents of a DOM Element.
|
||||
*
|
||||
* @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
|
||||
* @param {DomFilter} [filter=defaultFilter] Optional. Dom element filter
|
||||
* @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) {
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
let type = null
|
||||
if (element instanceof Element) {
|
||||
let hookName = null
|
||||
let hook
|
||||
// configure `hookName !== undefined` if element is a hook.
|
||||
if (element.hasAttribute('data-yjs-hook')) {
|
||||
hookName = element.getAttribute('data-yjs-hook')
|
||||
hook = hooks[hookName]
|
||||
if (hook === undefined) {
|
||||
console.error(`Unknown hook "${hookName}". Deleting yjsHook dataset property.`)
|
||||
element.removeAttribute('data-yjs-hook')
|
||||
hookName = null
|
||||
}
|
||||
}
|
||||
if (hookName === null) {
|
||||
// Not a hook
|
||||
const attrs = filterDomAttributes(element, filter)
|
||||
if (attrs === null) {
|
||||
type = false
|
||||
} else {
|
||||
type = new YXmlElement(element.nodeName)
|
||||
attrs.forEach((val, key) => {
|
||||
type.setAttribute(key, val)
|
||||
})
|
||||
type.insert(0, domsToTypes(element.childNodes, document, hooks, filter, binding))
|
||||
}
|
||||
} else {
|
||||
// Is a hook
|
||||
type = new YXmlHook(hookName)
|
||||
hook.fillType(element, type)
|
||||
}
|
||||
} else if (element instanceof Text) {
|
||||
type = new YXmlText()
|
||||
type.insert(0, element.nodeValue)
|
||||
} else {
|
||||
throw new Error('Can\'t transform this node type to a YXml type!')
|
||||
}
|
||||
createAssociation(binding, element, type)
|
||||
return type
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import isParentOf from '../../src/Util/isParentOf.js'
|
||||
|
||||
/**
|
||||
* @callback DomFilter
|
||||
* @param {string} nodeName
|
||||
* @param {Map<string, string>} attrs
|
||||
* @return {Map | null}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default filter method (does nothing).
|
||||
*
|
||||
* @param {String} nodeName The nodeName of the element
|
||||
* @param {Map} attrs Map of key-value pairs that are attributes of the node.
|
||||
* @return {Map | null} The allowed attributes or null, if the element should be
|
||||
* filtered.
|
||||
*/
|
||||
export function defaultFilter (nodeName, attrs) {
|
||||
// TODO: implement basic filter that filters out dangerous properties!
|
||||
return attrs
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function filterDomAttributes (dom, filter) {
|
||||
const attrs = new Map()
|
||||
for (let i = dom.attributes.length - 1; i >= 0; i--) {
|
||||
const attr = dom.attributes[i]
|
||||
attrs.set(attr.name, attr.value)
|
||||
}
|
||||
return filter(dom.nodeName, attrs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a filter on a type.
|
||||
*
|
||||
* @param {Y} y The Yjs instance.
|
||||
* @param {DomBinding} binding The DOM binding instance that has the dom filter.
|
||||
* @param {YXmlElement | YXmlFragment } type The type to apply the filter to.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function applyFilterOnType (y, binding, type) {
|
||||
if (isParentOf(binding.type, type)) {
|
||||
const nodeName = type.nodeName
|
||||
let attributes = new Map()
|
||||
if (type.getAttributes !== undefined) {
|
||||
let attrs = type.getAttributes()
|
||||
for (let key in attrs) {
|
||||
attributes.set(key, attrs[key])
|
||||
}
|
||||
}
|
||||
const filteredAttributes = binding.filter(nodeName, new Map(attributes))
|
||||
if (filteredAttributes === null) {
|
||||
type._delete(y)
|
||||
} else {
|
||||
// iterate original attributes
|
||||
attributes.forEach((value, key) => {
|
||||
// delete all attributes that are not in filteredAttributes
|
||||
if (filteredAttributes.has(key) === false) {
|
||||
type.removeAttribute(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/* globals getSelection */
|
||||
|
||||
import { getRelativePosition } from '../../src/Util/relativePosition.js'
|
||||
|
||||
let relativeSelection = null
|
||||
|
||||
function _getCurrentRelativeSelection (domBinding) {
|
||||
const { baseNode, baseOffset, extentNode, extentOffset } = getSelection()
|
||||
const baseNodeType = domBinding.domToType.get(baseNode)
|
||||
const extentNodeType = domBinding.domToType.get(extentNode)
|
||||
if (baseNodeType !== undefined && extentNodeType !== undefined) {
|
||||
return {
|
||||
from: getRelativePosition(baseNodeType, baseOffset),
|
||||
to: getRelativePosition(extentNodeType, extentOffset)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const getCurrentRelativeSelection = typeof getSelection !== 'undefined' ? _getCurrentRelativeSelection : domBinding => null
|
||||
|
||||
export function 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) {
|
||||
if (relativeSelection !== null) {
|
||||
domBinding.restoreSelection(relativeSelection)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/* eslint-env browser */
|
||||
/* global getSelection */
|
||||
|
||||
import YXmlText from '../../src/Types/YXml/YXmlText.js'
|
||||
import YXmlHook from '../../src/Types/YXml/YXmlHook.js'
|
||||
import { removeDomChildrenUntilElementFound } from './util.js'
|
||||
|
||||
function findScrollReference (scrollingElement) {
|
||||
if (scrollingElement !== null) {
|
||||
let anchor = getSelection().anchorNode
|
||||
if (anchor == null) {
|
||||
let children = scrollingElement.children // only iterate through non-text nodes
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const elem = children[i]
|
||||
const rect = elem.getBoundingClientRect()
|
||||
if (rect.top >= 0) {
|
||||
return { elem, top: rect.top }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/**
|
||||
* @type {Element}
|
||||
*/
|
||||
let elem = anchor.parentElement
|
||||
if (anchor instanceof Element) {
|
||||
elem = anchor
|
||||
}
|
||||
return {
|
||||
elem,
|
||||
top: elem.getBoundingClientRect().top
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function fixScroll (scrollingElement, ref) {
|
||||
if (ref !== null) {
|
||||
const { elem, top } = ref
|
||||
const currentTop = elem.getBoundingClientRect().top
|
||||
const newScroll = scrollingElement.scrollTop + currentTop - top
|
||||
if (newScroll >= 0) {
|
||||
scrollingElement.scrollTop = newScroll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export default function typeObserver (events) {
|
||||
this._mutualExclude(() => {
|
||||
const scrollRef = findScrollReference(this.scrollingElement)
|
||||
events.forEach(event => {
|
||||
const yxml = event.target
|
||||
const dom = this.typeToDom.get(yxml)
|
||||
if (dom !== undefined && dom !== false) {
|
||||
if (yxml.constructor === YXmlText) {
|
||||
dom.nodeValue = yxml.toString()
|
||||
} else if (event.attributesChanged !== undefined) {
|
||||
// update attributes
|
||||
event.attributesChanged.forEach(attributeName => {
|
||||
const value = yxml.getAttribute(attributeName)
|
||||
if (value === undefined) {
|
||||
dom.removeAttribute(attributeName)
|
||||
} else {
|
||||
dom.setAttribute(attributeName, value)
|
||||
}
|
||||
})
|
||||
/*
|
||||
* TODO: instead of hard-checking the types, it would be best to
|
||||
* specify the type's features. E.g.
|
||||
* - _yxmlHasAttributes
|
||||
* - _yxmlHasChildren
|
||||
* Furthermore, the features shouldn't be encoded in the types,
|
||||
* only in the attributes (above)
|
||||
*/
|
||||
if (event.childListChanged && yxml.constructor !== YXmlHook) {
|
||||
let currentChild = dom.firstChild
|
||||
yxml.forEach(childType => {
|
||||
const childNode = this.typeToDom.get(childType)
|
||||
switch (childNode) {
|
||||
case undefined:
|
||||
// Does not exist. Create it.
|
||||
const node = childType.toDom(this.opts.document, this.opts.hooks, this)
|
||||
dom.insertBefore(node, currentChild)
|
||||
break
|
||||
case false:
|
||||
// nop
|
||||
break
|
||||
default:
|
||||
// Is already attached to the dom.
|
||||
// Find it and remove all dom nodes in-between.
|
||||
removeDomChildrenUntilElementFound(dom, currentChild, childNode)
|
||||
currentChild = childNode.nextSibling
|
||||
break
|
||||
}
|
||||
})
|
||||
removeDomChildrenUntilElementFound(dom, currentChild, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
fixScroll(this.scrollingElement, scrollRef)
|
||||
})
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
|
||||
import domToType from './domToType.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('../../src/Types/YXml/YXmlText.js').default} YXmlText
|
||||
* @typedef {import('../../src/Types/YXml/YXmlElement.js').default} YXmlElement
|
||||
* @typedef {import('../../src/Types/YXml/YXmlHook.js').default} YXmlHook
|
||||
* @typedef {import('./DomBinding.js').default} DomBinding
|
||||
*/
|
||||
|
||||
/**
|
||||
* Iterates items until an undeleted item is found.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function iterateUntilUndeleted (item) {
|
||||
while (item !== null && item._deleted) {
|
||||
item = item._right
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an association (the information that a DOM element belongs to a
|
||||
* type).
|
||||
*
|
||||
* @param {DomBinding} domBinding The binding object
|
||||
* @param {Element} dom The dom that is to be associated with type
|
||||
* @param {YXmlElement|YXmlHook} type The type that is to be associated with dom
|
||||
*
|
||||
*/
|
||||
export function removeAssociation (domBinding, dom, type) {
|
||||
domBinding.domToType.delete(dom)
|
||||
domBinding.typeToDom.delete(type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an association (the information that a DOM element belongs to a
|
||||
* type).
|
||||
*
|
||||
* @param {DomBinding} domBinding The binding object
|
||||
* @param {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
|
||||
*
|
||||
*/
|
||||
export function createAssociation (domBinding, dom, type) {
|
||||
if (domBinding !== undefined) {
|
||||
domBinding.domToType.set(dom, type)
|
||||
domBinding.typeToDom.set(type, dom)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If oldDom is associated with a type, associate newDom with the type and
|
||||
* forget about oldDom. If oldDom is not associated with any type, nothing happens.
|
||||
*
|
||||
* @param {DomBinding} domBinding The binding object
|
||||
* @param {Element} oldDom The existing dom
|
||||
* @param {Element} newDom The new dom object
|
||||
*/
|
||||
export function switchAssociation (domBinding, oldDom, newDom) {
|
||||
if (domBinding !== undefined) {
|
||||
const type = domBinding.domToType.get(oldDom)
|
||||
if (type !== undefined) {
|
||||
removeAssociation(domBinding, oldDom, type)
|
||||
createAssociation(domBinding, newDom, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert Dom Elements after one of the children of this YXmlFragment.
|
||||
* The Dom elements will be bound to a new YXmlElement and inserted at the
|
||||
* specified position.
|
||||
*
|
||||
* @param {YXmlElement} type The type in which to insert DOM elements.
|
||||
* @param {YXmlElement|null} prev The reference node. New YxmlElements are
|
||||
* inserted after this node. Set null to insert at
|
||||
* the beginning.
|
||||
* @param {Array<Element>} doms The Dom elements to insert.
|
||||
* @param {?Document} _document Optional. Provide the global document object.
|
||||
* @param {DomBinding} binding The dom binding
|
||||
* @return {Array<YXmlElement>} The YxmlElements that are inserted.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function insertDomElementsAfter (type, prev, doms, _document, binding) {
|
||||
const types = domsToTypes(doms, _document, binding.opts.hooks, binding.filter, binding)
|
||||
return type.insertAfter(prev, types)
|
||||
}
|
||||
|
||||
export function domsToTypes (doms, _document, hooks, filter, binding) {
|
||||
const types = []
|
||||
for (let dom of doms) {
|
||||
const t = domToType(dom, _document, hooks, filter, binding)
|
||||
if (t !== false) {
|
||||
types.push(t)
|
||||
}
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
export function insertNodeHelper (yxml, prevExpectedNode, child, _document, binding) {
|
||||
let insertedNodes = insertDomElementsAfter(yxml, prevExpectedNode, [child], _document, binding)
|
||||
if (insertedNodes.length > 0) {
|
||||
return insertedNodes[0]
|
||||
} else {
|
||||
return prevExpectedNode
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove children until `elem` is found.
|
||||
*
|
||||
* @param {Element} parent The parent of `elem` and `currentChild`.
|
||||
* @param {Element} currentChild Start removing elements with `currentChild`. If
|
||||
* `currentChild` is `elem` it won't be removed.
|
||||
* @param {Element|null} elem The elemnt to look for.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
export function removeDomChildrenUntilElementFound (parent, currentChild, elem) {
|
||||
while (currentChild !== elem) {
|
||||
const del = currentChild
|
||||
currentChild = currentChild.nextSibling
|
||||
parent.removeChild(del)
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import BindMapping from '../BindMapping.js'
|
||||
import * as PModel from 'prosemirror-model'
|
||||
import * as Y from '../../src/index.js'
|
||||
import { createMutex } from '../../lib/mutex.js'
|
||||
import { Plugin, PluginKey } from 'prosemirror-state'
|
||||
import { Decoration, DecorationSet } from 'prosemirror-view'
|
||||
|
||||
/**
|
||||
* @typedef {import('prosemirror-view').EditorView} EditorView
|
||||
* @typedef {import('prosemirror-state').EditorState} EditorState
|
||||
* @typedef {BindMapping<Y.Text | Y.XmlElement, PModel.Node>} ProsemirrorMapping
|
||||
*/
|
||||
|
||||
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 {Y.XmlFragment} yXmlFragment
|
||||
*/
|
||||
export const prosemirrorPlugin = yXmlFragment => {
|
||||
const pluginState = {
|
||||
type: yXmlFragment,
|
||||
y: yXmlFragment._y,
|
||||
binding: null
|
||||
}
|
||||
const plugin = new Plugin({
|
||||
key: prosemirrorPluginKey,
|
||||
state: {
|
||||
init: (initargs, state) => {
|
||||
return pluginState
|
||||
},
|
||||
apply: (tr, pluginState) => {
|
||||
return pluginState
|
||||
}
|
||||
},
|
||||
view: view => {
|
||||
const binding = new ProsemirrorBinding(yXmlFragment, view)
|
||||
pluginState.binding = binding
|
||||
return {
|
||||
update: () => {
|
||||
binding._prosemirrorChanged()
|
||||
},
|
||||
destroy: () => {
|
||||
binding.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return plugin
|
||||
}
|
||||
|
||||
export const cursorPluginKey = new PluginKey('yjs-cursor')
|
||||
|
||||
export const cursorPlugin = new Plugin({
|
||||
key: cursorPluginKey,
|
||||
props: {
|
||||
decorations: state => {
|
||||
const y = prosemirrorPluginKey.getState(state).y
|
||||
const awareness = y.getAwarenessInfo()
|
||||
const decorations = []
|
||||
awareness.forEach((state, userID) => {
|
||||
if (state.cursor != null) {
|
||||
const username = `User: ${userID}`
|
||||
decorations.push(Decoration.widget(state.cursor.from, () => {
|
||||
const cursor = document.createElement('span')
|
||||
cursor.classList.add('ProseMirror-yjs-cursor')
|
||||
const user = document.createElement('div')
|
||||
user.insertBefore(document.createTextNode(username), null)
|
||||
cursor.insertBefore(user, null)
|
||||
return cursor
|
||||
}, { key: username }))
|
||||
decorations.push(Decoration.inline(state.cursor.from, state.cursor.to, { style: 'background-color: #ffa50070' }))
|
||||
}
|
||||
})
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
}
|
||||
},
|
||||
view: view => {
|
||||
const y = prosemirrorPluginKey.getState(view.state).y
|
||||
const awarenessListener = () => {
|
||||
view.updateState(view.state)
|
||||
}
|
||||
y.on('awareness', awarenessListener)
|
||||
return {
|
||||
update: () => {
|
||||
const y = prosemirrorPluginKey.getState(view.state).y
|
||||
const from = view.state.selection.from
|
||||
const to = view.state.selection.to
|
||||
const current = y.getLocalAwarenessInfo()
|
||||
if (current.cursor == null || current.cursor.to !== to || current.cursor.from !== from) {
|
||||
y.setAwarenessField('cursor', {
|
||||
from, to
|
||||
})
|
||||
}
|
||||
},
|
||||
destroy: () => {
|
||||
const y = prosemirrorPluginKey.getState(view.state).y
|
||||
y.setAwarenessField('cursor', null)
|
||||
y.off('awareness', awarenessListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default class ProsemirrorBinding {
|
||||
/**
|
||||
* @param {Y.XmlFragment} 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 BindMapping()
|
||||
this._observeFunction = this._typeChanged.bind(this)
|
||||
yXmlFragment.observeDeep(this._observeFunction)
|
||||
}
|
||||
_typeChanged (events) {
|
||||
if (events.length === 0) {
|
||||
return
|
||||
}
|
||||
this.mux(() => {
|
||||
events.forEach(event => {
|
||||
// recompute node for each parent
|
||||
// except main node, compute main node in the end
|
||||
let target = event.target
|
||||
if (target !== this.type) {
|
||||
do {
|
||||
if (target.constructor === Y.XmlElement) {
|
||||
createNodeFromYElement(target, this.prosemirrorView.state.schema, this.mapping)
|
||||
}
|
||||
target = target._parent
|
||||
} while (target._parent !== this.type)
|
||||
}
|
||||
})
|
||||
const fragmentContent = this.type.toArray().map(t => createNodeIfNotExists(t, this.prosemirrorView.state.schema, this.mapping))
|
||||
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.updateState(this.prosemirrorView.state.apply(tr))
|
||||
})
|
||||
}
|
||||
_prosemirrorChanged () {
|
||||
this.mux(() => {
|
||||
updateYFragment(this.type, this.prosemirrorView.state, this.mapping)
|
||||
})
|
||||
}
|
||||
destroy () {
|
||||
this.type.unobserveDeep(this._observeFunction)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y.XmlElement} el
|
||||
* @param {PModel.Schema} schema
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
* @return {PModel.Node}
|
||||
*/
|
||||
export const createNodeIfNotExists = (el, schema, mapping) => {
|
||||
const node = mapping.getY(el)
|
||||
if (node === undefined) {
|
||||
return createNodeFromYElement(el, schema, mapping)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y.XmlElement} el
|
||||
* @param {PModel.Schema} schema
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
* @return {PModel.Node}
|
||||
*/
|
||||
export const createNodeFromYElement = (el, schema, mapping) => {
|
||||
const children = []
|
||||
el.toArray().forEach(type => {
|
||||
if (type.constructor === Y.XmlElement) {
|
||||
children.push(createNodeIfNotExists(type, schema, mapping))
|
||||
} else {
|
||||
children.concat(createTextNodesFromYText(type, schema, mapping)).forEach(textchild => children.push(textchild))
|
||||
}
|
||||
})
|
||||
const node = schema.node(el.nodeName.toLowerCase(), el.getAttributes(), el.toArray().map(t => createNodeIfNotExists(t, schema, mapping)))
|
||||
mapping.bind(el, node)
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y.Text} text
|
||||
* @param {PModel.Schema} schema
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
* @return {Array<PModel.Node>}
|
||||
*/
|
||||
export const createTextNodesFromYText = (text, schema, mapping) => {
|
||||
const nodes = []
|
||||
const deltas = text.toDelta()
|
||||
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.bind(text, nodes[0]) // only map to first child, all following children are also considered bound to this type
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PModel.Node} node
|
||||
* @param {ProsemirrorMapping} mapping
|
||||
* @return {Y.XmlElement | Y.Text}
|
||||
*/
|
||||
export const createTypeFromNode = (node, mapping) => {
|
||||
let type
|
||||
if (node.isText) {
|
||||
type = new Y.Text()
|
||||
const attrs = {}
|
||||
node.marks.forEach(mark => { attrs[mark.type.name] = mark.attrs })
|
||||
type.insert(0, node.text, attrs)
|
||||
} else {
|
||||
type = new Y.XmlElement(node.type.name)
|
||||
for (let key in node.attrs) {
|
||||
type.setAttribute(key, node.attrs[key])
|
||||
}
|
||||
type.insert(0, node.content.content.map(node => createTypeFromNode(node, mapping)))
|
||||
}
|
||||
mapping.bind(type, node)
|
||||
return type
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Y.XmlFragment} yDomFragment
|
||||
* @param {EditorState} state
|
||||
* @param {BindMapping} mapping
|
||||
*/
|
||||
const updateYFragment = (yDomFragment, state, mapping) => {
|
||||
const pChildCnt = state.doc.content.childCount
|
||||
const yChildren = yDomFragment.toArray()
|
||||
const yChildCnt = yChildren.length
|
||||
const minCnt = pChildCnt < yChildCnt ? pChildCnt : yChildCnt
|
||||
let left = 0
|
||||
let right = 0
|
||||
// find number of matching elements from left
|
||||
for (;left < minCnt; left++) {
|
||||
if (state.doc.content.child(left) !== mapping.getY(yChildren[left])) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// find number of matching elements from right
|
||||
for (;right < minCnt; right++) {
|
||||
if (state.doc.content.child(pChildCnt - right - 1) !== mapping.getY(yChildren[yChildCnt - right - 1])) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (left + right > pChildCnt) {
|
||||
// nothing changed
|
||||
return
|
||||
}
|
||||
yDomFragment._y.transact(() => {
|
||||
// now update y to match editor state
|
||||
yDomFragment.delete(left, yChildCnt - left - right)
|
||||
yDomFragment.insert(left, state.doc.content.content.slice(left, pChildCnt - right).map(node => createTypeFromNode(node, mapping)))
|
||||
})
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import Binding from '../Binding.js'
|
||||
|
||||
function typeObserver (event) {
|
||||
const quill = this.target
|
||||
// Force flush Quill changes.
|
||||
quill.update('yjs')
|
||||
this._mutualExclude(function () {
|
||||
// Apply computed delta.
|
||||
quill.updateContents(event.delta, 'yjs')
|
||||
// Force flush Quill changes. Ignore applied changes.
|
||||
quill.update('yjs')
|
||||
})
|
||||
}
|
||||
|
||||
function quillObserver (delta) {
|
||||
this._mutualExclude(() => {
|
||||
this.type.applyDelta(delta.ops)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A Binding that binds a YText type to a Quill editor.
|
||||
*
|
||||
* @example
|
||||
* const quill = new Quill(document.createElement('div'))
|
||||
* const type = y.define('quill', Y.Text)
|
||||
* const binding = new Y.QuillBinding(quill, type)
|
||||
* // Now modifications on the DOM will be reflected in the Type, and the other
|
||||
* // way around!
|
||||
*/
|
||||
export default class QuillBinding extends Binding {
|
||||
/**
|
||||
* @param {YText} textType
|
||||
* @param {Quill} quill
|
||||
*/
|
||||
constructor (textType, quill) {
|
||||
// Binding handles textType as this.type and quill as this.target.
|
||||
super(textType, quill)
|
||||
// Set initial value.
|
||||
quill.setContents(textType.toDelta(), 'yjs')
|
||||
// Observers are handled by this class.
|
||||
this._typeObserver = typeObserver.bind(this)
|
||||
this._quillObserver = quillObserver.bind(this)
|
||||
textType.observe(this._typeObserver)
|
||||
quill.on('text-change', this._quillObserver)
|
||||
}
|
||||
destroy () {
|
||||
// Remove everything that is handled by this class.
|
||||
this.type.unobserve(this._typeObserver)
|
||||
this.target.off('text-change', this._quillObserver)
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
|
||||
import Binding from '../Binding.js'
|
||||
import simpleDiff from '../../lib/simpleDiff.js'
|
||||
import { getRelativePosition, fromRelativePosition } from '../../src/Util/relativePosition.js'
|
||||
|
||||
function typeObserver () {
|
||||
this._mutualExclude(() => {
|
||||
const textarea = this.target
|
||||
const textType = this.type
|
||||
const relativeStart = getRelativePosition(textType, textarea.selectionStart)
|
||||
const relativeEnd = getRelativePosition(textType, textarea.selectionEnd)
|
||||
textarea.value = textType.toString()
|
||||
const start = fromRelativePosition(textType._y, relativeStart)
|
||||
const end = fromRelativePosition(textType._y, relativeEnd)
|
||||
textarea.setSelectionRange(start, end)
|
||||
})
|
||||
}
|
||||
|
||||
function domObserver () {
|
||||
this._mutualExclude(() => {
|
||||
let diff = simpleDiff(this.type.toString(), this.target.value)
|
||||
this.type.delete(diff.pos, diff.remove)
|
||||
this.type.insert(diff.pos, diff.insert)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A binding that binds a YText to a dom textarea.
|
||||
*
|
||||
* This binding is automatically destroyed when its parent is deleted.
|
||||
*
|
||||
* @example
|
||||
* const textare = document.createElement('textarea')
|
||||
* const type = y.define('textarea', Y.Text)
|
||||
* const binding = new Y.QuillBinding(type, textarea)
|
||||
*
|
||||
*/
|
||||
export default class TextareaBinding extends Binding {
|
||||
constructor (textType, domTextarea) {
|
||||
// Binding handles textType as this.type and domTextarea as this.target
|
||||
super(textType, domTextarea)
|
||||
// set initial value
|
||||
domTextarea.value = textType.toString()
|
||||
// Observers are handled by this class
|
||||
this._typeObserver = typeObserver.bind(this)
|
||||
this._domObserver = domObserver.bind(this)
|
||||
textType.observe(this._typeObserver)
|
||||
domTextarea.addEventListener('input', this._domObserver)
|
||||
}
|
||||
destroy () {
|
||||
// Remove everything that is handled by this class
|
||||
this.type.unobserve(this._typeObserver)
|
||||
this.target.unobserve(this._domObserver)
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
1
examples/.gitignore
vendored
Normal file
1
examples/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
build
|
||||
@@ -1,33 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style type="text/css" media="screen">
|
||||
#aceContainer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
.inserted {
|
||||
position:absolute;
|
||||
z-index:20;
|
||||
background-color: #FFC107;
|
||||
}
|
||||
.deleted {
|
||||
position:absolute;
|
||||
z-index:20;
|
||||
background-color: #FFC107;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="aceContainer"></div>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../bower_components/ace-builds/src/ace.js"></script>
|
||||
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,17 +0,0 @@
|
||||
/* global Y, ace */
|
||||
|
||||
let y = new Y('ace-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yAce = y
|
||||
|
||||
// bind the textarea to a shared text element
|
||||
var editor = ace.edit('aceContainer')
|
||||
editor.setTheme('ace/theme/chrome')
|
||||
editor.getSession().setMode('ace/mode/javascript')
|
||||
|
||||
y.define('ace', Y.Text).bindAce(editor)
|
||||
@@ -1,19 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<style>
|
||||
#chat p span {
|
||||
color: blue;
|
||||
}
|
||||
</style>
|
||||
<div id="chat"></div>
|
||||
<form id="chatform">
|
||||
<input name="username" type="text" style="width:15%;">
|
||||
<input name="message" type="text" style="width:60%;">
|
||||
<input type="submit" value="Send">
|
||||
</form>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,65 +0,0 @@
|
||||
/* global Y */
|
||||
|
||||
let y = new Y('chat-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yChat = y
|
||||
|
||||
let chatprotocol = y.define('chatprotocol', Y.Array)
|
||||
|
||||
let chatcontainer = document.querySelector('#chat')
|
||||
|
||||
// This functions inserts a message at the specified position in the DOM
|
||||
function appendMessage (message, position) {
|
||||
var p = document.createElement('p')
|
||||
var uname = document.createElement('span')
|
||||
uname.appendChild(document.createTextNode(message.username + ': '))
|
||||
p.appendChild(uname)
|
||||
p.appendChild(document.createTextNode(message.message))
|
||||
chatcontainer.insertBefore(p, chatcontainer.children[position] || null)
|
||||
}
|
||||
|
||||
// This function makes sure that only 7 messages exist in the chat history.
|
||||
// The rest is deleted
|
||||
function cleanupChat () {
|
||||
if (chatprotocol.length > 7) {
|
||||
chatprotocol.delete(0, chatprotocol.length - 7)
|
||||
}
|
||||
}
|
||||
cleanupChat()
|
||||
|
||||
// Insert the initial content
|
||||
chatprotocol.toArray().forEach(appendMessage)
|
||||
|
||||
// whenever content changes, make sure to reflect the changes in the DOM
|
||||
chatprotocol.observe(function (event) {
|
||||
// concurrent insertions may result in a history > 7, so cleanup here
|
||||
cleanupChat()
|
||||
chatcontainer.innerHTML = ''
|
||||
chatprotocol.toArray().forEach(appendMessage)
|
||||
})
|
||||
document.querySelector('#chatform').onsubmit = function (event) {
|
||||
// the form is submitted
|
||||
var message = {
|
||||
username: this.querySelector('[name=username]').value,
|
||||
message: this.querySelector('[name=message]').value
|
||||
}
|
||||
if (message.username.length > 0 && message.message.length > 0) {
|
||||
if (chatprotocol.length > 6) {
|
||||
// If we are goint to insert the 8th element, make sure to delete first.
|
||||
chatprotocol.delete(0)
|
||||
}
|
||||
// Here we insert a message in the shared chat type.
|
||||
// This will call the observe function (see line 40)
|
||||
// and reflect the change in the DOM
|
||||
chatprotocol.push([message])
|
||||
this.querySelector('[name=message]').value = ''
|
||||
}
|
||||
// Do not send this form!
|
||||
event.preventDefault()
|
||||
return false
|
||||
}
|
||||
70
examples/codemirror.html
Normal file
70
examples/codemirror.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Yjs CodeMirror Example</title>
|
||||
<link rel=stylesheet href="https://codemirror.net/lib/codemirror.css">
|
||||
<style>
|
||||
#container {
|
||||
border: grey;
|
||||
border-style: solid;
|
||||
border-width: thin;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>This example shows how to bind a YText type to <a href="https://codemirror.net/">CodeMirror</a> editor.</p>
|
||||
<p>The content of this editor is shared with every client who visits this domain.</p>
|
||||
<div class="code-html">
|
||||
<style>
|
||||
.remote-caret {
|
||||
position: absolute;
|
||||
border-left: black;
|
||||
border-left-style: solid;
|
||||
border-left-width: 2px;
|
||||
height: 1em;
|
||||
}
|
||||
.remote-caret > div {
|
||||
position: relative;
|
||||
top: -1.05em;
|
||||
font-size: 13px;
|
||||
background-color: rgb(250, 129, 0);
|
||||
font-family: serif;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
line-height: normal;
|
||||
user-select: none;
|
||||
color: white;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
</style>
|
||||
<div id="container"></div>
|
||||
</div>
|
||||
<!-- The actual source file for the following code is found in ./codemirror.js. Run `npm run watch` to compile the files -->
|
||||
<script class="code-js" src="./build/codemirror.js" type="module">
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
import { CodeMirrorBinding } from 'yjs/bindings/codemirror.js'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import CodeMirror from 'codemirror'
|
||||
import 'codemirror/mode/javascript/javascript.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('codemirror')
|
||||
const ytext = ydocument.define('codemirror', Y.Text)
|
||||
|
||||
const editor = new CodeMirror(document.querySelector('#container'), {
|
||||
mode: 'javascript',
|
||||
lineNumbers: true
|
||||
})
|
||||
|
||||
const binding = new CodeMirrorBinding(ytext, editor)
|
||||
|
||||
window.codemirrorExample = {
|
||||
binding, editor, ytext, ydocument
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
22
examples/codemirror.js
Normal file
22
examples/codemirror.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { CodeMirrorBinding } from 'y-codemirror'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import CodeMirror from 'codemirror'
|
||||
import 'codemirror/mode/javascript/javascript.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('codemirror')
|
||||
const ytext = ydocument.getText('codemirror')
|
||||
|
||||
const editor = new CodeMirror(document.querySelector('#container'), {
|
||||
mode: 'javascript',
|
||||
lineNumbers: true
|
||||
})
|
||||
|
||||
const binding = new CodeMirrorBinding(ytext, editor)
|
||||
|
||||
window.codemirrorExample = {
|
||||
binding, editor, ytext, ydocument
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<div id="codeMirrorContainer"></div>
|
||||
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../bower_components/codemirror/lib/codemirror.js"></script>
|
||||
<script src="../bower_components/codemirror/mode/javascript/javascript.js"></script>
|
||||
<link rel="stylesheet" href="../bower_components/codemirror/lib/codemirror.css">
|
||||
<style>
|
||||
.CodeMirror {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
/* global Y, CodeMirror */
|
||||
|
||||
let y = new Y('codemirror-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yCodeMirror = y
|
||||
|
||||
var editor = CodeMirror(document.querySelector('#codeMirrorContainer'), {
|
||||
mode: 'javascript',
|
||||
lineNumbers: true
|
||||
})
|
||||
y.define('codemirror', Y.Text).bindCodeMirror(editor)
|
||||
37
examples/dom.html
Normal file
37
examples/dom.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Yjs Prosemirror Example</title>
|
||||
<link rel=stylesheet href="https://prosemirror.net/css/editor.css">
|
||||
<style>
|
||||
#content {
|
||||
min-height: 500px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>This example shows how to bind a YXmlFragment type to an arbitrary DOM element. We set the DOM element to contenteditable so it basically behaves like a very powerful rich-text editor.</p>
|
||||
<p>The content of this editor is shared with every client who visits this domain.</p>
|
||||
<hr>
|
||||
<div class="code-html">
|
||||
|
||||
<div id="content" contenteditable=""></div>
|
||||
</div>
|
||||
<!-- The actual source file for the following code is found in ./dom.js. Run `npm run watch` to compile the files -->
|
||||
<script class="code-js" src="./build/dom.js" type="module">
|
||||
import * as Y from 'yjs/index.js'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
import { DomBinding } from 'yjs/bindings/dom.js'
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('dom')
|
||||
const type = ydocument.define('xml', Y.XmlFragment)
|
||||
const binding = new DomBinding(type, document.querySelector('#content'), { scrollingElement: document.scrollingElement })
|
||||
|
||||
window.example = {
|
||||
provider, ydocument, type, binding
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
13
examples/dom.js
Normal file
13
examples/dom.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as Y from '../src/index.js'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { DomBinding } from 'y-dom'
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('dom')
|
||||
const type = ydocument.define('xml', Y.XmlFragment)
|
||||
const binding = new DomBinding(type, document.querySelector('#content'), { scrollingElement: document.scrollingElement })
|
||||
|
||||
window.example = {
|
||||
provider, ydocument, type, binding
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<style>
|
||||
path {
|
||||
fill: none;
|
||||
stroke: blue;
|
||||
stroke-width: 1px;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
</style>
|
||||
<button type="button" id="clearDrawingCanvas">Clear Drawing</button>
|
||||
<svg id="drawingCanvas" viewbox="0 0 100 100" width="100%"></svg>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../bower_components/d3/d3.min.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,74 +0,0 @@
|
||||
/* globals Y, d3 */
|
||||
|
||||
let y = new Y('drawing-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yDrawing = y
|
||||
var drawing = y.define('drawing', Y.Array)
|
||||
var renderPath = d3.svg.line()
|
||||
.x(function (d) { return d[0] })
|
||||
.y(function (d) { return d[1] })
|
||||
.interpolate('basic')
|
||||
|
||||
var svg = d3.select('#drawingCanvas')
|
||||
.call(d3.behavior.drag()
|
||||
.on('dragstart', dragstart)
|
||||
.on('drag', drag)
|
||||
.on('dragend', dragend))
|
||||
|
||||
// create line from a shared array object and update the line when the array changes
|
||||
function drawLine (yarray) {
|
||||
var line = svg.append('path').datum(yarray.toArray())
|
||||
line.attr('d', renderPath)
|
||||
yarray.observe(function (event) {
|
||||
line.remove()
|
||||
line = svg.append('path').datum(yarray.toArray())
|
||||
line.attr('d', renderPath)
|
||||
})
|
||||
}
|
||||
// call drawLine every time an array is appended
|
||||
drawing.observe(function (event) {
|
||||
event.removedElements.forEach(function () {
|
||||
// if one is deleted, all will be deleted!!
|
||||
svg.selectAll('path').remove()
|
||||
})
|
||||
event.addedElements.forEach(function (path) {
|
||||
drawLine(path)
|
||||
})
|
||||
})
|
||||
// draw all existing content
|
||||
for (var i = 0; i < drawing.length; i++) {
|
||||
drawLine(drawing.get(i))
|
||||
}
|
||||
|
||||
// clear canvas on request
|
||||
document.querySelector('#clearDrawingCanvas').onclick = function () {
|
||||
drawing.delete(0, drawing.length)
|
||||
}
|
||||
|
||||
var sharedLine = null
|
||||
function dragstart () {
|
||||
drawing.insert(drawing.length, [Y.Array])
|
||||
sharedLine = drawing.get(drawing.length - 1)
|
||||
}
|
||||
|
||||
// After one dragged event is recognized, we ignore them for 33ms.
|
||||
var ignoreDrag = null
|
||||
function drag () {
|
||||
if (sharedLine != null && ignoreDrag == null) {
|
||||
ignoreDrag = window.setTimeout(function () {
|
||||
ignoreDrag = null
|
||||
}, 10)
|
||||
sharedLine.push([d3.mouse(this)])
|
||||
}
|
||||
}
|
||||
|
||||
function dragend () {
|
||||
sharedLine = null
|
||||
window.clearTimeout(ignoreDrag)
|
||||
ignoreDrag = null
|
||||
}
|
||||
9
examples/exampleConfig.js
Normal file
9
examples/exampleConfig.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
const isDeployed = location.hostname === 'yjs.website'
|
||||
|
||||
if (!isDeployed) {
|
||||
console.log('%cYjs: Start your local websocket server by running %c`npm run websocket-server`', 'color:blue', 'color: grey; font-weight: bold')
|
||||
}
|
||||
|
||||
export const serverAddress = isDeployed ? 'wss://api.yjs.website' : 'ws://localhost:1234'
|
||||
17
examples/examples.json
Normal file
17
examples/examples.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"codemirror": {
|
||||
"title": "CodeMirror Binding"
|
||||
},
|
||||
"prosemirror": {
|
||||
"title": "ProseMirror Binding"
|
||||
},
|
||||
"textarea": {
|
||||
"title": "Textarea Binding"
|
||||
},
|
||||
"quill": {
|
||||
"title": "Quill Binding"
|
||||
},
|
||||
"dom": {
|
||||
"title": "Dom Binding"
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
</head>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../bower_components/d3/d3.min.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
<style>
|
||||
magic-drawing .drawingCanvas path {
|
||||
fill: none;
|
||||
stroke: blue;
|
||||
stroke-width: 2px;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
magic-drawing .drawingCanvas {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
cursor: default;
|
||||
padding:1px;
|
||||
border:1px solid #021a40;
|
||||
}
|
||||
magic-drawing .clearDrawingButton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
magic-drawing {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body contenteditable="true">
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,134 +0,0 @@
|
||||
/* global Y, d3 */
|
||||
|
||||
const hooks = {
|
||||
'magic-drawing': {
|
||||
fillType: function (dom, type) {
|
||||
initDrawingBindings(type, dom)
|
||||
},
|
||||
createDom: function (type) {
|
||||
const dom = document.createElement('magic-drawing')
|
||||
initDrawingBindings(type, dom)
|
||||
return dom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = function () {
|
||||
window.domBinding = new Y.DomBinding(window.yXmlType, document.body, { hooks })
|
||||
}
|
||||
|
||||
window.addMagicDrawing = function addMagicDrawing () {
|
||||
let mt = document.createElement('magic-drawing')
|
||||
mt.setAttribute('data-yjs-hook', 'magic-drawing')
|
||||
document.body.append(mt)
|
||||
}
|
||||
|
||||
var renderPath = d3.svg.line()
|
||||
.x(function (d) { return d[0] })
|
||||
.y(function (d) { return d[1] })
|
||||
.interpolate('basic')
|
||||
|
||||
function initDrawingBindings (type, dom) {
|
||||
dom.contentEditable = 'false'
|
||||
dom.setAttribute('data-yjs-hook', 'magic-drawing')
|
||||
var drawing = type.get('drawing')
|
||||
if (drawing === undefined) {
|
||||
drawing = type.set('drawing', new Y.Array())
|
||||
}
|
||||
var canvas = dom.querySelector('.drawingCanvas')
|
||||
if (canvas == null) {
|
||||
canvas = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
canvas.setAttribute('class', 'drawingCanvas')
|
||||
canvas.setAttribute('viewbox', '0 0 100 100')
|
||||
dom.insertBefore(canvas, null)
|
||||
}
|
||||
var clearDrawingButton = dom.querySelector('.clearDrawingButton')
|
||||
if (clearDrawingButton == null) {
|
||||
clearDrawingButton = document.createElement('button')
|
||||
clearDrawingButton.setAttribute('type', 'button')
|
||||
clearDrawingButton.setAttribute('class', 'clearDrawingButton')
|
||||
clearDrawingButton.innerText = 'Clear Drawing'
|
||||
dom.insertBefore(clearDrawingButton, null)
|
||||
}
|
||||
var svg = d3.select(canvas)
|
||||
.call(d3.behavior.drag()
|
||||
.on('dragstart', dragstart)
|
||||
.on('drag', drag)
|
||||
.on('dragend', dragend))
|
||||
// create line from a shared array object and update the line when the array changes
|
||||
function drawLine (yarray, svg) {
|
||||
var line = svg.append('path').datum(yarray.toArray())
|
||||
line.attr('d', renderPath)
|
||||
yarray.observe(function (event) {
|
||||
line.remove()
|
||||
line = svg.append('path').datum(yarray.toArray())
|
||||
line.attr('d', renderPath)
|
||||
})
|
||||
}
|
||||
// call drawLine every time an array is appended
|
||||
drawing.observe(function (event) {
|
||||
event.removedElements.forEach(function () {
|
||||
// if one is deleted, all will be deleted!!
|
||||
svg.selectAll('path').remove()
|
||||
})
|
||||
event.addedElements.forEach(function (path) {
|
||||
drawLine(path, svg)
|
||||
})
|
||||
})
|
||||
// draw all existing content
|
||||
for (var i = 0; i < drawing.length; i++) {
|
||||
drawLine(drawing.get(i), svg)
|
||||
}
|
||||
|
||||
// clear canvas on request
|
||||
clearDrawingButton.onclick = function () {
|
||||
drawing.delete(0, drawing.length)
|
||||
}
|
||||
|
||||
var sharedLine = null
|
||||
function dragstart () {
|
||||
drawing.insert(drawing.length, [Y.Array])
|
||||
sharedLine = drawing.get(drawing.length - 1)
|
||||
}
|
||||
|
||||
// After one dragged event is recognized, we ignore them for 33ms.
|
||||
var ignoreDrag = null
|
||||
function drag () {
|
||||
if (sharedLine != null && ignoreDrag == null) {
|
||||
ignoreDrag = window.setTimeout(function () {
|
||||
ignoreDrag = null
|
||||
}, 10)
|
||||
sharedLine.push([d3.mouse(this)])
|
||||
}
|
||||
}
|
||||
|
||||
function dragend () {
|
||||
sharedLine = null
|
||||
window.clearTimeout(ignoreDrag)
|
||||
ignoreDrag = null
|
||||
}
|
||||
}
|
||||
|
||||
let y = new Y('html-editor-drawing-hook-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
window.yXml = y
|
||||
window.yXmlType = y.define('xml', Y.XmlFragment)
|
||||
window.undoManager = new Y.utils.UndoManager(window.yXmlType, {
|
||||
captureTimeout: 500
|
||||
})
|
||||
|
||||
document.onkeydown = function interceptUndoRedo (e) {
|
||||
if (e.keyCode === 90 && e.metaKey) {
|
||||
if (!e.shiftKey) {
|
||||
window.undoManager.undo()
|
||||
} else {
|
||||
window.undoManager.redo()
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
</head>
|
||||
<script src="./index.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<label for="room">Room: </label>
|
||||
<input type="text" id="room" name="room">
|
||||
<div id="content" contenteditable style="position:absolute;top:35px;left:0;right:0;bottom:0;outline: 0px solid transparent;"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,77 +0,0 @@
|
||||
|
||||
import YWebsocketsConnector from '../../src/Connectors/WebsocketsConnector/WebsocketsConnector.js'
|
||||
import Y from '../../src/Y.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'
|
||||
import YXmlElement from '../../src/Types/YXml/YXmlElement.js'
|
||||
import YIndexdDBPersistence from '../../src/Persistences/IndexedDBPersistence.js'
|
||||
|
||||
const connector = new YWebsocketsConnector()
|
||||
const persistence = new YIndexdDBPersistence()
|
||||
|
||||
const roomInput = document.querySelector('#room')
|
||||
|
||||
let currentRoomName = null
|
||||
let y = null
|
||||
let domBinding = null
|
||||
|
||||
function setRoomName (roomName) {
|
||||
if (currentRoomName !== roomName) {
|
||||
console.log(`change room: "${roomName}"`)
|
||||
roomInput.value = roomName
|
||||
currentRoomName = roomName
|
||||
location.hash = '#' + roomName
|
||||
if (y !== null) {
|
||||
domBinding.destroy()
|
||||
}
|
||||
|
||||
const room = connector._rooms.get(roomName)
|
||||
if (room !== undefined) {
|
||||
y = room.y
|
||||
} else {
|
||||
y = new Y(roomName, null, null, { gc: true })
|
||||
persistence.connectY(roomName, y).then(() => {
|
||||
// connect after persisted content was applied to y
|
||||
// If we don't wait for persistence, the other peer will send all data, waisting
|
||||
// network bandwidth..
|
||||
connector.connectY(roomName, y)
|
||||
})
|
||||
window.y = y
|
||||
}
|
||||
|
||||
window.y = y
|
||||
window.yXmlType = y.define('xml', YXmlFragment)
|
||||
|
||||
domBinding = new DomBinding(window.yXmlType, document.querySelector('#content'), { scrollingElement: document.scrollingElement })
|
||||
}
|
||||
}
|
||||
window.setRoomName = setRoomName
|
||||
|
||||
window.createRooms = function (i = 0) {
|
||||
setInterval(function () {
|
||||
setRoomName(i + '')
|
||||
i++
|
||||
const nodes = []
|
||||
for (let j = 0; j < 100; j++) {
|
||||
const node = new YXmlElement('p')
|
||||
node.insert(0, [new YXmlText(`This is the ${i}th paragraph of room ${i}`)])
|
||||
nodes.push(node)
|
||||
}
|
||||
y.share.xml.insert(0, nodes)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
connector.syncPersistence(persistence)
|
||||
|
||||
window.connector = connector
|
||||
window.persistence = persistence
|
||||
|
||||
window.onload = function () {
|
||||
setRoomName((location.hash || '#default').slice(1))
|
||||
roomInput.addEventListener('input', e => {
|
||||
const roomName = e.target.value
|
||||
setRoomName(roomName)
|
||||
})
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<div id="codeMirrorContainer"></div>
|
||||
<script src="../bower_components/codemirror/lib/codemirror.js"></script>
|
||||
<script src="../bower_components/codemirror/mode/javascript/javascript.js"></script>
|
||||
<link rel="stylesheet" href="../bower_components/codemirror/lib/codemirror.css">
|
||||
<style>
|
||||
.CodeMirror {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src='../../../y-indexeddb/y-indexeddb.js'></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,19 +0,0 @@
|
||||
/* global Y, CodeMirror */
|
||||
|
||||
const persistence = new Y.IndexedDB()
|
||||
const connector = {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
room: 'codemirror-example'
|
||||
}
|
||||
}
|
||||
|
||||
const y = new Y('codemirror-example', connector, persistence)
|
||||
window.yCodeMirror = y
|
||||
|
||||
var editor = CodeMirror(document.querySelector('#codeMirrorContainer'), {
|
||||
mode: 'javascript',
|
||||
lineNumbers: true
|
||||
})
|
||||
|
||||
y.define('codemirror', Y.Text).bindCodeMirror(editor)
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style type="text/css">
|
||||
.draggable {
|
||||
cursor: move;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<svg id="puzzle-example" width="100%" viewBox="0 0 800 800">
|
||||
<g>
|
||||
<path d="M 311.76636,154.23389 C 312.14136,171.85693 318.14087,184.97998 336.13843,184.23047 C 354.13647,183.48047 351.88647,180.48096 354.88599,178.98096 C 357.8855,177.48096 368.38452,170.35693 380.00806,169.98193 C 424.61841,168.54297 419.78296,223.6001 382.25757,223.6001 C 377.75806,223.6001 363.51001,219.10107 356.38599,211.97656 C 349.26196,204.85254 310.64185,207.10254 314.76636,236.34863 C 316.34888,247.5708 324.08374,267.90723 324.84595,286.23486 C 325.29321,296.99414 323.17603,307.00635 321.58911,315.6377 C 360.11353,305.4585 367.73462,304.30518 404.00513,312.83936 C 410.37915,314.33887 436.62573,310.21436 421.25269,290.3418 C 405.87964,270.46924 406.25464,248.34717 417.12817,240.84814 C 428.00171,233.34912 446.74976,228.84961 457.99829,234.09912 C 469.24683,239.34814 484.61987,255.84619 475.24585,271.59424 C 465.87231,287.34229 452.74878,290.7168 456.49829,303.84033 C 460.2478,316.96387 479.74536,320.33838 500.74292,321.83789 C 509.70142,322.47803 527.97192,323.28467 542.10864,320.12939 C 549.91821,318.38672 556.92212,315.89502 562.46753,313.56396 C 561.40796,277.80664 560.84888,245.71729 560.3606,241.97314 C 558.85278,230.41455 542.49536,217.28564 525.86499,223.2251 C 520.61548,225.1001 519.86548,231.84912 505.24243,232.59912 C 444.92798,235.69238 462.06958,143.26709 525.86499,180.48096 C 539.52759,188.45068 575.19409,190.7583 570.10913,156.85889 C 567.85962,141.86035 553.98608,102.86523 553.98608,102.86523 C 553.98608,102.86523 477.23755,111.82227 451.99878,91.991699 C 441.50024,83.74292 444.87476,69.494629 449.37427,61.245605 C 453.87378,52.996582 465.12231,46.622559 464.74731,36.123779 C 463.02563,-12.086426 392.96704,-10.902832 396.5061,36.873535 C 397.25562,46.997314 406.62964,52.621582 410.75415,60.495605 C 420.00757,78.161377 405.50024,96.073486 384.50757,99.490723 C 377.36206,100.65381 349.17505,102.65332 320.39429,102.23486 C 319.677,102.22461 318.95923,102.21143 318.24194,102.19775 C 315.08423,120.9751 311.55688,144.39697 311.76636,154.23389 z " style="fill:#f2c569;stroke:#000000" id="path2502"/>
|
||||
<path d="M 500.74292,321.83789 C 479.74536,320.33838 460.2478,316.96387 456.49829,303.84033 C 452.74878,290.7168 465.87231,287.34229 475.24585,271.59424 C 484.61987,255.84619 469.24683,239.34814 457.99829,234.09912 C 446.74976,228.84961 428.00171,233.34912 417.12817,240.84814 C 406.25464,248.34717 405.87964,270.46924 421.25269,290.3418 C 436.62573,310.21436 410.37915,314.33887 404.00513,312.83936 C 367.73462,304.30518 360.11353,305.4585 321.58911,315.6377 C 320.56372,321.21484 319.75854,326.2207 320.01538,330.46191 C 320.76538,342.83545 329.3894,385.95508 327.8894,392.7041 C 326.3894,399.45312 313.64136,418.20117 297.89331,407.32715 C 282.14526,396.45361 276.52075,393.4541 265.27222,394.5791 C 254.02368,395.70361 239.77563,402.07812 239.77563,419.32568 C 239.77563,436.57373 250.27417,449.69727 268.64673,447.82227 C 287.36353,445.9126 317.92163,423.11035 325.63989,452.69678 C 330.1394,469.94434 330.51392,487.19238 330.1394,498.44092 C 329.95825,503.87646 326.09985,518.06592 322.16089,531.28125 C 353.2854,532.73682 386.47095,531.26611 394.2561,529.93701 C 430.30933,523.78174 429.31909,496.09766 412.62866,477.44385 C 406.25464,470.31934 401.75513,455.32129 405.87964,444.82275 C 414.07056,423.97314 458.8064,422.17773 473.37134,438.82324 C 483.86987,450.82178 475.99585,477.44385 468.49683,482.69287 C 453.52222,493.17529 457.22485,516.83008 473.37134,528.06201 C 504.79126,549.91943 572.35913,535.56152 572.35913,535.56152 C 572.35913,535.56152 567.85962,498.06592 567.48462,471.81934 C 567.10962,445.57275 589.60669,450.07227 593.3562,450.07227 C 597.10571,450.07227 604.22974,455.32129 609.47925,459.4458 C 614.72876,463.57031 618.85327,469.94434 630.85181,470.69434 C 677.43726,473.60596 674.58813,420.7373 631.97632,413.32666 C 623.35229,411.82666 614.72876,416.32617 603.10522,424.57519 C 591.48169,432.82422 577.23315,425.32519 570.10913,417.45117 C 566.07788,412.99561 563.8479,360.16406 562.46753,313.56396 C 556.92212,315.89502 549.91821,318.38672 542.10864,320.12939 C 527.97192,323.28467 509.70142,322.47803 500.74292,321.83789 z " style="fill:#f3f3d6;stroke:#000000" id="path2504"/>
|
||||
<path d="M 240.52563,141.86035 C 257.60327,159.6499 243.94507,188.68799 214.65356,190.22949 C 185.09448,191.78516 164.66675,157.17822 190.28589,136.61621 C 200.49585,128.42139 198.05786,114.12158 179.78296,106.98975 C 154.4187,97.091553 90.54419,107.73975 90.54419,107.73975 C 90.54419,107.73975 100.88794,135.11328 101.41772,168.48242 C 101.79272,192.104 68.796875,189.47949 63.172607,186.85498 C 57.54834,184.23047 45.924805,173.73145 37.675781,173.73145 C -14.411865,173.73145 -10.013184,245.84375 39.925537,232.22412 C 48.174316,229.97461 56.42334,220.97559 68.796875,222.47559 C 81.17041,223.9751 87.544434,232.59912 87.544434,246.09766 C 87.544434,252.51709 87.0354,281.24268 86.340576,312.87012 C 119.15894,313.67676 160.60962,314.46582 170.03442,313.58887 C 186.15698,312.08936 195.90601,301.59033 188.40698,293.3418 C 180.90796,285.09277 156.16089,256.59619 179.03296,239.34814 C 201.90503,222.10059 235.65112,231.84912 239.77563,247.22217 C 243.90015,262.59521 240.52563,273.46924 234.90112,279.09326 C 229.27661,284.71777 210.52905,298.96582 221.40259,308.71484 C 232.27661,318.46338 263.77222,330.83691 302.39282,320.71338 C 309.58862,318.82715 315.92114,317.13525 321.58911,315.6377 C 323.17603,307.00635 325.29321,296.99414 324.84595,286.23486 C 324.08374,267.90723 316.34888,247.5708 314.76636,236.34863 C 310.64185,207.10254 349.26196,204.85254 356.38599,211.97656 C 363.51001,219.10107 377.75806,223.6001 382.25757,223.6001 C 419.78296,223.6001 424.61841,168.54297 380.00806,169.98193 C 368.38452,170.35693 357.8855,177.48096 354.88599,178.98096 C 351.88647,180.48096 354.13647,183.48047 336.13843,184.23047 C 318.14087,184.97998 312.14136,171.85693 311.76636,154.23389 C 311.55688,144.39697 315.08423,120.9751 318.24194,102.19775 C 290.37524,101.67725 262.46069,98.968262 254.39868,97.991211 C 233.38013,95.443359 217.17456,117.53662 240.52563,141.86035 z " style="fill:#bebcdb;stroke:#000000" id="path2506"/>
|
||||
<path d="M 325.63989,452.69678 C 317.92163,423.11035 287.36353,445.9126 268.64673,447.82227 C 250.27417,449.69727 239.77563,436.57373 239.77563,419.32568 C 239.77563,402.07812 254.02368,395.70361 265.27222,394.5791 C 276.52075,393.4541 282.14526,396.45361 297.89331,407.32715 C 313.64136,418.20117 326.3894,399.45313 327.8894,392.7041 C 329.3894,385.95508 320.76538,342.83545 320.01538,330.46191 C 319.75855,326.2207 320.56372,321.21484 321.58911,315.6377 C 315.92114,317.13525 309.58862,318.82715 302.39282,320.71338 C 263.77222,330.83691 232.27661,318.46338 221.40259,308.71484 C 210.52905,298.96582 229.27661,284.71777 234.90112,279.09326 C 240.52563,273.46924 243.90015,262.59521 239.77563,247.22217 C 235.65112,231.84912 201.90503,222.10059 179.03296,239.34814 C 156.16089,256.59619 180.90796,285.09277 188.40698,293.3418 C 195.90601,301.59033 186.15698,312.08936 170.03442,313.58887 C 160.60962,314.46582 119.15894,313.67676 86.340576,312.87012 C 85.573975,347.74561 84.581299,386.15088 83.794922,402.07812 C 82.295166,432.44922 109.29175,422.32568 115.66577,420.82568 C 122.04028,419.32568 126.16479,409.57715 143.03735,408.45215 C 185.9231,405.59326 186.09985,466.69629 144.16235,467.69482 C 128.41431,468.06982 113.79126,451.19678 108.16675,447.44727 C 102.54272,443.69775 87.919433,442.94775 83.794922,457.9458 C 82.01709,464.41113 78.118652,481.65137 78.098144,496.18994 C 78.071045,515.38037 82.295166,531.81201 82.295166,531.81201 C 82.295166,531.81201 105.54224,526.5625 149.41187,526.5625 C 193.28149,526.5625 199.65552,547.93506 194.78101,558.80859 C 189.90649,569.68213 181.28296,568.93213 179.40796,583.18066 C 172.7063,634.11133 253.34106,631.08203 249.14917,584.68018 C 247.96948,571.62354 237.16528,571.66699 232.27661,557.68359 C 222.17944,528.80273 244.64966,523.56299 257.39819,524.68799 C 263.59351,525.23437 290.95679,529.73389 320.75757,531.21582 C 321.22437,531.23877 321.69312,531.25928 322.16089,531.28125 C 326.09985,518.06592 329.95825,503.87646 330.1394,498.44092 C 330.51392,487.19238 330.1394,469.94434 325.63989,452.69678 z " style="fill:#d3ea9d;stroke:#000000" id="path2508"/>
|
||||
</g>
|
||||
</svg>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../bower_components/d3/d3.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,67 +0,0 @@
|
||||
/* global Y, d3 */
|
||||
|
||||
let y = new Y('jigsaw-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
let jigsaw = y.define('jigsaw', Y.Map)
|
||||
window.yJigsaw = y
|
||||
|
||||
var origin // mouse start position - translation of piece
|
||||
var drag = d3.behavior.drag()
|
||||
.on('dragstart', function (params) {
|
||||
// get the translation of the element
|
||||
var translation = d3
|
||||
.select(this)
|
||||
.attr('transform')
|
||||
.slice(10, -1)
|
||||
.split(',')
|
||||
.map(Number)
|
||||
// mouse coordinates
|
||||
var mouse = d3.mouse(this.parentNode)
|
||||
origin = {
|
||||
x: mouse[0] - translation[0],
|
||||
y: mouse[1] - translation[1]
|
||||
}
|
||||
})
|
||||
.on('drag', function () {
|
||||
var mouse = d3.mouse(this.parentNode)
|
||||
var x = mouse[0] - origin.x // =^= mouse - mouse at dragstart + translation at dragstart
|
||||
var y = mouse[1] - origin.y
|
||||
d3.select(this).attr('transform', 'translate(' + x + ',' + y + ')')
|
||||
})
|
||||
.on('dragend', function (piece, i) {
|
||||
// save the current translation of the puzzle piece
|
||||
var mouse = d3.mouse(this.parentNode)
|
||||
var x = mouse[0] - origin.x
|
||||
var y = mouse[1] - origin.y
|
||||
jigsaw.set(piece, {x: x, y: y})
|
||||
})
|
||||
|
||||
var data = ['piece1', 'piece2', 'piece3', 'piece4']
|
||||
var pieces = d3.select(document.querySelector('#puzzle-example')).selectAll('path').data(data)
|
||||
|
||||
pieces
|
||||
.classed('draggable', true)
|
||||
.attr('transform', function (piece) {
|
||||
var translation = piece.get('translation') || {x: 0, y: 0}
|
||||
return 'translate(' + translation.x + ',' + translation.y + ')'
|
||||
}).call(drag)
|
||||
|
||||
data.forEach(function (piece) {
|
||||
jigsaw.observe(function () {
|
||||
// whenever a property of a piece changes, update the translation of the pieces
|
||||
pieces
|
||||
.transition()
|
||||
.attr('transform', function (piece) {
|
||||
var translation = piece.get(piece)
|
||||
if (translation == null || typeof translation.x !== 'number' || typeof translation.y !== 'number') {
|
||||
translation = { x: 0, y: 0 }
|
||||
}
|
||||
return 'translate(' + translation.x + ',' + translation.y + ')'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<div id="monacoContainer"></div>
|
||||
<style>
|
||||
#monacoContainer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
<script src="../node_modules/monaco-editor/min/vs/loader.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,22 +0,0 @@
|
||||
/* global Y, monaco */
|
||||
|
||||
require.config({ paths: { 'vs': '../node_modules/monaco-editor/min/vs' } })
|
||||
|
||||
let y = new Y('monaco-example', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
window.yMonaco = y
|
||||
|
||||
// Create Monaco editor
|
||||
var editor = monaco.editor.create(document.getElementById('monacoContainer'), {
|
||||
language: 'javascript'
|
||||
})
|
||||
|
||||
// Bind to y.share.monaco
|
||||
y.define('monaco', Y.Text).bindMonaco(editor)
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
</head>
|
||||
<script src="./index.js" type="module"></script>
|
||||
<link rel="stylesheet" type="text/css" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar">
|
||||
<h3 id="createNoteButton">+ Create Note</h3>
|
||||
<div class="notelist"></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<h1 id="headline"></h1>
|
||||
<div id="editor" contenteditable="true"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,132 +0,0 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
import { createYdbClient } from '../../YdbClient/index.js'
|
||||
import Y from '../../src/Y.dist.js'
|
||||
import * as ydb from '../../YdbClient/YdbClient.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
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
|
||||
})
|
||||
|
||||
createYdbClient('ws://localhost:8899/ws').then(ydbclient => {
|
||||
const y = ydbclient.getY('notelist')
|
||||
let ynotelist = y.define('notelist', Y.Array)
|
||||
window.ynotelist = ynotelist
|
||||
const domNoteList = document.querySelector('.notelist')
|
||||
|
||||
// utils
|
||||
const addEventListener = (element, eventname, f) => element.addEventListener(eventname, f)
|
||||
|
||||
// create note button
|
||||
const createNoteButton = event => {
|
||||
ynotelist.insert(0, [{
|
||||
guid: uuidv4(),
|
||||
title: 'Note #' + ynotelist.length
|
||||
}])
|
||||
}
|
||||
addEventListener(document.querySelector('#createNoteButton'), 'click', createNoteButton)
|
||||
window.createNote = createNoteButton
|
||||
window.createNotes = n => {
|
||||
y.transact(() => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
createNoteButton()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// clear note list function
|
||||
window.clearNotes = () => ynotelist.delete(0, ynotelist.length)
|
||||
|
||||
// update editor and editor title
|
||||
let domBinding = null
|
||||
const updateEditor = () => {
|
||||
domNoteList.querySelectorAll('a').forEach(a => a.classList.remove('selected'))
|
||||
const domNote = document.querySelector('.notelist').querySelector(`[href="${location.hash}"]`)
|
||||
if (domNote !== null) {
|
||||
domNote.classList.add('selected')
|
||||
const note = ynotelist.toArray().find(note => note.guid === location.hash.slice(1))
|
||||
if (note !== undefined) {
|
||||
const ydoc = ydbclient.getY(note.guid)
|
||||
const ycontent = ydoc.define('content', Y.XmlFragment)
|
||||
if (domBinding !== null) {
|
||||
domBinding.destroy()
|
||||
}
|
||||
domBinding = new DomBinding(ycontent, document.querySelector('#editor'))
|
||||
document.querySelector('#headline').innerText = note.title
|
||||
document.querySelector('#editor').focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listen to url-hash changes
|
||||
addEventListener(window, 'hashchange', updateEditor)
|
||||
updateEditor()
|
||||
|
||||
const styleSyncedState = (div, noteSyncedState) => {
|
||||
let classes = []
|
||||
if (noteSyncedState.persisted) {
|
||||
classes.push('persisted')
|
||||
} else {
|
||||
if (noteSyncedState.upsynced) {
|
||||
classes.push('upsynced')
|
||||
} else {
|
||||
classes.push('noupsynced')
|
||||
}
|
||||
if (noteSyncedState.downsynced) {
|
||||
classes.push('downsynced')
|
||||
} else {
|
||||
classes.push('nodownsynced')
|
||||
}
|
||||
}
|
||||
div.setAttribute('class', classes.join(' '))
|
||||
}
|
||||
|
||||
ydbclient.on('syncstate', event => event.updated.forEach((state, room) => {
|
||||
const a = document.querySelector(`[href="#${room}"]`)
|
||||
if (a !== null) {
|
||||
styleSyncedState(a.firstChild, state)
|
||||
}
|
||||
}))
|
||||
|
||||
// render note list
|
||||
const renderNoteList = (elementList, insertRef = domNoteList.firstChild) => {
|
||||
const fragment = document.createDocumentFragment()
|
||||
const addNow = elementList.splice(0, 100)
|
||||
addNow.forEach(note => {
|
||||
const a = document.createElement('a')
|
||||
const div = document.createElement('div')
|
||||
a.insertBefore(div, null)
|
||||
a.setAttribute('href', '#' + note.guid)
|
||||
div.innerText = note.title
|
||||
styleSyncedState(div, ydbclient.getRoomState(note.guid))
|
||||
fragment.insertBefore(a, null)
|
||||
})
|
||||
if (domBinding == null) {
|
||||
updateEditor()
|
||||
}
|
||||
domNoteList.insertBefore(fragment, insertRef)
|
||||
if (elementList.length > 0) {
|
||||
setTimeout(() => renderNoteList(elementList, insertRef), 100)
|
||||
}
|
||||
}
|
||||
{
|
||||
const notelist = ynotelist.toArray()
|
||||
if (notelist.length > 0) {
|
||||
renderNoteList(notelist)
|
||||
ydb.subscribeRooms(ydbclient, notelist.map(note => note.guid))
|
||||
}
|
||||
}
|
||||
ynotelist.observe(event => {
|
||||
const addedNotes = []
|
||||
event.addedElements.forEach(itemJson => itemJson._content.forEach(json => addedNotes.push(json)))
|
||||
renderNoteList(addedNotes.slice().reverse()) // renderNoteList modifies addedNotes, so first make a copy of it
|
||||
setTimeout(() => {
|
||||
ydb.subscribeRooms(ydbclient, addedNotes.map(note => note.guid))
|
||||
}, 200)
|
||||
if (domBinding === null) {
|
||||
updateEditor()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
.sidebar {
|
||||
height: 100%; /* Full-height: remove this if you want "auto" height */
|
||||
width: 180px; /* Set the width of the sidebar */
|
||||
position: fixed; /* Fixed Sidebar (stay in place on scroll) */
|
||||
z-index: 1; /* Stay on top */
|
||||
top: 0; /* Stay at the top */
|
||||
left: 0;
|
||||
background-color: #111; /* Black */
|
||||
overflow-x: hidden; /* Disable horizontal scroll */
|
||||
padding-top: 20px;
|
||||
color: #50abff;
|
||||
}
|
||||
|
||||
#createNoteButton {
|
||||
padding-left: .5em;
|
||||
padding-top: .5em;
|
||||
padding-bottom: .7em;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notelist > a {
|
||||
padding: 6px 8px 6px 16px;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
color: #818181;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notelist > a.selected {
|
||||
border-style: outset;
|
||||
}
|
||||
|
||||
.notelist > a > div {
|
||||
position: relative;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* When you mouse over the navigation links, change their color */
|
||||
.sidebar a:hover {
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
/* Style page content */
|
||||
.main {
|
||||
margin-left: 180px; /* Same as the width of the sidebar */
|
||||
padding: 0px 10px;
|
||||
}
|
||||
|
||||
/* On smaller screens, where height is less than 450px, change the style of the sidebar (less padding and a smaller font size) */
|
||||
@media screen and (max-height: 450px) {
|
||||
.sidebar {padding-top: 15px;}
|
||||
.sidebar a {font-size: 18px;}
|
||||
}
|
||||
|
||||
#editor {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
[contenteditable]:focus {
|
||||
outline: 0px solid transparent;
|
||||
}
|
||||
|
||||
.persisted::before {
|
||||
content: "✔";
|
||||
color: green;
|
||||
position: absolute;
|
||||
right: -14px;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.upsynced::before {
|
||||
content: "↑";
|
||||
color: green;
|
||||
position: absolute;
|
||||
right: -14px;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.noupsynced::before {
|
||||
content: "↑";
|
||||
color: red;
|
||||
position: absolute;
|
||||
right: -14px;
|
||||
top: 0px;
|
||||
}
|
||||
.downsynced::after {
|
||||
content: "↓";
|
||||
color: green;
|
||||
position: absolute;
|
||||
right: -22px;
|
||||
top: 0px;
|
||||
}
|
||||
.nodownsynced::after {
|
||||
content: "↓";
|
||||
color: red;
|
||||
position: absolute;
|
||||
right: -22px;
|
||||
top: 0px;
|
||||
}
|
||||
159
examples/prosemirror-history.js
Normal file
159
examples/prosemirror-history.js
Normal file
@@ -0,0 +1,159 @@
|
||||
|
||||
import { Plugin } from 'prosemirror-state'
|
||||
import crel from 'crel'
|
||||
import * as Y from '../src/index.js'
|
||||
import { prosemirrorPluginKey } from 'y-prosemirror'
|
||||
import * as encoding from 'lib0/encoding.js'
|
||||
import * as decoding from 'lib0/decoding.js'
|
||||
import * as historyProtocol from 'y-protocols/history.js'
|
||||
|
||||
const niceColors = ['#3cb44b', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#008080', '#9a6324', '#800000', '#808000', '#000075', '#808080']
|
||||
|
||||
const createUserCSS = (userid, username, color = 'rgb(250, 129, 0)', color2 = 'rgba(250, 129, 0, .41)') => `
|
||||
[ychange_state][ychange_user="${userid}"]:hover::before {
|
||||
content: "${username}" !important;
|
||||
background-color: ${color} !important;
|
||||
}
|
||||
[ychange_state="added"][ychange_user="${userid}"] {
|
||||
background-color: ${color2} !important;
|
||||
}
|
||||
[ychange_state="removed"][ychange_user="${userid}"] {
|
||||
color: ${color} !important;
|
||||
}
|
||||
`
|
||||
|
||||
export const noteHistoryPlugin = new Plugin({
|
||||
state: {
|
||||
init (initargs, state) {
|
||||
return new NoteHistoryPlugin()
|
||||
},
|
||||
apply (tr, pluginState) {
|
||||
return pluginState
|
||||
}
|
||||
},
|
||||
view (editorView) {
|
||||
const hstate = noteHistoryPlugin.getState(editorView.state)
|
||||
hstate.init(editorView)
|
||||
return {
|
||||
destroy: hstate.destroy.bind(hstate)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
const wrapper = crel('div', { style: 'display: flex;' })
|
||||
const historyContainer = crel('div', { style: 'align-self: baseline; flex-basis: 250px;', class: 'shared-history' })
|
||||
wrapper.insertBefore(historyContainer, null)
|
||||
const userStyleContainer = crel('style')
|
||||
wrapper.insertBefore(userStyleContainer, null)
|
||||
return { wrapper, historyContainer, userStyleContainer }
|
||||
}
|
||||
|
||||
class NoteHistoryPlugin {
|
||||
init (editorView) {
|
||||
this.editorView = editorView
|
||||
const { historyContainer, wrapper, userStyleContainer } = createWrapper()
|
||||
this.userStyleContainer = userStyleContainer
|
||||
this.wrapper = wrapper
|
||||
this.historyContainer = historyContainer
|
||||
const n = editorView.dom.parentNode.parentNode
|
||||
n.parentNode.replaceChild(this.wrapper, n)
|
||||
n.style['flex-grow'] = '1'
|
||||
wrapper.insertBefore(n, this.wrapper.firstChild)
|
||||
this.render()
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array)
|
||||
history.observe(this.render.bind(this))
|
||||
}
|
||||
destroy () {
|
||||
this.wrapper.parentNode.replaceChild(this.wrapper.firstChild, this.wrapper)
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array)
|
||||
history.unobserve(this.render)
|
||||
}
|
||||
render () {
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array).toArray()
|
||||
const fragment = document.createDocumentFragment()
|
||||
const snapshotBtn = crel('button', { type: 'button' }, ['snapshot'])
|
||||
fragment.insertBefore(snapshotBtn, null)
|
||||
let _prevSnap = null // empty
|
||||
snapshotBtn.addEventListener('click', () => {
|
||||
const awareness = y.getAwarenessInfo()
|
||||
const userMap = new Map()
|
||||
const aw = y.getLocalAwarenessInfo()
|
||||
userMap.set(y.userID, aw.name || 'unknown')
|
||||
awareness.forEach((a, userID) => {
|
||||
userMap.set(userID, a.name || 'Unknown')
|
||||
})
|
||||
this.snapshot(userMap)
|
||||
})
|
||||
history.forEach(buf => {
|
||||
const decoder = decoding.createDecoder(buf)
|
||||
const snapshot = historyProtocol.readHistorySnapshot(decoder)
|
||||
const date = new Date(decoding.readUint32(decoder) * 1000)
|
||||
const restoreBtn = crel('button', { type: 'button' }, ['restore'])
|
||||
const a = crel('a', [
|
||||
'• ' + date.toUTCString(), restoreBtn
|
||||
])
|
||||
const el = crel('div', [ a ])
|
||||
let prevSnapshot = _prevSnap // rebind to new variable
|
||||
restoreBtn.addEventListener('click', event => {
|
||||
if (prevSnapshot === null) {
|
||||
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
|
||||
}
|
||||
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot, restore: true }))
|
||||
event.stopPropagation()
|
||||
})
|
||||
a.addEventListener('click', () => {
|
||||
console.log('setting snapshot')
|
||||
if (prevSnapshot === null) {
|
||||
prevSnapshot = { ds: snapshot.ds, sm: new Map() }
|
||||
}
|
||||
this.renderSnapshot(snapshot, prevSnapshot)
|
||||
})
|
||||
fragment.insertBefore(el, null)
|
||||
_prevSnap = snapshot
|
||||
})
|
||||
this.historyContainer.innerHTML = ''
|
||||
this.historyContainer.insertBefore(fragment, null)
|
||||
}
|
||||
renderSnapshot (snapshot, prevSnapshot) {
|
||||
this.editorView.dispatch(this.editorView.state.tr.setMeta(prosemirrorPluginKey, { snapshot, prevSnapshot }))
|
||||
/**
|
||||
* @type {Array<string|null>}
|
||||
*/
|
||||
let colors = niceColors.slice()
|
||||
let style = ''
|
||||
snapshot.userMap.forEach((name, userid) => {
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
const randInt = name.split('').map(s => s.charCodeAt(0)).reduce((a, b) => a + b)
|
||||
let color = null
|
||||
let i = 0
|
||||
for (; i < colors.length && color === null; i++) {
|
||||
color = colors[(randInt + i) % colors.length]
|
||||
}
|
||||
if (color === null) {
|
||||
colors = niceColors.slice()
|
||||
i = 0
|
||||
color = colors[randInt % colors.length]
|
||||
}
|
||||
colors[randInt % colors.length] = null
|
||||
style += createUserCSS(userid, name, color, color + '69')
|
||||
})
|
||||
this.userStyleContainer.innerHTML = style
|
||||
}
|
||||
/**
|
||||
* @param {Map<number, string>} [updatedUserMap] Maps from userid (yjs model) to account name (e.g. mail address)
|
||||
*/
|
||||
snapshot (updatedUserMap = new Map()) {
|
||||
const y = prosemirrorPluginKey.getState(this.editorView.state).y
|
||||
const history = y.define('history', Y.Array)
|
||||
const encoder = encoding.createEncoder()
|
||||
historyProtocol.writeHistorySnapshot(encoder, y, updatedUserMap)
|
||||
encoding.writeUint32(encoder, Math.floor(Date.now() / 1000))
|
||||
history.push([encoding.toBuffer(encoder)])
|
||||
}
|
||||
}
|
||||
197
examples/prosemirror-schema.js
Normal file
197
examples/prosemirror-schema.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Schema } from 'prosemirror-model'
|
||||
|
||||
const brDOM = ['br']
|
||||
|
||||
const calcYchangeDomAttrs = (attrs, domAttrs = {}) => {
|
||||
domAttrs = Object.assign({}, domAttrs)
|
||||
if (attrs.ychange !== null) {
|
||||
domAttrs.ychange_user = attrs.ychange.user
|
||||
domAttrs.ychange_state = attrs.ychange.state
|
||||
}
|
||||
return domAttrs
|
||||
}
|
||||
|
||||
// :: Object
|
||||
// [Specs](#model.NodeSpec) for the nodes defined in this schema.
|
||||
export const nodes = {
|
||||
// :: NodeSpec The top level document node.
|
||||
doc: {
|
||||
content: 'block+'
|
||||
},
|
||||
|
||||
// :: NodeSpec A plain paragraph textblock. Represented in the DOM
|
||||
// as a `<p>` element.
|
||||
paragraph: {
|
||||
attrs: { ychange: { default: null } },
|
||||
content: 'inline*',
|
||||
group: 'block',
|
||||
parseDOM: [{ tag: 'p' }],
|
||||
toDOM (node) { return ['p', calcYchangeDomAttrs(node.attrs), 0] }
|
||||
},
|
||||
|
||||
// :: NodeSpec A blockquote (`<blockquote>`) wrapping one or more blocks.
|
||||
blockquote: {
|
||||
attrs: { ychange: { default: null } },
|
||||
content: 'block+',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
parseDOM: [{ tag: 'blockquote' }],
|
||||
toDOM (node) { return ['blockquote', calcYchangeDomAttrs(node.attrs), 0] }
|
||||
},
|
||||
|
||||
// :: NodeSpec A horizontal rule (`<hr>`).
|
||||
horizontal_rule: {
|
||||
attrs: { ychange: { default: null } },
|
||||
group: 'block',
|
||||
parseDOM: [{ tag: 'hr' }],
|
||||
toDOM (node) {
|
||||
return ['hr', calcYchangeDomAttrs(node.attrs)]
|
||||
}
|
||||
},
|
||||
|
||||
// :: NodeSpec A heading textblock, with a `level` attribute that
|
||||
// should hold the number 1 to 6. Parsed and serialized as `<h1>` to
|
||||
// `<h6>` elements.
|
||||
heading: {
|
||||
attrs: {
|
||||
level: { default: 1 },
|
||||
ychange: { default: null }
|
||||
},
|
||||
content: 'inline*',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
parseDOM: [{ tag: 'h1', attrs: { level: 1 } },
|
||||
{ tag: 'h2', attrs: { level: 2 } },
|
||||
{ tag: 'h3', attrs: { level: 3 } },
|
||||
{ tag: 'h4', attrs: { level: 4 } },
|
||||
{ tag: 'h5', attrs: { level: 5 } },
|
||||
{ tag: 'h6', attrs: { level: 6 } }],
|
||||
toDOM (node) { return ['h' + node.attrs.level, calcYchangeDomAttrs(node.attrs), 0] }
|
||||
},
|
||||
|
||||
// :: NodeSpec A code listing. Disallows marks or non-text inline
|
||||
// nodes by default. Represented as a `<pre>` element with a
|
||||
// `<code>` element inside of it.
|
||||
code_block: {
|
||||
attrs: { ychange: { default: null } },
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
group: 'block',
|
||||
code: true,
|
||||
defining: true,
|
||||
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }],
|
||||
toDOM (node) { return ['pre', calcYchangeDomAttrs(node.attrs), ['code', 0]] }
|
||||
},
|
||||
|
||||
// :: NodeSpec The text node.
|
||||
text: {
|
||||
group: 'inline'
|
||||
},
|
||||
|
||||
// :: NodeSpec An inline image (`<img>`) node. Supports `src`,
|
||||
// `alt`, and `href` attributes. The latter two default to the empty
|
||||
// string.
|
||||
image: {
|
||||
inline: true,
|
||||
attrs: {
|
||||
ychange: { default: null },
|
||||
src: {},
|
||||
alt: { default: null },
|
||||
title: { default: null }
|
||||
},
|
||||
group: 'inline',
|
||||
draggable: true,
|
||||
parseDOM: [{ tag: 'img[src]',
|
||||
getAttrs (dom) {
|
||||
return {
|
||||
src: dom.getAttribute('src'),
|
||||
title: dom.getAttribute('title'),
|
||||
alt: dom.getAttribute('alt')
|
||||
}
|
||||
} }],
|
||||
toDOM (node) {
|
||||
const domAttrs = {
|
||||
src: node.attrs.src,
|
||||
title: node.attrs.title,
|
||||
alt: node.attrs.alt
|
||||
}
|
||||
return ['img', calcYchangeDomAttrs(node.attrs, domAttrs)]
|
||||
}
|
||||
},
|
||||
|
||||
// :: NodeSpec A hard line break, represented in the DOM as `<br>`.
|
||||
hard_break: {
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
selectable: false,
|
||||
parseDOM: [{ tag: 'br' }],
|
||||
toDOM () { return brDOM }
|
||||
}
|
||||
}
|
||||
|
||||
const emDOM = ['em', 0]; const strongDOM = ['strong', 0]; const codeDOM = ['code', 0]
|
||||
|
||||
// :: Object [Specs](#model.MarkSpec) for the marks in the schema.
|
||||
export const marks = {
|
||||
// :: MarkSpec A link. Has `href` and `title` attributes. `title`
|
||||
// defaults to the empty string. Rendered and parsed as an `<a>`
|
||||
// element.
|
||||
link: {
|
||||
attrs: {
|
||||
href: {},
|
||||
title: { default: null }
|
||||
},
|
||||
inclusive: false,
|
||||
parseDOM: [{ tag: 'a[href]',
|
||||
getAttrs (dom) {
|
||||
return { href: dom.getAttribute('href'), title: dom.getAttribute('title') }
|
||||
} }],
|
||||
toDOM (node) { return ['a', node.attrs, 0] }
|
||||
},
|
||||
|
||||
// :: MarkSpec An emphasis mark. Rendered as an `<em>` element.
|
||||
// Has parse rules that also match `<i>` and `font-style: italic`.
|
||||
em: {
|
||||
parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }],
|
||||
toDOM () { return emDOM }
|
||||
},
|
||||
|
||||
// :: MarkSpec A strong mark. Rendered as `<strong>`, parse rules
|
||||
// also match `<b>` and `font-weight: bold`.
|
||||
strong: {
|
||||
parseDOM: [{ tag: 'strong' },
|
||||
// This works around a Google Docs misbehavior where
|
||||
// pasted content will be inexplicably wrapped in `<b>`
|
||||
// tags with a font-weight normal.
|
||||
{ tag: 'b', getAttrs: node => node.style.fontWeight !== 'normal' && null },
|
||||
{ style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null }],
|
||||
toDOM () { return strongDOM }
|
||||
},
|
||||
|
||||
// :: MarkSpec Code font mark. Represented as a `<code>` element.
|
||||
code: {
|
||||
parseDOM: [{ tag: 'code' }],
|
||||
toDOM () { return codeDOM }
|
||||
},
|
||||
ychange: {
|
||||
attrs: {
|
||||
user: { default: null },
|
||||
state: { default: null }
|
||||
},
|
||||
inclusive: false,
|
||||
parseDOM: [{ tag: 'ychange' }],
|
||||
toDOM (node) {
|
||||
return ['ychange', { ychange_user: node.attrs.user, ychange_state: node.attrs.state }, 0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// :: Schema
|
||||
// This schema rougly corresponds to the document schema used by
|
||||
// [CommonMark](http://commonmark.org/), minus the list elements,
|
||||
// which are defined in the [`prosemirror-schema-list`](#schema-list)
|
||||
// module.
|
||||
//
|
||||
// To reuse elements from this schema, extend or read from its
|
||||
// `spec.nodes` and `spec.marks` [properties](#model.Schema.spec).
|
||||
export const schema = new Schema({ nodes, marks })
|
||||
330
examples/prosemirror.css
Normal file
330
examples/prosemirror.css
Normal file
@@ -0,0 +1,330 @@
|
||||
.ProseMirror {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ProseMirror li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::selection { background: transparent; }
|
||||
.ProseMirror-hideselection *::-moz-selection { background: transparent; }
|
||||
.ProseMirror-hideselection { caret-color: transparent; }
|
||||
|
||||
.ProseMirror-selectednode {
|
||||
outline: 2px solid #8cf;
|
||||
}
|
||||
|
||||
/* Make sure li selections wrap around markers */
|
||||
|
||||
li.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
li.ProseMirror-selectednode:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -32px;
|
||||
right: -2px; top: -2px; bottom: -2px;
|
||||
border: 2px solid #8cf;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ProseMirror-textblock-dropdown {
|
||||
min-width: 3em;
|
||||
}
|
||||
|
||||
.ProseMirror-menu {
|
||||
margin: 0 -4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ProseMirror-tooltip .ProseMirror-menu {
|
||||
width: -webkit-fit-content;
|
||||
width: fit-content;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
margin-right: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ProseMirror-menuseparator {
|
||||
border-right: 1px solid #ddd;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu {
|
||||
font-size: 90%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown {
|
||||
vertical-align: 1px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-wrap {
|
||||
padding: 1px 0 1px 4px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown:after {
|
||||
content: "";
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid currentColor;
|
||||
opacity: .6;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: calc(50% - 2px);
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu {
|
||||
position: absolute;
|
||||
background: white;
|
||||
color: #666;
|
||||
border: 1px solid #aaa;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-menu {
|
||||
z-index: 15;
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-item {
|
||||
cursor: pointer;
|
||||
padding: 2px 8px 2px 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-item:hover {
|
||||
background: #f2f2f2;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-wrap {
|
||||
position: relative;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-label:after {
|
||||
content: "";
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 4px solid currentColor;
|
||||
opacity: .6;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu {
|
||||
display: none;
|
||||
min-width: 4em;
|
||||
left: 100%;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
background: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
background: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled {
|
||||
opacity: .3;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
border-top-left-radius: inherit;
|
||||
border-top-right-radius: inherit;
|
||||
position: relative;
|
||||
min-height: 1em;
|
||||
color: #666;
|
||||
padding: 1px 6px;
|
||||
top: 0; left: 0; right: 0;
|
||||
border-bottom: 1px solid silver;
|
||||
background: white;
|
||||
z-index: 10;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
display: inline-block;
|
||||
line-height: .8;
|
||||
vertical-align: -2px; /* Compensate for padding */
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled.ProseMirror-icon {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ProseMirror-icon svg {
|
||||
fill: currentColor;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.ProseMirror-icon span {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid black;
|
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes ProseMirror-cursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
/* Add space around the hr to make clicking it easier */
|
||||
|
||||
.ProseMirror-example-setup-style hr {
|
||||
padding: 2px 10px;
|
||||
border: none;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.ProseMirror-example-setup-style hr:after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
background-color: silver;
|
||||
line-height: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror ul, .ProseMirror ol {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.ProseMirror blockquote {
|
||||
padding-left: 1em;
|
||||
border-left: 3px solid #eee;
|
||||
margin-left: 0; margin-right: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-example-setup-style img {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
background: white;
|
||||
padding: 5px 10px 5px 15px;
|
||||
border: 1px solid silver;
|
||||
position: fixed;
|
||||
border-radius: 3px;
|
||||
z-index: 11;
|
||||
box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
.ProseMirror-prompt h5 {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 100%;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt input[type="text"],
|
||||
.ProseMirror-prompt textarea {
|
||||
background: #eee;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt input[type="text"] {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-close {
|
||||
position: absolute;
|
||||
left: 2px; top: 1px;
|
||||
color: #666;
|
||||
border: none; background: transparent; padding: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-close:after {
|
||||
content: "✕";
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ProseMirror-invalid {
|
||||
background: #ffc;
|
||||
border: 1px solid #cc7;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
min-width: 10em;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
margin-top: 5px;
|
||||
display: none;
|
||||
}
|
||||
#editor, .editor {
|
||||
background: white;
|
||||
color: black;
|
||||
background-clip: padding-box;
|
||||
border-radius: 4px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
padding: 5px 0;
|
||||
margin-bottom: 23px;
|
||||
}
|
||||
|
||||
.ProseMirror p:first-child,
|
||||
.ProseMirror h1:first-child,
|
||||
.ProseMirror h2:first-child,
|
||||
.ProseMirror h3:first-child,
|
||||
.ProseMirror h4:first-child,
|
||||
.ProseMirror h5:first-child,
|
||||
.ProseMirror h6:first-child {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
padding: 4px 8px 4px 14px;
|
||||
line-height: 1.2;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror p { margin-bottom: 1em }
|
||||
|
||||
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" type="module">
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
import { prosemirrorPlugin, cursorPlugin } from 'yjs/bindings/prosemirror'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import { EditorState } from 'prosemirror-state'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { DOMParser } from 'prosemirror-model'
|
||||
import { schema } from 'prosemirror-schema-basic'
|
||||
import { exampleSetup } from 'prosemirror-example-setup'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('prosemirror')
|
||||
const type = ydocument.define('prosemirror', Y.XmlFragment)
|
||||
|
||||
const prosemirrorView = new EditorView(document.querySelector('#editor'), {
|
||||
state: EditorState.create({
|
||||
doc: DOMParser.fromSchema(schema).parse(document.querySelector('#content')),
|
||||
plugins: exampleSetup({schema}).concat([prosemirrorPlugin(type), cursorPlugin])
|
||||
})
|
||||
})
|
||||
|
||||
window.example = { provider, ydocument, type, prosemirrorView }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
25
examples/prosemirror.js
Normal file
25
examples/prosemirror.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { prosemirrorPlugin, cursorPlugin } from 'y-prosemirror'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import { EditorState } from 'prosemirror-state'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
import { DOMParser } from 'prosemirror-model'
|
||||
import { schema } from './prosemirror-schema.js'
|
||||
import { exampleSetup } from 'prosemirror-example-setup'
|
||||
// import { noteHistoryPlugin } from './prosemirror-history.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('prosemirror', { gc: false })
|
||||
const type = ydocument.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 }
|
||||
@@ -1,73 +0,0 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
import {Plugin} from 'prosemirror-state'
|
||||
import {Decoration, DecorationSet} from 'prosemirror-view'
|
||||
import {schema} from 'prosemirror-schema-basic'
|
||||
|
||||
const findPlaceholder = (state, id) => {
|
||||
let decos = PlaceholderPlugin.getState(state)
|
||||
let found = decos.find(null, null, spec => spec.id === id)
|
||||
return found.length ? found[0].from : null
|
||||
}
|
||||
|
||||
export const startImageUpload = (view, file) => {
|
||||
// A fresh object to act as the ID for this upload
|
||||
let id = {}
|
||||
|
||||
// Replace the selection with a placeholder
|
||||
let tr = view.state.tr
|
||||
if (!tr.selection.empty) tr.deleteSelection()
|
||||
tr.setMeta(PlaceholderPlugin, {add: {id, pos: tr.selection.from}})
|
||||
view.dispatch(tr)
|
||||
|
||||
uploadFile(file).then(url => {
|
||||
let pos = findPlaceholder(view.state, id)
|
||||
// If the content around the placeholder has been deleted, drop
|
||||
// the image
|
||||
if (pos == null) return
|
||||
// Otherwise, insert it at the placeholder's position, and remove
|
||||
// the placeholder
|
||||
view.dispatch(view.state.tr
|
||||
.replaceWith(pos, pos, schema.nodes.image.create({src: url}))
|
||||
.setMeta(PlaceholderPlugin, {remove: {id}}))
|
||||
}, () => {
|
||||
// On failure, just clean up the placeholder
|
||||
view.dispatch(tr.setMeta(PlaceholderPlugin, {remove: {id}}))
|
||||
})
|
||||
}
|
||||
|
||||
// This is just a dummy that loads the file and creates a data URL.
|
||||
// You could swap it out with a function that does an actual upload
|
||||
// and returns a regular URL for the uploaded file.
|
||||
function uploadFile (file) {
|
||||
let reader = new FileReader()
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.onload = () => resolve(reader.result)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
// Some extra delay to make the asynchronicity visible
|
||||
setTimeout(() => reader.readAsDataURL(file), 1500)
|
||||
})
|
||||
}
|
||||
|
||||
export const PlaceholderPlugin = new Plugin({
|
||||
state: {
|
||||
init () { return DecorationSet.empty },
|
||||
apply (tr, set) {
|
||||
// Adjust decoration positions to changes made by the transaction
|
||||
set = set.map(tr.mapping, tr.doc)
|
||||
// See if the transaction adds or removes any placeholders
|
||||
let action = tr.getMeta(this)
|
||||
if (action && action.add) {
|
||||
let widget = document.createElement('placeholder')
|
||||
let deco = Decoration.widget(action.add.pos, widget, {id: action.add.id})
|
||||
set = set.add(tr.doc, [deco])
|
||||
} else if (action && action.remove) {
|
||||
set = set.remove(set.find(null, null, spec => spec.id === action.remove.id))
|
||||
}
|
||||
return set
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations (state) { return this.getState(state) }
|
||||
}
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
|
||||
# Prosemirror Example
|
||||
|
||||
### Run basic websockets server
|
||||
|
||||
```sh
|
||||
node /provider/websocket/server.js
|
||||
```
|
||||
|
||||
### Bundle Prosemirror Example
|
||||
|
||||
This example requires external modules and needs to be bundled before shipping it to the browser.
|
||||
|
||||
```sh
|
||||
cd /examples/prosemirror/
|
||||
# bundle prosemirror example
|
||||
npx rollup -wc
|
||||
# serve example
|
||||
npx serve .
|
||||
```
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="module" src="./index.dist.js"></script>
|
||||
<link rel=stylesheet href="https://prosemirror.net/css/editor.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 }
|
||||
.ProseMirror-yjs-cursor {
|
||||
position: absolute;
|
||||
border-left: black;
|
||||
border-left-style: solid;
|
||||
border-left-width: 2px;
|
||||
border-color: orange;
|
||||
height: 1em;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" style="margin-bottom: 23px"></div>
|
||||
<div style="display: none" id="content">
|
||||
<h3>Hello User</h3>
|
||||
<p>type something ...</p>
|
||||
</div>
|
||||
<div>Insert image: <input type=file id=image-upload></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,45 +0,0 @@
|
||||
/* eslint-env browser */
|
||||
import * as Y from '../../src/index.js'
|
||||
import { prosemirrorPlugin, cursorPlugin } from '../../bindings/ProsemirrorBinding/ProsemirrorBinding.js'
|
||||
import WebsocketProvider from '../../provider/websocket/WebSocketProvider.js'
|
||||
|
||||
import {EditorState} from 'prosemirror-state'
|
||||
import {EditorView} from 'prosemirror-view'
|
||||
import {Schema, DOMParser, Mark, Fragment, Node, Slice} from 'prosemirror-model'
|
||||
import {schema} from 'prosemirror-schema-basic'
|
||||
import {exampleSetup} from 'prosemirror-example-setup'
|
||||
import { PlaceholderPlugin, startImageUpload } from './PlaceholderPlugin.js'
|
||||
|
||||
const provider = new WebsocketProvider('ws://localhost:1234/')
|
||||
const ydocument = provider.get('prosemirror')
|
||||
|
||||
/**
|
||||
* @type {any}
|
||||
*/
|
||||
const type = ydocument.define('prosemirror', Y.XmlFragment)
|
||||
|
||||
const view = new EditorView(document.querySelector('#editor'), {
|
||||
state: EditorState.create({
|
||||
doc: DOMParser.fromSchema(schema).parse(document.querySelector('#content')),
|
||||
plugins: exampleSetup({schema}).concat([PlaceholderPlugin, prosemirrorPlugin(type), cursorPlugin])
|
||||
})
|
||||
})
|
||||
|
||||
window.provider = provider
|
||||
window.ydocument = ydocument
|
||||
window.type = type
|
||||
window.view = view
|
||||
window.EditorState = EditorState
|
||||
window.EditorView = EditorView
|
||||
window.Mark = Mark
|
||||
window.Fragment = Fragment
|
||||
window.Node = Node
|
||||
window.Schema = Schema
|
||||
window.Slice = Slice
|
||||
|
||||
document.querySelector('#image-upload').addEventListener('change', e => {
|
||||
if (view.state.selection.$from.parent.inlineContent && e.target.files.length) {
|
||||
startImageUpload(view, e.target.files[0])
|
||||
}
|
||||
view.focus()
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
|
||||
export default {
|
||||
input: './index.js',
|
||||
output: {
|
||||
name: 'index',
|
||||
file: 'index.dist.js',
|
||||
format: 'umd',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
main: true,
|
||||
module: true,
|
||||
browser: true
|
||||
}),
|
||||
commonjs()
|
||||
]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- Main quill library -->
|
||||
<script src="../../node_modules/quill/dist/quill.min.js"></script>
|
||||
<link href="../../node_modules/quill/dist/quill.snow.css" rel="stylesheet">
|
||||
<!-- Quill cursors module -->
|
||||
<script src="../../node_modules/quill-cursors/dist/quill-cursors.min.js"></script>
|
||||
<link href="../../node_modules/quill-cursors/dist/quill-cursors.css" rel="stylesheet">
|
||||
<!-- Yjs Library and connector -->
|
||||
<script src="../../y.js"></script>
|
||||
<script src='../../../y-websockets-client/y-websockets-client.js'></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="quill-container">
|
||||
<div id="quill">
|
||||
</div>
|
||||
</div>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,78 +0,0 @@
|
||||
/* global Y, Quill, QuillCursors */
|
||||
|
||||
Quill.register('modules/cursors', QuillCursors)
|
||||
|
||||
let y = new Y('quill-0', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
let users = y.define('users', Y.Array)
|
||||
let myUserInfo = new Y.Map()
|
||||
myUserInfo.set('name', 'dada')
|
||||
myUserInfo.set('color', 'red')
|
||||
users.push([myUserInfo])
|
||||
|
||||
let quill = new Quill('#quill-container', {
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
['image', 'code-block'],
|
||||
[{ color: [] }, { background: [] }], // Snow theme fills in values
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
['link', 'image'],
|
||||
['link', 'code-block'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }]
|
||||
],
|
||||
cursors: {
|
||||
hideDelay: 500
|
||||
}
|
||||
},
|
||||
placeholder: 'Compose an epic...',
|
||||
theme: 'snow' // or 'bubble'
|
||||
})
|
||||
|
||||
let cursors = quill.getModule('cursors')
|
||||
|
||||
function drawCursors () {
|
||||
cursors.clearCursors()
|
||||
users.map((user, userId) => {
|
||||
if (user !== myUserInfo) {
|
||||
let relativeRange = user.get('range')
|
||||
let lastUpdated = new Date(user.get('last updated'))
|
||||
if (lastUpdated != null && new Date() - lastUpdated < 20000 && relativeRange != null) {
|
||||
let start = Y.utils.fromRelativePosition(y, relativeRange.start).offset
|
||||
let end = Y.utils.fromRelativePosition(y, relativeRange.end).offset
|
||||
let range = { index: start, length: end - start }
|
||||
cursors.setCursor(userId + '', range, user.get('name'), user.get('color'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
users.observeDeep(drawCursors)
|
||||
drawCursors()
|
||||
|
||||
quill.on('selection-change', function (range) {
|
||||
if (range != null) {
|
||||
myUserInfo.set('range', {
|
||||
start: Y.utils.getRelativePosition(yText, range.index),
|
||||
end: Y.utils.getRelativePosition(yText, range.index + range.length)
|
||||
})
|
||||
} else {
|
||||
myUserInfo.delete('range')
|
||||
}
|
||||
myUserInfo.set('last updated', new Date().toString())
|
||||
})
|
||||
|
||||
let yText = y.define('quill', Y.Text)
|
||||
let quillBinding = new Y.QuillBinding(yText, quill)
|
||||
|
||||
window.quillBinding = quillBinding
|
||||
window.yText = yText
|
||||
window.y = y
|
||||
window.quill = quill
|
||||
window.users = users
|
||||
window.cursors = cursors
|
||||
51
examples/quill.html
Normal file
51
examples/quill.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Yjs Quill Example</title>
|
||||
<link rel="stylesheet" href="https://cdn.quilljs.com/1.3.6/quill.snow.css">
|
||||
</head>
|
||||
<body>
|
||||
<p>This example shows how to bind a YText type to <a href="https://quilljs.com">Quill</a> editor.</p>
|
||||
<p>The content of this editor is shared with every client who visits this domain.</p>
|
||||
<div class="code-html">
|
||||
<div id="quill-container">
|
||||
<div id="quill">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- The actual source file for the following code is found in ./quill.js. Run `npm run watch` to compile the files -->
|
||||
<script class="code-js" src="./build/quill.js" type="module">
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
import { QuillBinding } from 'yjs/bindings/quill.js'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
import Quill from 'quill'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('quill')
|
||||
const ytext = ydocument.define('quill', Y.Text)
|
||||
|
||||
const quill = new Quill('#quill-container', {
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
['image', 'code-block'],
|
||||
[{ color: [] }, { background: [] }], // Snow theme fills in values
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
['link', 'image'],
|
||||
['link', 'code-block'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }]
|
||||
]
|
||||
},
|
||||
placeholder: 'Compose an epic...',
|
||||
theme: 'snow' // or 'bubble'
|
||||
})
|
||||
|
||||
window.quillBinding = new QuillBinding(ytext, quill)
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +1,16 @@
|
||||
/* global Y, Quill */
|
||||
import * as Y from '../src/index.js'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { QuillBinding } from 'y-quill'
|
||||
|
||||
let y = new Y('quill-cursors-0', {
|
||||
connector: {
|
||||
name: 'websockets-client',
|
||||
url: 'http://127.0.0.1:1234'
|
||||
}
|
||||
})
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
let quill = new Quill('#quill-container', {
|
||||
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] }],
|
||||
@@ -24,10 +27,4 @@ let quill = new Quill('#quill-container', {
|
||||
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
|
||||
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,31 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- quill does not include dist files! We are using the hosted version instead -->
|
||||
<!--link rel="stylesheet" href="../bower_components/quill/dist/quill.snow.css" /-->
|
||||
<link href="https://cdn.quilljs.com/1.0.4/quill.snow.css" rel="stylesheet">
|
||||
<link href="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.css" rel="stylesheet">
|
||||
<link href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.2.0/styles/monokai-sublime.min.css" rel="stylesheet">
|
||||
<style>
|
||||
#quill-container {
|
||||
border: 1px solid gray;
|
||||
box-shadow: 0px 0px 10px gray;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="quill-container">
|
||||
<div id="quill">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/KaTeX/0.5.1/katex.min.js" type="text/javascript"></script>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.2.0/highlight.min.js" type="text/javascript"></script>
|
||||
<script src="https://cdn.quilljs.com/1.0.4/quill.js"></script>
|
||||
<!-- quill does not include dist files! We are using the hosted version instead (see above)
|
||||
<script src="../bower_components/quill/dist/quill.js"></script>
|
||||
-->
|
||||
<script src="../bower_components/yjs/y.js"></script>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,49 +0,0 @@
|
||||
/* global Y, Quill */
|
||||
|
||||
// register yjs service worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Register service worker
|
||||
// it is important to copy yjs-sw-template to the root directory!
|
||||
navigator.serviceWorker.register('./yjs-sw-template.js').then(function (reg) {
|
||||
console.log('Yjs service worker registration succeeded. Scope is ' + reg.scope)
|
||||
}).catch(function (err) {
|
||||
console.error('Yjs service worker registration failed with error ' + err)
|
||||
})
|
||||
}
|
||||
|
||||
// initialize a shared object. This function call returns a promise!
|
||||
Y({
|
||||
db: {
|
||||
name: 'memory'
|
||||
},
|
||||
connector: {
|
||||
name: 'serviceworker',
|
||||
room: 'ServiceWorkerExample2'
|
||||
},
|
||||
sourceDir: '/bower_components',
|
||||
share: {
|
||||
richtext: 'Richtext' // y.share.richtext is of type Y.Richtext
|
||||
}
|
||||
}).then(function (y) {
|
||||
window.yServiceWorker = y
|
||||
|
||||
// create quill element
|
||||
window.quill = new Quill('#quill', {
|
||||
modules: {
|
||||
formula: true,
|
||||
syntax: true,
|
||||
toolbar: [
|
||||
[{ size: ['small', false, 'large', 'huge'] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ color: [] }, { background: [] }], // Snow theme fills in values
|
||||
[{ script: 'sub' }, { script: 'super' }],
|
||||
['link', 'image'],
|
||||
['link', 'code-block'],
|
||||
[{ list: 'ordered' }]
|
||||
]
|
||||
},
|
||||
theme: 'snow'
|
||||
})
|
||||
// bind quill to richtext type
|
||||
y.share.richtext.bind(window.quill)
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
/* eslint-env worker */
|
||||
|
||||
// copy and modify this file
|
||||
|
||||
self.DBConfig = {
|
||||
name: 'indexeddb'
|
||||
}
|
||||
self.ConnectorConfig = {
|
||||
name: 'websockets-client',
|
||||
// url: '..',
|
||||
options: {
|
||||
jsonp: false
|
||||
}
|
||||
}
|
||||
|
||||
importScripts(
|
||||
'/bower_components/yjs/y.js',
|
||||
'/bower_components/y-memory/y-memory.js',
|
||||
'/bower_components/y-indexeddb/y-indexeddb.js',
|
||||
'/bower_components/y-websockets-client/y-websockets-client.js',
|
||||
'/bower_components/y-serviceworker/yjs-sw-include.js'
|
||||
)
|
||||
29
examples/style.css
Normal file
29
examples/style.css
Normal file
@@ -0,0 +1,29 @@
|
||||
footer img {
|
||||
display: none;
|
||||
}
|
||||
|
||||
nav .title h1 a {
|
||||
display: none;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #b93c1d;
|
||||
}
|
||||
|
||||
#resizer {
|
||||
background-color: #b93c1d;
|
||||
}
|
||||
|
||||
.main section article.readme h1:first-child img {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main section article.readme h1:first-child {
|
||||
margin-bottom: 16px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.main section article.readme h1:first-child::before {
|
||||
content: "Yjs";
|
||||
font-size: 2em;
|
||||
}
|
||||
32
examples/textarea.html
Normal file
32
examples/textarea.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Yjs Textarea Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>This example shows how to bind a YText type to a DOM Textarea.</p>
|
||||
<p>The content of this textarea is shared with every client who visits this domain.</p>
|
||||
<div class="code-html">
|
||||
|
||||
<textarea style="width:80%;" rows=40 autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<!-- The actual source file for the following code is found in ./textarea.js. Run `npm run watch` to compile the files -->
|
||||
<script class="code-js" src="./build/textarea.js" type="module">
|
||||
import * as Y from 'yjs'
|
||||
import { WebsocketProvider } from 'yjs/provider/websocket.js'
|
||||
import { TextareaBinding } from 'yjs/bindings/textarea.js'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('textarea')
|
||||
const type = ydocument.define('textarea', Y.Text)
|
||||
const textarea = document.querySelector('textarea')
|
||||
const binding = new TextareaBinding(type, textarea)
|
||||
|
||||
window.textareaExample = {
|
||||
provider, ydocument, type, textarea, binding
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
examples/textarea.js
Normal file
14
examples/textarea.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
import { TextareaBinding } from 'y-textarea'
|
||||
|
||||
import * as conf from './exampleConfig.js'
|
||||
|
||||
const provider = new WebsocketProvider(conf.serverAddress)
|
||||
const ydocument = provider.get('textarea')
|
||||
const type = ydocument.getText('textarea')
|
||||
const textarea = document.querySelector('textarea')
|
||||
const binding = new TextareaBinding(type, textarea)
|
||||
|
||||
window.textareaExample = {
|
||||
provider, ydocument, type, textarea, binding
|
||||
}
|
||||
@@ -1,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)
|
||||
@@ -1,113 +0,0 @@
|
||||
|
||||
/**
|
||||
* Handles named events.
|
||||
*/
|
||||
export default class NamedEventHandler {
|
||||
constructor () {
|
||||
this._eventListener = new Map()
|
||||
this._stateListener = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Returns all listeners that listen to a specified name.
|
||||
*
|
||||
* @param {String} name The query event name.
|
||||
*/
|
||||
_getListener (name) {
|
||||
let listeners = this._eventListener.get(name)
|
||||
if (listeners === undefined) {
|
||||
listeners = {
|
||||
once: new Set(),
|
||||
on: new Set()
|
||||
}
|
||||
this._eventListener.set(name, listeners)
|
||||
}
|
||||
return listeners
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a named event listener. The listener is removed after it has been
|
||||
* called once.
|
||||
*
|
||||
* @param {String} name The event name to listen to.
|
||||
* @param {Function} f The function that is executed when the event is fired.
|
||||
*/
|
||||
once (name, f) {
|
||||
let listeners = this._getListener(name)
|
||||
listeners.once.add(f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a named event listener.
|
||||
*
|
||||
* @param {String} name The event name to listen to.
|
||||
* @param {Function} f The function that is executed when the event is fired.
|
||||
*/
|
||||
on (name, f) {
|
||||
let listeners = this._getListener(name)
|
||||
listeners.on.add(f)
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Init the saved state for an event name.
|
||||
*/
|
||||
_initStateListener (name) {
|
||||
let state = this._stateListener.get(name)
|
||||
if (state === undefined) {
|
||||
state = {}
|
||||
state.promise = new Promise(function (resolve) {
|
||||
state.resolve = resolve
|
||||
})
|
||||
this._stateListener.set(name, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Promise that is resolved when the event name is called.
|
||||
* The Promise is immediately resolved when the event name was called in the
|
||||
* past.
|
||||
*/
|
||||
when (name) {
|
||||
return this._initStateListener(name).promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an event listener that was registered with either
|
||||
* {@link EventHandler#on} or {@link EventHandler#once}.
|
||||
*/
|
||||
off (name, f) {
|
||||
if (name == null || f == null) {
|
||||
throw new Error('You must specify event name and function!')
|
||||
}
|
||||
const listener = this._eventListener.get(name)
|
||||
if (listener !== undefined) {
|
||||
listener.on.delete(f)
|
||||
listener.once.delete(f)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a named event. All registered event listeners that listen to the
|
||||
* specified name will receive the event.
|
||||
*
|
||||
* @param {String} name The event name.
|
||||
* @param {Array} args The arguments that are applied to the event listener.
|
||||
*/
|
||||
emit (name, ...args) {
|
||||
this._initStateListener(name).resolve()
|
||||
const listener = this._eventListener.get(name)
|
||||
if (listener !== undefined) {
|
||||
listener.on.forEach(f => f.apply(null, args))
|
||||
listener.once.forEach(f => f.apply(null, args))
|
||||
listener.once = new Set()
|
||||
} else if (name === 'error') {
|
||||
console.error(args[0])
|
||||
}
|
||||
}
|
||||
destroy () {
|
||||
this._eventListener = null
|
||||
}
|
||||
}
|
||||
465
lib/Tree.js
465
lib/Tree.js
@@ -1,465 +0,0 @@
|
||||
|
||||
function rotate (tree, parent, newParent, n) {
|
||||
if (parent === null) {
|
||||
tree.root = newParent
|
||||
newParent._parent = null
|
||||
} else if (parent.left === n) {
|
||||
parent.left = newParent
|
||||
} else if (parent.right === n) {
|
||||
parent.right = newParent
|
||||
} else {
|
||||
throw new Error('The elements are wrongly connected!')
|
||||
}
|
||||
}
|
||||
|
||||
class N {
|
||||
// A created node is always red!
|
||||
constructor (val) {
|
||||
this.val = val
|
||||
this.color = true
|
||||
this._left = null
|
||||
this._right = null
|
||||
this._parent = null
|
||||
}
|
||||
isRed () { return this.color }
|
||||
isBlack () { return !this.color }
|
||||
redden () { this.color = true; return this }
|
||||
blacken () { this.color = false; return this }
|
||||
get grandparent () {
|
||||
return this.parent.parent
|
||||
}
|
||||
get parent () {
|
||||
return this._parent
|
||||
}
|
||||
get sibling () {
|
||||
return (this === this.parent.left)
|
||||
? this.parent.right : this.parent.left
|
||||
}
|
||||
get left () {
|
||||
return this._left
|
||||
}
|
||||
get right () {
|
||||
return this._right
|
||||
}
|
||||
set left (n) {
|
||||
if (n !== null) {
|
||||
n._parent = this
|
||||
}
|
||||
this._left = n
|
||||
}
|
||||
set right (n) {
|
||||
if (n !== null) {
|
||||
n._parent = this
|
||||
}
|
||||
this._right = n
|
||||
}
|
||||
rotateLeft (tree) {
|
||||
const parent = this.parent
|
||||
const newParent = this.right
|
||||
const newRight = this.right.left
|
||||
newParent.left = this
|
||||
this.right = newRight
|
||||
rotate(tree, parent, newParent, this)
|
||||
}
|
||||
next () {
|
||||
if (this.right !== null) {
|
||||
// search the most left node in the right tree
|
||||
var o = this.right
|
||||
while (o.left !== null) {
|
||||
o = o.left
|
||||
}
|
||||
return o
|
||||
} else {
|
||||
var p = this
|
||||
while (p.parent !== null && p !== p.parent.left) {
|
||||
p = p.parent
|
||||
}
|
||||
return p.parent
|
||||
}
|
||||
}
|
||||
prev () {
|
||||
if (this.left !== null) {
|
||||
// search the most right node in the left tree
|
||||
var o = this.left
|
||||
while (o.right !== null) {
|
||||
o = o.right
|
||||
}
|
||||
return o
|
||||
} else {
|
||||
var p = this
|
||||
while (p.parent !== null && p !== p.parent.right) {
|
||||
p = p.parent
|
||||
}
|
||||
return p.parent
|
||||
}
|
||||
}
|
||||
rotateRight (tree) {
|
||||
const parent = this.parent
|
||||
const newParent = this.left
|
||||
const newLeft = this.left.right
|
||||
newParent.right = this
|
||||
this.left = newLeft
|
||||
rotate(tree, parent, newParent, this)
|
||||
}
|
||||
getUncle () {
|
||||
// we can assume that grandparent exists when this is called!
|
||||
if (this.parent === this.parent.parent.left) {
|
||||
return this.parent.parent.right
|
||||
} else {
|
||||
return this.parent.parent.left
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* This is a Red Black Tree implementation
|
||||
*/
|
||||
export default class Tree {
|
||||
constructor () {
|
||||
this.root = null
|
||||
this.length = 0
|
||||
}
|
||||
findNext (id) {
|
||||
var nextID = id.clone()
|
||||
nextID.clock += 1
|
||||
return this.findWithLowerBound(nextID)
|
||||
}
|
||||
findPrev (id) {
|
||||
let prevID = id.clone()
|
||||
prevID.clock -= 1
|
||||
return this.findWithUpperBound(prevID)
|
||||
}
|
||||
findNodeWithLowerBound (from) {
|
||||
var o = this.root
|
||||
if (o === null) {
|
||||
return null
|
||||
} else {
|
||||
while (true) {
|
||||
if (from === null || (from.lessThan(o.val._id) && o.left !== null)) {
|
||||
// o is included in the bound
|
||||
// try to find an element that is closer to the bound
|
||||
o = o.left
|
||||
} else if (from !== null && o.val._id.lessThan(from)) {
|
||||
// o is not within the bound, maybe one of the right elements is..
|
||||
if (o.right !== null) {
|
||||
o = o.right
|
||||
} else {
|
||||
// there is no right element. Search for the next bigger element,
|
||||
// this should be within the bounds
|
||||
return o.next()
|
||||
}
|
||||
} else {
|
||||
return o
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
findNodeWithUpperBound (to) {
|
||||
if (to === void 0) {
|
||||
throw new Error('You must define from!')
|
||||
}
|
||||
var o = this.root
|
||||
if (o === null) {
|
||||
return null
|
||||
} else {
|
||||
while (true) {
|
||||
if ((to === null || o.val._id.lessThan(to)) && o.right !== null) {
|
||||
// o is included in the bound
|
||||
// try to find an element that is closer to the bound
|
||||
o = o.right
|
||||
} else if (to !== null && to.lessThan(o.val._id)) {
|
||||
// o is not within the bound, maybe one of the left elements is..
|
||||
if (o.left !== null) {
|
||||
o = o.left
|
||||
} else {
|
||||
// there is no left element. Search for the prev smaller element,
|
||||
// this should be within the bounds
|
||||
return o.prev()
|
||||
}
|
||||
} else {
|
||||
return o
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
findSmallestNode () {
|
||||
var o = this.root
|
||||
while (o != null && o.left != null) {
|
||||
o = o.left
|
||||
}
|
||||
return o
|
||||
}
|
||||
findWithLowerBound (from) {
|
||||
var n = this.findNodeWithLowerBound(from)
|
||||
return n == null ? null : n.val
|
||||
}
|
||||
findWithUpperBound (to) {
|
||||
var n = this.findNodeWithUpperBound(to)
|
||||
return n == null ? null : n.val
|
||||
}
|
||||
iterate (from, to, f) {
|
||||
var o
|
||||
if (from === null) {
|
||||
o = this.findSmallestNode()
|
||||
} else {
|
||||
o = this.findNodeWithLowerBound(from)
|
||||
}
|
||||
while (
|
||||
o !== null &&
|
||||
(
|
||||
to === null || // eslint-disable-line no-unmodified-loop-condition
|
||||
o.val._id.lessThan(to) ||
|
||||
o.val._id.equals(to)
|
||||
)
|
||||
) {
|
||||
f(o.val)
|
||||
o = o.next()
|
||||
}
|
||||
}
|
||||
find (id) {
|
||||
let n = this.findNode(id)
|
||||
if (n !== null) {
|
||||
return n.val
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
findNode (id) {
|
||||
var o = this.root
|
||||
if (o === null) {
|
||||
return null
|
||||
} else {
|
||||
while (true) {
|
||||
if (o === null) {
|
||||
return null
|
||||
}
|
||||
if (id.lessThan(o.val._id)) {
|
||||
o = o.left
|
||||
} else if (o.val._id.lessThan(id)) {
|
||||
o = o.right
|
||||
} else {
|
||||
return o
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
delete (id) {
|
||||
var d = this.findNode(id)
|
||||
if (d == null) {
|
||||
// throw new Error('Element does not exist!')
|
||||
return
|
||||
}
|
||||
this.length--
|
||||
if (d.left !== null && d.right !== null) {
|
||||
// switch d with the greates element in the left subtree.
|
||||
// o should have at most one child.
|
||||
var o = d.left
|
||||
// find
|
||||
while (o.right !== null) {
|
||||
o = o.right
|
||||
}
|
||||
// switch
|
||||
d.val = o.val
|
||||
d = o
|
||||
}
|
||||
// d has at most one child
|
||||
// let n be the node that replaces d
|
||||
var isFakeChild
|
||||
var child = d.left || d.right
|
||||
if (child === null) {
|
||||
isFakeChild = true
|
||||
child = new N(null)
|
||||
child.blacken()
|
||||
d.right = child
|
||||
} else {
|
||||
isFakeChild = false
|
||||
}
|
||||
|
||||
if (d.parent === null) {
|
||||
if (!isFakeChild) {
|
||||
this.root = child
|
||||
child.blacken()
|
||||
child._parent = null
|
||||
} else {
|
||||
this.root = null
|
||||
}
|
||||
return
|
||||
} else if (d.parent.left === d) {
|
||||
d.parent.left = child
|
||||
} else if (d.parent.right === d) {
|
||||
d.parent.right = child
|
||||
} else {
|
||||
throw new Error('Impossible!')
|
||||
}
|
||||
if (d.isBlack()) {
|
||||
if (child.isRed()) {
|
||||
child.blacken()
|
||||
} else {
|
||||
this._fixDelete(child)
|
||||
}
|
||||
}
|
||||
this.root.blacken()
|
||||
if (isFakeChild) {
|
||||
if (child.parent.left === child) {
|
||||
child.parent.left = null
|
||||
} else if (child.parent.right === child) {
|
||||
child.parent.right = null
|
||||
} else {
|
||||
throw new Error('Impossible #3')
|
||||
}
|
||||
}
|
||||
}
|
||||
_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
|
||||
}
|
||||
// d was already replaced by the child
|
||||
// d is not the root
|
||||
// d and child are black
|
||||
var sibling = n.sibling
|
||||
if (isRed(sibling)) {
|
||||
// make sibling the grandfather
|
||||
n.parent.redden()
|
||||
sibling.blacken()
|
||||
if (n === n.parent.left) {
|
||||
n.parent.rotateLeft(this)
|
||||
} else if (n === n.parent.right) {
|
||||
n.parent.rotateRight(this)
|
||||
} else {
|
||||
throw new Error('Impossible #2')
|
||||
}
|
||||
sibling = n.sibling
|
||||
}
|
||||
// parent, sibling, and children of n are black
|
||||
if (n.parent.isBlack() &&
|
||||
sibling.isBlack() &&
|
||||
isBlack(sibling.left) &&
|
||||
isBlack(sibling.right)
|
||||
) {
|
||||
sibling.redden()
|
||||
this._fixDelete(n.parent)
|
||||
} else if (n.parent.isRed() &&
|
||||
sibling.isBlack() &&
|
||||
isBlack(sibling.left) &&
|
||||
isBlack(sibling.right)
|
||||
) {
|
||||
sibling.redden()
|
||||
n.parent.blacken()
|
||||
} else {
|
||||
if (n === n.parent.left &&
|
||||
sibling.isBlack() &&
|
||||
isRed(sibling.left) &&
|
||||
isBlack(sibling.right)
|
||||
) {
|
||||
sibling.redden()
|
||||
sibling.left.blacken()
|
||||
sibling.rotateRight(this)
|
||||
sibling = n.sibling
|
||||
} else if (n === n.parent.right &&
|
||||
sibling.isBlack() &&
|
||||
isRed(sibling.right) &&
|
||||
isBlack(sibling.left)
|
||||
) {
|
||||
sibling.redden()
|
||||
sibling.right.blacken()
|
||||
sibling.rotateLeft(this)
|
||||
sibling = n.sibling
|
||||
}
|
||||
sibling.color = n.parent.color
|
||||
n.parent.blacken()
|
||||
if (n === n.parent.left) {
|
||||
sibling.right.blacken()
|
||||
n.parent.rotateLeft(this)
|
||||
} else {
|
||||
sibling.left.blacken()
|
||||
n.parent.rotateRight(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
put (v) {
|
||||
var node = new N(v)
|
||||
if (this.root !== null) {
|
||||
var p = this.root // p abbrev. parent
|
||||
while (true) {
|
||||
if (node.val._id.lessThan(p.val._id)) {
|
||||
if (p.left === null) {
|
||||
p.left = node
|
||||
break
|
||||
} else {
|
||||
p = p.left
|
||||
}
|
||||
} else if (p.val._id.lessThan(node.val._id)) {
|
||||
if (p.right === null) {
|
||||
p.right = node
|
||||
break
|
||||
} else {
|
||||
p = p.right
|
||||
}
|
||||
} else {
|
||||
p.val = node.val
|
||||
return p
|
||||
}
|
||||
}
|
||||
this._fixInsert(node)
|
||||
} else {
|
||||
this.root = node
|
||||
}
|
||||
this.length++
|
||||
this.root.blacken()
|
||||
return node
|
||||
}
|
||||
_fixInsert (n) {
|
||||
if (n.parent === null) {
|
||||
n.blacken()
|
||||
return
|
||||
} else if (n.parent.isBlack()) {
|
||||
return
|
||||
}
|
||||
var uncle = n.getUncle()
|
||||
if (uncle !== null && uncle.isRed()) {
|
||||
// Note: parent: red, uncle: red
|
||||
n.parent.blacken()
|
||||
uncle.blacken()
|
||||
n.grandparent.redden()
|
||||
this._fixInsert(n.grandparent)
|
||||
} else {
|
||||
// Note: parent: red, uncle: black or null
|
||||
// Now we transform the tree in such a way that
|
||||
// either of these holds:
|
||||
// 1) grandparent.left.isRed
|
||||
// and grandparent.left.left.isRed
|
||||
// 2) grandparent.right.isRed
|
||||
// and grandparent.right.right.isRed
|
||||
if (n === n.parent.right && n.parent === n.grandparent.left) {
|
||||
n.parent.rotateLeft(this)
|
||||
// Since we rotated and want to use the previous
|
||||
// cases, we need to set n in such a way that
|
||||
// n.parent.isRed again
|
||||
n = n.left
|
||||
} else if (n === n.parent.left && n.parent === n.grandparent.right) {
|
||||
n.parent.rotateRight(this)
|
||||
// see above
|
||||
n = n.right
|
||||
}
|
||||
// Case 1) or 2) hold from here on.
|
||||
// Now traverse grandparent, make parent a black node
|
||||
// on the highest level which holds two red nodes.
|
||||
n.parent.blacken()
|
||||
n.grandparent.redden()
|
||||
if (n === n.parent.left) {
|
||||
// Case 1
|
||||
n.grandparent.rotateRight(this)
|
||||
} else {
|
||||
// Case 2
|
||||
n.grandparent.rotateLeft(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
|
||||
export const BITS32 = 0xFFFFFFFF
|
||||
export const BITS21 = (1 << 21) - 1
|
||||
export const BITS16 = (1 << 16) - 1
|
||||
|
||||
export const BIT26 = 1 << 26
|
||||
export const BIT32 = 1 << 32
|
||||
168
lib/decoding.js
168
lib/decoding.js
@@ -1,168 +0,0 @@
|
||||
|
||||
/* global Buffer */
|
||||
|
||||
import * as globals from './globals.js'
|
||||
|
||||
/**
|
||||
* A Decoder handles the decoding of an ArrayBuffer.
|
||||
*/
|
||||
export class Decoder {
|
||||
/**
|
||||
* @param {ArrayBuffer} buffer Binary data to decode
|
||||
*/
|
||||
constructor (buffer) {
|
||||
this.arr = new Uint8Array(buffer)
|
||||
this.pos = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @return {Decoder}
|
||||
*/
|
||||
export const createDecoder = buffer => new Decoder(buffer)
|
||||
|
||||
export const hasContent = decoder => decoder.pos !== decoder.arr.length
|
||||
|
||||
/**
|
||||
* Clone a decoder instance.
|
||||
* Optionally set a new position parameter.
|
||||
* @param {Decoder} decoder The decoder instance
|
||||
* @return {Decoder} A clone of `decoder`
|
||||
*/
|
||||
export const clone = (decoder, newPos = decoder.pos) => {
|
||||
let _decoder = createDecoder(decoder.arr.buffer)
|
||||
_decoder.pos = newPos
|
||||
return _decoder
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `len` bytes as an ArrayBuffer.
|
||||
* @param {Decoder} decoder The decoder instance
|
||||
* @param {number} len The length of bytes to read
|
||||
* @return {ArrayBuffer}
|
||||
*/
|
||||
export const readArrayBuffer = (decoder, len) => {
|
||||
const arrayBuffer = globals.createUint8ArrayFromLen(len)
|
||||
const view = globals.createUint8ArrayFromBuffer(decoder.arr.buffer, decoder.pos, len)
|
||||
arrayBuffer.set(view)
|
||||
decoder.pos += len
|
||||
return arrayBuffer.buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Read variable length payload as ArrayBuffer
|
||||
* @param {Decoder} decoder
|
||||
* @return {ArrayBuffer}
|
||||
*/
|
||||
export const readPayload = decoder => readArrayBuffer(decoder, readVarUint(decoder))
|
||||
|
||||
/**
|
||||
* Read the rest of the content as an ArrayBuffer
|
||||
* @param {Decoder} decoder
|
||||
* @return {ArrayBuffer}
|
||||
*/
|
||||
export const readTail = decoder => readArrayBuffer(decoder, decoder.arr.length - decoder.pos)
|
||||
|
||||
/**
|
||||
* Skip one byte, jump to the next position.
|
||||
* @param {Decoder} decoder The decoder instance
|
||||
* @return {number} The next position
|
||||
*/
|
||||
export const skip8 = decoder => decoder.pos++
|
||||
|
||||
/**
|
||||
* Read one byte as unsigned integer.
|
||||
* @param {Decoder} decoder The decoder instance
|
||||
* @return {number} Unsigned 8-bit integer
|
||||
*/
|
||||
export const readUint8 = decoder => decoder.arr[decoder.pos++]
|
||||
|
||||
/**
|
||||
* Read 4 bytes as unsigned integer.
|
||||
*
|
||||
* @param {Decoder} decoder
|
||||
* @return {number} An unsigned integer.
|
||||
*/
|
||||
export const readUint32 = decoder => {
|
||||
let uint =
|
||||
decoder.arr[decoder.pos] +
|
||||
(decoder.arr[decoder.pos + 1] << 8) +
|
||||
(decoder.arr[decoder.pos + 2] << 16) +
|
||||
(decoder.arr[decoder.pos + 3] << 24)
|
||||
decoder.pos += 4
|
||||
return uint
|
||||
}
|
||||
|
||||
/**
|
||||
* Look ahead without incrementing position.
|
||||
* to the next byte and read it as unsigned integer.
|
||||
*
|
||||
* @param {Decoder} decoder
|
||||
* @return {number} An unsigned integer.
|
||||
*/
|
||||
export const peekUint8 = decoder => decoder.arr[decoder.pos]
|
||||
|
||||
/**
|
||||
* Read unsigned integer (32bit) with variable length.
|
||||
* 1/8th of the storage is used as encoding overhead.
|
||||
* * numbers < 2^7 is stored in one bytlength
|
||||
* * numbers < 2^14 is stored in two bylength
|
||||
*
|
||||
* @param {Decoder} decoder
|
||||
* @return {number} An unsigned integer.length
|
||||
*/
|
||||
export const readVarUint = decoder => {
|
||||
let num = 0
|
||||
let len = 0
|
||||
while (true) {
|
||||
let r = decoder.arr[decoder.pos++]
|
||||
num = num | ((r & 0b1111111) << len)
|
||||
len += 7
|
||||
if (r < 1 << 7) {
|
||||
return num >>> 0 // return unsigned number!
|
||||
}
|
||||
if (len > 35) {
|
||||
throw new Error('Integer out of range!')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read string of variable length
|
||||
* * varUint is used to store the length of the string
|
||||
*
|
||||
* Transforming utf8 to a string is pretty expensive. The code performs 10x better
|
||||
* when String.fromCodePoint is fed with all characters as arguments.
|
||||
* But most environments have a maximum number of arguments per functions.
|
||||
* For effiency reasons we apply a maximum of 10000 characters at once.
|
||||
*
|
||||
* @param {Decoder} decoder
|
||||
* @return {String} The read String.
|
||||
*/
|
||||
export const readVarString = decoder => {
|
||||
let remainingLen = readVarUint(decoder)
|
||||
let encodedString = ''
|
||||
while (remainingLen > 0) {
|
||||
const nextLen = remainingLen < 10000 ? remainingLen : 10000
|
||||
const bytes = new Array(nextLen)
|
||||
for (let i = 0; i < nextLen; i++) {
|
||||
bytes[i] = decoder.arr[decoder.pos++]
|
||||
}
|
||||
encodedString += String.fromCodePoint.apply(null, bytes)
|
||||
remainingLen -= nextLen
|
||||
}
|
||||
return decodeURIComponent(escape(encodedString))
|
||||
}
|
||||
|
||||
/**
|
||||
* Look ahead and read varString without incrementing position
|
||||
* @param {Decoder} decoder
|
||||
* @return {string}
|
||||
*/
|
||||
export const peekVarString = decoder => {
|
||||
let pos = decoder.pos
|
||||
let s = readVarString(decoder)
|
||||
decoder.pos = pos
|
||||
return s
|
||||
}
|
||||
218
lib/encoding.js
218
lib/encoding.js
@@ -1,218 +0,0 @@
|
||||
|
||||
import * as globals from './globals.js'
|
||||
|
||||
const bits7 = 0b1111111
|
||||
const bits8 = 0b11111111
|
||||
|
||||
/**
|
||||
* A BinaryEncoder handles the encoding to an ArrayBuffer.
|
||||
*/
|
||||
export class Encoder {
|
||||
constructor () {
|
||||
this.cpos = 0
|
||||
this.cbuf = globals.createUint8ArrayFromLen(1000)
|
||||
this.bufs = []
|
||||
}
|
||||
}
|
||||
|
||||
export const createEncoder = () => new Encoder()
|
||||
|
||||
/**
|
||||
* The current length of the encoded data.
|
||||
*/
|
||||
export const length = encoder => {
|
||||
let len = encoder.cpos
|
||||
for (let i = 0; i < encoder.bufs.length; i++) {
|
||||
len += encoder.bufs[i].length
|
||||
}
|
||||
return len
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform to ArrayBuffer. TODO: rename to .toArrayBuffer
|
||||
* @param {Encoder} encoder
|
||||
* @return {ArrayBuffer} The created ArrayBuffer.
|
||||
*/
|
||||
export const toBuffer = encoder => {
|
||||
const uint8arr = globals.createUint8ArrayFromLen(length(encoder))
|
||||
let curPos = 0
|
||||
for (let i = 0; i < encoder.bufs.length; i++) {
|
||||
let d = encoder.bufs[i]
|
||||
uint8arr.set(d, curPos)
|
||||
curPos += d.length
|
||||
}
|
||||
uint8arr.set(globals.createUint8ArrayFromBuffer(encoder.cbuf.buffer, 0, encoder.cpos), curPos)
|
||||
return uint8arr.buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one byte to the encoder.
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} num The byte that is to be encoded.
|
||||
*/
|
||||
export const write = (encoder, num) => {
|
||||
if (encoder.cpos === encoder.cbuf.length) {
|
||||
encoder.bufs.push(encoder.cbuf)
|
||||
encoder.cbuf = globals.createUint8ArrayFromLen(encoder.cbuf.length * 2)
|
||||
encoder.cpos = 0
|
||||
}
|
||||
encoder.cbuf[encoder.cpos++] = num
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one byte at a specific position.
|
||||
* Position must already be written (i.e. encoder.length > pos)
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} pos Position to which to write data
|
||||
* @param {number} num Unsigned 8-bit integer
|
||||
*/
|
||||
export const set = (encoder, pos, num) => {
|
||||
let buffer = null
|
||||
// iterate all buffers and adjust position
|
||||
for (let i = 0; i < encoder.bufs.length && buffer === null; i++) {
|
||||
const b = encoder.bufs[i]
|
||||
if (pos < b.length) {
|
||||
buffer = b // found buffer
|
||||
} else {
|
||||
pos -= b.length
|
||||
}
|
||||
}
|
||||
if (buffer === null) {
|
||||
// use current buffer
|
||||
buffer = encoder.cbuf
|
||||
}
|
||||
buffer[pos] = num
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one byte as an unsigned integer.
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
export const writeUint8 = (encoder, num) => write(encoder, num & bits8)
|
||||
|
||||
/**
|
||||
* Write one byte as an unsigned Integer at a specific location.
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} pos The location where the data will be written.
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
export const setUint8 = (encoder, pos, num) => set(encoder, pos, num & bits8)
|
||||
|
||||
/**
|
||||
* Write two bytes as an unsigned integer.
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
export const writeUint16 = (encoder, num) => {
|
||||
write(encoder, num & bits8)
|
||||
write(encoder, (num >>> 8) & bits8)
|
||||
}
|
||||
/**
|
||||
* Write two bytes as an unsigned integer at a specific location.
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} pos The location where the data will be written.
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
export const setUint16 = (encoder, pos, num) => {
|
||||
set(encoder, pos, num & bits8)
|
||||
set(encoder, pos + 1, (num >>> 8) & bits8)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write two bytes as an unsigned integer
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
export const writeUint32 = (encoder, num) => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
write(encoder, num & bits8)
|
||||
num >>>= 8
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write two bytes as an unsigned integer at a specific location.
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} pos The location where the data will be written.
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
export const setUint32 = (encoder, pos, num) => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
set(encoder, pos + i, num & bits8)
|
||||
num >>>= 8
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a variable length unsigned integer.
|
||||
*
|
||||
* Encodes integers in the range from [0, 4294967295] / [0, 0xffffffff]. (max 32 bit unsigned integer)
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {number} num The number that is to be encoded.
|
||||
*/
|
||||
export const writeVarUint = (encoder, num) => {
|
||||
while (num >= 0b10000000) {
|
||||
write(encoder, 0b10000000 | (bits7 & num))
|
||||
num >>>= 7
|
||||
}
|
||||
write(encoder, bits7 & num)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a variable length string.
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {String} str The string that is to be encoded.
|
||||
*/
|
||||
export const writeVarString = (encoder, str) => {
|
||||
const encodedString = unescape(encodeURIComponent(str))
|
||||
const len = encodedString.length
|
||||
writeVarUint(encoder, len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
write(encoder, encodedString.codePointAt(i))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the content of another Encoder.
|
||||
*
|
||||
* TODO: can be improved!
|
||||
*
|
||||
* @param {Encoder} encoder The enUint8Arr
|
||||
* @param {Encoder} append The BinaryEncoder to be written.
|
||||
*/
|
||||
export const writeBinaryEncoder = (encoder, append) => writeArrayBuffer(encoder, toBuffer(append))
|
||||
|
||||
/**
|
||||
* Append an arrayBuffer to the encoder.
|
||||
*
|
||||
* @param {Encoder} encoder
|
||||
* @param {ArrayBuffer} arrayBuffer
|
||||
*/
|
||||
export const writeArrayBuffer = (encoder, arrayBuffer) => {
|
||||
const prevBufferLen = encoder.cbuf.length
|
||||
// TODO: Append to cbuf if possible
|
||||
encoder.bufs.push(globals.createUint8ArrayFromBuffer(encoder.cbuf.buffer, 0, encoder.cpos))
|
||||
encoder.bufs.push(globals.createUint8ArrayFromArrayBuffer(arrayBuffer))
|
||||
encoder.cbuf = globals.createUint8ArrayFromLen(prevBufferLen)
|
||||
encoder.cpos = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Encoder} encoder
|
||||
* @param {ArrayBuffer} arrayBuffer
|
||||
*/
|
||||
export const writePayload = (encoder, arrayBuffer) => {
|
||||
writeVarUint(encoder, arrayBuffer.byteLength)
|
||||
writeArrayBuffer(encoder, arrayBuffer)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import * as encoding from './encoding.js'
|
||||
|
||||
/**
|
||||
* Check if binary encoding is compatible with golang binary encoding - binary.PutVarUint.
|
||||
*
|
||||
* Result: is compatible up to 32 bit: [0, 4294967295] / [0, 0xffffffff]. (max 32 bit unsigned integer)
|
||||
*/
|
||||
let err = null
|
||||
try {
|
||||
const tests = [
|
||||
{ in: 0, out: [0] },
|
||||
{ in: 1, out: [1] },
|
||||
{ in: 128, out: [128, 1] },
|
||||
{ in: 200, out: [200, 1] },
|
||||
{ in: 32, out: [32] },
|
||||
{ in: 500, out: [244, 3] },
|
||||
{ in: 256, out: [128, 2] },
|
||||
{ in: 700, out: [188, 5] },
|
||||
{ in: 1024, out: [128, 8] },
|
||||
{ in: 1025, out: [129, 8] },
|
||||
{ in: 4048, out: [208, 31] },
|
||||
{ in: 5050, out: [186, 39] },
|
||||
{ in: 1000000, out: [192, 132, 61] },
|
||||
{ in: 34951959, out: [151, 166, 213, 16] },
|
||||
{ in: 2147483646, out: [254, 255, 255, 255, 7] },
|
||||
{ in: 2147483647, out: [255, 255, 255, 255, 7] },
|
||||
{ in: 2147483648, out: [128, 128, 128, 128, 8] },
|
||||
{ in: 2147483700, out: [180, 128, 128, 128, 8] },
|
||||
{ in: 4294967294, out: [254, 255, 255, 255, 15] },
|
||||
{ in: 4294967295, out: [255, 255, 255, 255, 15] }
|
||||
]
|
||||
tests.forEach(test => {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, test.in)
|
||||
const buffer = new Uint8Array(encoding.toBuffer(encoder))
|
||||
if (buffer.byteLength !== test.out.length) {
|
||||
throw new Error('Length don\'t match!')
|
||||
}
|
||||
for (let j = 0; j < buffer.length; j++) {
|
||||
if (buffer[j] !== test[1][j]) {
|
||||
throw new Error('values don\'t match!')
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
err = error
|
||||
} finally {
|
||||
console.log('YDB Client: Encoding varUint compatiblity test: ', err || 'success!')
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
export const Uint8Array_ = Uint8Array
|
||||
|
||||
/**
|
||||
* @param {Array<number>} arr
|
||||
* @return {ArrayBuffer}
|
||||
*/
|
||||
export const createArrayBufferFromArray = arr => new Uint8Array_(arr).buffer
|
||||
|
||||
export const createUint8ArrayFromLen = len => new Uint8Array_(len)
|
||||
|
||||
/**
|
||||
* Create Uint8Array with initial content from buffer
|
||||
*/
|
||||
export const createUint8ArrayFromBuffer = (buffer, byteOffset, length) => new Uint8Array_(buffer, byteOffset, length)
|
||||
|
||||
/**
|
||||
* Create Uint8Array with initial content from buffer
|
||||
*/
|
||||
export const createUint8ArrayFromArrayBuffer = arraybuffer => new Uint8Array_(arraybuffer)
|
||||
export const createArrayFromArrayBuffer = arraybuffer => Array.from(createUint8ArrayFromArrayBuffer(arraybuffer))
|
||||
|
||||
export const createPromise = f => new Promise(f)
|
||||
|
||||
export const createMap = () => new Map()
|
||||
export const createSet = () => new Set()
|
||||
|
||||
/**
|
||||
* `Promise.all` wait for all promises in the array to resolve and return the result
|
||||
* @param {Array<Promise<any>>} arrp
|
||||
* @return {any}
|
||||
*/
|
||||
export const pall = arrp => Promise.all(arrp)
|
||||
export const preject = reason => Promise.reject(reason)
|
||||
export const presolve = res => Promise.resolve(res)
|
||||
|
||||
export const until = (timeout, check) => createPromise((resolve, reject) => {
|
||||
const hasTimeout = timeout > 0
|
||||
const untilInterval = () => {
|
||||
if (check()) {
|
||||
clearInterval(intervalHandle)
|
||||
resolve()
|
||||
} else if (hasTimeout) {
|
||||
timeout -= 10
|
||||
if (timeout < 0) {
|
||||
clearInterval(intervalHandle)
|
||||
reject(error('Timeout'))
|
||||
}
|
||||
}
|
||||
}
|
||||
const intervalHandle = setInterval(untilInterval, 10)
|
||||
})
|
||||
|
||||
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
|
||||
*/
|
||||
export const wait = t => createPromise(r => setTimeout(r, t))
|
||||
159
lib/idb.js
159
lib/idb.js
@@ -1,159 +0,0 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
import * as globals from './globals.js'
|
||||
|
||||
/*
|
||||
* IDB Request to Promise transformer
|
||||
*/
|
||||
export const rtop = request => globals.createPromise((resolve, reject) => {
|
||||
request.onerror = event => reject(new Error(event.target.error))
|
||||
request.onblocked = () => location.reload()
|
||||
request.onsuccess = event => resolve(event.target.result)
|
||||
})
|
||||
|
||||
/**
|
||||
* @return {Promise<IDBDatabase>}
|
||||
*/
|
||||
export const openDB = (name, initDB) => globals.createPromise((resolve, reject) => {
|
||||
let request = indexedDB.open(name)
|
||||
/**
|
||||
* @param {any} event
|
||||
*/
|
||||
request.onupgradeneeded = event => initDB(event.target.result)
|
||||
/**
|
||||
* @param {any} event
|
||||
*/
|
||||
request.onerror = event => reject(new Error(event.target.error))
|
||||
request.onblocked = () => location.reload()
|
||||
/**
|
||||
* @param {any} event
|
||||
*/
|
||||
request.onsuccess = event => {
|
||||
const db = event.target.result
|
||||
db.onversionchange = () => { db.close() }
|
||||
addEventListener('unload', () => db.close())
|
||||
resolve(db)
|
||||
}
|
||||
})
|
||||
|
||||
export const deleteDB = name => rtop(indexedDB.deleteDatabase(name))
|
||||
|
||||
export const createStores = (db, definitions) => definitions.forEach(d =>
|
||||
db.createObjectStore.apply(db, d)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {String | number | ArrayBuffer | Date | Array } key
|
||||
* @return {Promise<ArrayBuffer>}
|
||||
*/
|
||||
export const get = (store, key) =>
|
||||
rtop(store.get(key))
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {String | number | ArrayBuffer | Date | IDBKeyRange | Array } key
|
||||
*/
|
||||
export const del = (store, key) =>
|
||||
rtop(store.delete(key))
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {String | number | ArrayBuffer | Date | boolean} item
|
||||
* @param {String | number | ArrayBuffer | Date | Array} [key]
|
||||
*/
|
||||
export const put = (store, item, key) =>
|
||||
rtop(store.put(item, key))
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {String | number | ArrayBuffer | Date | boolean} item
|
||||
* @param {String | number | ArrayBuffer | Date | Array} [key]
|
||||
* @return {Promise<ArrayBuffer>}
|
||||
*/
|
||||
export const add = (store, item, key) =>
|
||||
rtop(store.add(item, key))
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {String | number | ArrayBuffer | Date} item
|
||||
* @return {Promise<number>}
|
||||
*/
|
||||
export const addAutoKey = (store, item) =>
|
||||
rtop(store.add(item))
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {IDBKeyRange} [range]
|
||||
*/
|
||||
export const getAll = (store, range) =>
|
||||
rtop(store.getAll(range))
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {IDBKeyRange} [range]
|
||||
*/
|
||||
export const getAllKeys = (store, range) =>
|
||||
rtop(store.getAllKeys(range))
|
||||
|
||||
/**
|
||||
* @typedef KeyValuePair
|
||||
* @type {Object}
|
||||
* @property {any} k key
|
||||
* @property {any} v Value
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {IDBKeyRange} [range]
|
||||
* @return {Promise<Array<KeyValuePair>>}
|
||||
*/
|
||||
export const getAllKeysValues = (store, range) =>
|
||||
globals.pall([getAllKeys(store, range), getAll(store, range)]).then(([ks, vs]) => ks.map((k, i) => ({ k, v: vs[i] })))
|
||||
|
||||
/**
|
||||
* Iterate on keys and values
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {IDBKeyRange?} keyrange
|
||||
* @param {function(any, any)} f Return true in order to continue the cursor
|
||||
*/
|
||||
export const iterate = (store, keyrange, f) => globals.createPromise((resolve, reject) => {
|
||||
const request = store.openCursor(keyrange)
|
||||
request.onerror = reject
|
||||
/**
|
||||
* @param {any} event
|
||||
*/
|
||||
request.onsuccess = event => {
|
||||
const cursor = event.target.result
|
||||
if (cursor === null) {
|
||||
return resolve()
|
||||
}
|
||||
f(cursor.value, cursor.key)
|
||||
cursor.continue()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Iterate on the keys (no values)
|
||||
* @param {IDBObjectStore} store
|
||||
* @param {IDBKeyRange} keyrange
|
||||
* @param {function(IDBCursor)} f Call `idbcursor.continue()` to iterate further
|
||||
*/
|
||||
export const iterateKeys = (store, keyrange, f) => {
|
||||
/**
|
||||
* @param {any} event
|
||||
*/
|
||||
store.openKeyCursor(keyrange).onsuccess = event => f(event.target.result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open store from transaction
|
||||
* @param {IDBTransaction} t
|
||||
* @param {String} store
|
||||
* @returns {IDBObjectStore}
|
||||
*/
|
||||
export const getStore = (t, store) => t.objectStore(store)
|
||||
|
||||
export const createIDBKeyRangeBound = (lower, upper, lowerOpen, upperOpen) => IDBKeyRange.bound(lower, upper, lowerOpen, upperOpen)
|
||||
export const createIDBKeyRangeUpperBound = (upper, upperOpen) => IDBKeyRange.upperBound(upper, upperOpen)
|
||||
export const createIDBKeyRangeLowerBound = (lower, lowerOpen) => IDBKeyRange.lowerBound(lower, lowerOpen)
|
||||
@@ -1,34 +0,0 @@
|
||||
import * as test from './test.js'
|
||||
import * as idb from './idb.js'
|
||||
import * as logging from './logging.js'
|
||||
|
||||
const initTestDB = db => idb.createStores(db, [['test']])
|
||||
const testDBName = 'idb-test'
|
||||
|
||||
const createTransaction = db => db.transaction(['test'], 'readwrite')
|
||||
/**
|
||||
* @param {IDBTransaction} t
|
||||
* @return {IDBObjectStore}
|
||||
*/
|
||||
const getStore = t => idb.getStore(t, 'test')
|
||||
|
||||
idb.deleteDB(testDBName).then(() => idb.openDB(testDBName, initTestDB)).then(db => {
|
||||
test.run('idb iteration', async testname => {
|
||||
const t = createTransaction(db)
|
||||
await idb.put(getStore(t), 0, ['t', 0])
|
||||
await idb.put(getStore(t), 1, ['t', 1])
|
||||
const valsGetAll = await idb.getAll(getStore(t))
|
||||
if (valsGetAll.length !== 2) {
|
||||
logging.fail('getAll does not return two values')
|
||||
}
|
||||
const valsIterate = []
|
||||
const keyrange = idb.createIDBKeyRangeBound(['t', 0], ['t', 1], false, false)
|
||||
await idb.put(getStore(t), 2, ['t', 2])
|
||||
await idb.iterate(getStore(t), keyrange, (val, key) => {
|
||||
valsIterate.push(val)
|
||||
})
|
||||
if (valsIterate.length !== 2) {
|
||||
logging.fail('iterate does not return two values')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
|
||||
import * as globals from './globals.js'
|
||||
|
||||
let date = new Date().getTime()
|
||||
|
||||
const writeDate = () => {
|
||||
const oldDate = date
|
||||
date = new Date().getTime()
|
||||
return date - oldDate
|
||||
}
|
||||
|
||||
export const print = (...args) => console.log(...args)
|
||||
export const log = m => print(`%cydb-client: %c${m} %c+${writeDate()}ms`, 'color: blue;', '', 'color: blue')
|
||||
|
||||
export const fail = m => {
|
||||
throw new Error(m)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @return {string}
|
||||
*/
|
||||
export const arrayBufferToString = buffer => JSON.stringify(Array.from(globals.createUint8ArrayFromBuffer(buffer)))
|
||||
@@ -1,2 +0,0 @@
|
||||
|
||||
export const floor = Math.floor
|
||||
32
lib/mutex.js
32
lib/mutex.js
@@ -1,32 +0,0 @@
|
||||
|
||||
/**
|
||||
* Creates a mutual exclude function with the following property:
|
||||
*
|
||||
* @example
|
||||
* const mutex = createMutex()
|
||||
* mutex(function () {
|
||||
* // This function is immediately executed
|
||||
* mutex(function () {
|
||||
* // This function is never executed, as it is called with the same
|
||||
* // mutex function
|
||||
* })
|
||||
* })
|
||||
*
|
||||
* @return {Function} A mutual exclude function
|
||||
* @public
|
||||
*/
|
||||
export const createMutex = () => {
|
||||
let token = true
|
||||
return (f, g) => {
|
||||
if (token) {
|
||||
token = false
|
||||
try {
|
||||
f()
|
||||
} finally {
|
||||
token = true
|
||||
}
|
||||
} else if (g !== undefined) {
|
||||
g()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER
|
||||
export const MIN_SAFE_INTEGER = Number.MIN_SAFE_INTEGER
|
||||
@@ -1,66 +0,0 @@
|
||||
const N = 624
|
||||
const M = 397
|
||||
|
||||
function twist (u, v) {
|
||||
return ((((u & 0x80000000) | (v & 0x7fffffff)) >>> 1) ^ ((v & 1) ? 0x9908b0df : 0))
|
||||
}
|
||||
|
||||
function nextState (state) {
|
||||
let p = 0
|
||||
let j
|
||||
for (j = N - M + 1; --j; p++) {
|
||||
state[p] = state[p + M] ^ twist(state[p], state[p + 1])
|
||||
}
|
||||
for (j = M; --j; p++) {
|
||||
state[p] = state[p + M - N] ^ twist(state[p], state[p + 1])
|
||||
}
|
||||
state[p] = state[p + M - N] ^ twist(state[p], state[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a port of Shawn Cokus's implementation of the original Mersenne Twister algorithm (http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/MT2002/CODES/MTARCOK/mt19937ar-cok.c).
|
||||
* MT has a very high period of 2^19937. Though the authors of xorshift describe that a high period is not
|
||||
* very relevant (http://vigna.di.unimi.it/xorshift/). It is four times slower than xoroshiro128plus and
|
||||
* needs to recompute its state after generating 624 numbers.
|
||||
*
|
||||
* @example
|
||||
* const gen = new Mt19937(new Date().getTime())
|
||||
* console.log(gen.next())
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export default 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.
|
||||
*/
|
||||
constructor (seed) {
|
||||
this.seed = seed
|
||||
const state = new Uint32Array(N)
|
||||
state[0] = seed
|
||||
for (let i = 1; i < N; i++) {
|
||||
state[i] = (Math.imul(1812433253, (state[i - 1] ^ (state[i - 1] >>> 30))) + i) & 0xFFFFFFFF
|
||||
}
|
||||
this._state = state
|
||||
this._i = 0
|
||||
nextState(this._state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random signed integer.
|
||||
*
|
||||
* @return {Number} A 32 bit signed integer.
|
||||
*/
|
||||
next () {
|
||||
if (this._i === N) {
|
||||
// need to compute a new state
|
||||
nextState(this._state)
|
||||
this._i = 0
|
||||
}
|
||||
let y = this._state[this._i++]
|
||||
y ^= (y >>> 11)
|
||||
y ^= (y << 7) & 0x9d2c5680
|
||||
y ^= (y << 15) & 0xefc60000
|
||||
y ^= (y >>> 18)
|
||||
return y
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
|
||||
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) {
|
||||
console.log('== ' + name + ' ==')
|
||||
const gen = new Gen(1234)
|
||||
let head = 0
|
||||
let tails = 0
|
||||
const date = time.getUnixTime()
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.height = DIAMETER
|
||||
canvas.width = DIAMETER
|
||||
const ctx = canvas.getContext('2d')
|
||||
const vals = new Set()
|
||||
ctx.fillStyle = 'blue'
|
||||
for (let i = 0; i < NUMBERS; i++) {
|
||||
const n = gen.next() & 0xFFFFFF
|
||||
const x = (gen.next() >>> 0) % DIAMETER
|
||||
const y = (gen.next() >>> 0) % DIAMETER
|
||||
ctx.fillRect(x, y, 1, 2)
|
||||
if ((n & 1) === 1) {
|
||||
head++
|
||||
} else {
|
||||
tails++
|
||||
}
|
||||
if (vals.has(n)) {
|
||||
console.warn(`The generator generated a duplicate`)
|
||||
}
|
||||
vals.add(n)
|
||||
}
|
||||
console.log('time: ', time.getUnixTime() - date)
|
||||
console.log('head:', head, 'tails:', tails)
|
||||
console.log('%c ', `font-size: 200px; background: url(${canvas.toDataURL()}) no-repeat;`)
|
||||
const h1 = document.createElement('h1')
|
||||
h1.insertBefore(document.createTextNode(name), null)
|
||||
document.body.insertBefore(h1, null)
|
||||
document.body.appendChild(canvas)
|
||||
}
|
||||
|
||||
runPRNG('mt19937', Mt19937)
|
||||
runPRNG('xoroshiro128plus', Xoroshiro128plus)
|
||||
runPRNG('xorshift32', Xorshift32)
|
||||
@@ -1,5 +0,0 @@
|
||||
# Pseudo Random Number Generators (PRNG)
|
||||
|
||||
Given a seed a PRNG generates a sequence of numbers that cannot be reasonably predicted. Two PRNGs must generate the same random sequence of numbers if given the same seed.
|
||||
|
||||
TODO: explain what POINT is
|
||||
@@ -1,98 +0,0 @@
|
||||
|
||||
import Xorshift32 from './Xorshift32.js'
|
||||
|
||||
/**
|
||||
* This is a variant of xoroshiro128plus - the fastest full-period generator passing BigCrush without systematic failures.
|
||||
*
|
||||
* This implementation follows the idea of the original xoroshiro128plus implementation,
|
||||
* but is optimized for the JavaScript runtime. I.e.
|
||||
* * The operations are performed on 32bit integers (the original implementation works with 64bit values).
|
||||
* * The initial 128bit state is computed based on a 32bit seed and Xorshift32.
|
||||
* * This implementation returns two 32bit values based on the 64bit value that is computed by xoroshiro128plus.
|
||||
* Caution: The last addition step works slightly different than in the original implementation - the add carry of the
|
||||
* first 32bit addition is not carried over to the last 32bit.
|
||||
*
|
||||
* [Reference implementation](http://vigna.di.unimi.it/xorshift/xoroshiro128plus.c)
|
||||
*/
|
||||
export default class Xoroshiro128plus {
|
||||
constructor (seed) {
|
||||
this.seed = seed
|
||||
// This is a variant of Xoroshiro128plus to fill the initial state
|
||||
const xorshift32 = new Xorshift32(seed)
|
||||
this.state = new Uint32Array(4)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
this.state[i] = xorshift32.next()
|
||||
}
|
||||
this._fresh = true
|
||||
}
|
||||
next () {
|
||||
const state = this.state
|
||||
if (this._fresh) {
|
||||
this._fresh = false
|
||||
return (state[0] + state[2]) & 0xFFFFFFFF
|
||||
} else {
|
||||
this._fresh = true
|
||||
const s0 = state[0]
|
||||
const s1 = state[1]
|
||||
const s2 = state[2] ^ s0
|
||||
const s3 = state[3] ^ s1
|
||||
// function js_rotl (x, k) {
|
||||
// k = k - 32
|
||||
// const x1 = x[0]
|
||||
// const x2 = x[1]
|
||||
// x[0] = x2 << k | x1 >>> (32 - k)
|
||||
// x[1] = x1 << k | x2 >>> (32 - k)
|
||||
// }
|
||||
// rotl(s0, 55) // k = 23 = 55 - 32; j = 9 = 32 - 23
|
||||
state[0] = (s1 << 23 | s0 >>> 9) ^ s2 ^ (s2 << 14 | s3 >>> 18)
|
||||
state[1] = (s0 << 23 | s1 >>> 9) ^ s3 ^ (s3 << 14)
|
||||
// rol(s1, 36) // k = 4 = 36 - 32; j = 23 = 32 - 9
|
||||
state[2] = s3 << 4 | s2 >>> 28
|
||||
state[3] = s2 << 4 | s3 >>> 28
|
||||
return (state[1] + state[3]) & 0xFFFFFFFF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
// reference implementation
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
uint64_t s[2];
|
||||
|
||||
static inline uint64_t rotl(const uint64_t x, int k) {
|
||||
return (x << k) | (x >> (64 - k));
|
||||
}
|
||||
|
||||
uint64_t next(void) {
|
||||
const uint64_t s0 = s[0];
|
||||
uint64_t s1 = s[1];
|
||||
s1 ^= s0;
|
||||
s[0] = rotl(s0, 55) ^ s1 ^ (s1 << 14); // a, b
|
||||
s[1] = rotl(s1, 36); // c
|
||||
return (s[0] + s[1]) & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
int i;
|
||||
s[0] = 1111 | (1337ul << 32);
|
||||
s[1] = 1234 | (9999ul << 32);
|
||||
|
||||
printf("1000 outputs of genrand_int31()\n");
|
||||
for (i=0; i<100; i++) {
|
||||
printf("%10lu ", i);
|
||||
printf("%10lu ", next());
|
||||
printf("- %10lu ", s[0] >> 32);
|
||||
printf("%10lu ", (s[0] << 32) >> 32);
|
||||
printf("%10lu ", s[1] >> 32);
|
||||
printf("%10lu ", (s[1] << 32) >> 32);
|
||||
printf("\n");
|
||||
// if (i%5==4) printf("\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
*/
|
||||
@@ -1,26 +0,0 @@
|
||||
|
||||
/**
|
||||
* Xorshift32 is a very simple but elegang PRNG with a period of `2^32-1`.
|
||||
*/
|
||||
export default 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.
|
||||
*/
|
||||
constructor (seed) {
|
||||
this.seed = seed
|
||||
this._state = seed
|
||||
}
|
||||
/**
|
||||
* Generate a random signed integer.
|
||||
*
|
||||
* @return {Number} A 32 bit signed integer.
|
||||
*/
|
||||
next () {
|
||||
let x = this._state
|
||||
x ^= x << 13
|
||||
x ^= x >> 17
|
||||
x ^= x << 5
|
||||
this._state = x
|
||||
return x
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
|
||||
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'
|
||||
|
||||
/**
|
||||
* Description of the function
|
||||
* @callback generatorNext
|
||||
* @return {number} A 32bit integer
|
||||
*/
|
||||
|
||||
/**
|
||||
* A random type generator.
|
||||
*
|
||||
* @typedef {Object} PRNG
|
||||
* @property {generatorNext} next Generate new number
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a Xoroshiro128plus Pseudo-Random-Number-Generator.
|
||||
* This is the fastest full-period generator passing BigCrush without systematic failures.
|
||||
* But there are more PRNGs available in ./PRNG/.
|
||||
*
|
||||
* @param {number} seed A positive 32bit integer. Do not use negative numbers.
|
||||
* @return {PRNG}
|
||||
*/
|
||||
export const createPRNG = seed => new DefaultPRNG(Math.floor(seed < 1 ? seed * binary.BITS32 : seed))
|
||||
|
||||
/**
|
||||
* Generates a single random bool.
|
||||
*
|
||||
* @param {PRNG} gen A random number generator.
|
||||
* @return {Boolean} A random boolean
|
||||
*/
|
||||
export const bool = gen => (gen.next() & 2) === 2 // brackets are non-optional!
|
||||
|
||||
/**
|
||||
* Generates a random integer with 53 bit resolution.
|
||||
*
|
||||
* @param {PRNG} gen A random number generator.
|
||||
* @param {Number} [min = MIN_SAFE_INTEGER] The lower bound of the allowed return values (inclusive).
|
||||
* @param {Number} [max = MAX_SAFE_INTEGER] The upper bound of the allowed return values (inclusive).
|
||||
* @return {Number} A random integer on [min, max]
|
||||
*/
|
||||
export const int53 = (gen, min = MIN_SAFE_INTEGER, max = MAX_SAFE_INTEGER) => math.floor(real53(gen) * (max + 1 - min) + min)
|
||||
|
||||
/**
|
||||
* Generates a random integer with 32 bit resolution.
|
||||
*
|
||||
* @param {PRNG} gen A random number generator.
|
||||
* @param {Number} [min = MIN_SAFE_INTEGER] The lower bound of the allowed return values (inclusive).
|
||||
* @param {Number} [max = MAX_SAFE_INTEGER] The upper bound of the allowed return values (inclusive).
|
||||
* @return {Number} A random integer on [min, max]
|
||||
*/
|
||||
export const int32 = (gen, min = MIN_SAFE_INTEGER, max = MAX_SAFE_INTEGER) => min + ((gen.next() >>> 0) % (max + 1 - min))
|
||||
|
||||
/**
|
||||
* Generates a random real on [0, 1) with 32 bit resolution.
|
||||
*
|
||||
* @param {PRNG} gen A random number generator.
|
||||
* @return {Number} A random real number on [0, 1).
|
||||
*/
|
||||
export const real32 = gen => (gen.next() >>> 0) / binary.BITS32
|
||||
|
||||
/**
|
||||
* Generates a random real on [0, 1) with 53 bit resolution.
|
||||
*
|
||||
* @param {PRNG} gen A random number generator.
|
||||
* @return {Number} A random real number on [0, 1).
|
||||
*/
|
||||
export const real53 = gen => (((gen.next() >>> 5) * binary.BIT26) + (gen.next() >>> 6)) / MAX_SAFE_INTEGER
|
||||
|
||||
/**
|
||||
* Generates a random character from char code 32 - 126. I.e. Characters, Numbers, special characters, and Space:
|
||||
*
|
||||
* (Space)!"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[/]^_`abcdefghijklmnopqrstuvwxyz{|}~
|
||||
*/
|
||||
export const char = gen => fromCharCode(int32(gen, 32, 126))
|
||||
|
||||
/**
|
||||
* @param {PRNG} gen
|
||||
* @return {string} A single letter (a-z)
|
||||
*/
|
||||
export const letter = gen => fromCharCode(int32(gen, 97, 122))
|
||||
|
||||
/**
|
||||
* @param {PRNG} gen
|
||||
* @return {string} A random word without spaces consisting of letters (a-z)
|
||||
*/
|
||||
export const word = gen => {
|
||||
const len = int32(gen, 0, 20)
|
||||
let str = ''
|
||||
for (let i = 0; i < len; i++) {
|
||||
str += letter(gen)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: this function produces invalid runes. Does not cover all of utf16!!
|
||||
*/
|
||||
export const utf16Rune = gen => {
|
||||
const codepoint = int32(gen, 0, 256)
|
||||
return fromCodePoint(codepoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PRNG} gen
|
||||
* @param {number} [maxlen = 20]
|
||||
*/
|
||||
export const utf16String = (gen, maxlen = 20) => {
|
||||
const len = int32(gen, 0, maxlen)
|
||||
let str = ''
|
||||
for (let i = 0; i < len; i++) {
|
||||
str += utf16Rune(gen)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns one element of a given array.
|
||||
*
|
||||
* @param {PRNG} gen A random number generator.
|
||||
* @param {Array<T>} array Non empty Array of possible values.
|
||||
* @return {T} One of the values of the supplied Array.
|
||||
* @template T
|
||||
*/
|
||||
export const oneOf = (gen, array) => array[int32(gen, 0, array.length - 1)]
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
*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'
|
||||
|
||||
function init (Gen) {
|
||||
return {
|
||||
gen: new Gen(1234)
|
||||
}
|
||||
}
|
||||
|
||||
const PRNGs = [
|
||||
{ name: 'Xoroshiro128plus', Gen: Xoroshiro128plus },
|
||||
{ name: 'Xorshift32', Gen: Xorshift32 },
|
||||
{ name: 'MT19937', Gen: MT19937 }
|
||||
]
|
||||
|
||||
const ITERATONS = 1000000
|
||||
|
||||
for (const PRNG of PRNGs) {
|
||||
const prefix = rt.orange`${PRNG.name}:`
|
||||
|
||||
test(rt.plain`${prefix} generateBool`, function generateBoolTest (t) {
|
||||
const { gen } = init(PRNG.Gen)
|
||||
let head = 0
|
||||
let tail = 0
|
||||
let b
|
||||
let i
|
||||
|
||||
for (i = 0; i < ITERATONS; i++) {
|
||||
b = generateBool(gen)
|
||||
if (b) {
|
||||
head++
|
||||
} else {
|
||||
tail++
|
||||
}
|
||||
}
|
||||
t.log(`Generated ${head} heads and ${tail} tails.`)
|
||||
t.assert(tail >= Math.floor(ITERATONS * 0.49), 'Generated enough tails.')
|
||||
t.assert(head >= Math.floor(ITERATONS * 0.49), 'Generated enough heads.')
|
||||
})
|
||||
|
||||
test(rt.plain`${prefix} generateInt integers average correctly`, function averageIntTest (t) {
|
||||
const { gen } = init(PRNG.Gen)
|
||||
let count = 0
|
||||
let i
|
||||
|
||||
for (i = 0; i < ITERATONS; i++) {
|
||||
count += generateInt(gen, 0, 100)
|
||||
}
|
||||
const average = count / ITERATONS
|
||||
const expectedAverage = 100 / 2
|
||||
t.log(`Average is: ${average}. Expected average is ${expectedAverage}.`)
|
||||
t.assert(Math.abs(average - expectedAverage) <= 1, 'Expected average is at most 1 off.')
|
||||
})
|
||||
|
||||
test(rt.plain`${prefix} generateInt32 generates integer with 32 bits`, function generateLargeIntegers (t) {
|
||||
const { gen } = init(PRNG.Gen)
|
||||
let num = 0
|
||||
let i
|
||||
let newNum
|
||||
for (i = 0; i < ITERATONS; i++) {
|
||||
newNum = generateInt32(gen, 0, MAX_SAFE_INTEGER)
|
||||
if (newNum > num) {
|
||||
num = newNum
|
||||
}
|
||||
}
|
||||
t.log(`Largest number generated is ${num} (0b${num.toString(2)})`)
|
||||
t.assert(num > (BIT32 >>> 0), 'Largest number is 32 bits long.')
|
||||
})
|
||||
|
||||
test(rt.plain`${prefix} generateReal has 53 bit resolution`, function real53bitResolution (t) {
|
||||
const { gen } = init(PRNG.Gen)
|
||||
let num = 0
|
||||
let i
|
||||
let newNum
|
||||
for (i = 0; i < ITERATONS; i++) {
|
||||
newNum = generateReal(gen) * MAX_SAFE_INTEGER
|
||||
if (newNum > num) {
|
||||
num = newNum
|
||||
}
|
||||
}
|
||||
t.log(`Largest number generated is ${num}.`)
|
||||
t.assert((MAX_SAFE_INTEGER - num) / MAX_SAFE_INTEGER < 0.01, 'Largest number is close to MAX_SAFE_INTEGER (at most 1% off).')
|
||||
})
|
||||
|
||||
test(rt.plain`${prefix} generateChar generates all described characters`, function real53bitResolution (t) {
|
||||
const { gen } = init(PRNG.Gen)
|
||||
const charSet = new Set()
|
||||
const chars = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[/]^_`abcdefghijklmnopqrstuvwxyz{|}~"'
|
||||
let i
|
||||
let char
|
||||
for (i = chars.length - 1; i >= 0; i--) {
|
||||
charSet.add(chars[i])
|
||||
}
|
||||
for (i = 0; i < ITERATONS; i++) {
|
||||
char = generateChar(gen)
|
||||
charSet.delete(char)
|
||||
}
|
||||
t.log(`Charactes missing: ${charSet.size} - generating all of "${chars}"`)
|
||||
t.assert(charSet.size === 0, 'Generated all documented characters.')
|
||||
})
|
||||
}
|
||||
*/
|
||||
@@ -1,47 +0,0 @@
|
||||
|
||||
/**
|
||||
* A SimpleDiff describes a change on a String.
|
||||
*
|
||||
* @example
|
||||
* console.log(a) // the old value
|
||||
* console.log(b) // the updated value
|
||||
* // Apply changes of diff (pseudocode)
|
||||
* a.remove(diff.pos, diff.remove) // Remove `diff.remove` characters
|
||||
* a.insert(diff.pos, diff.insert) // Insert `diff.insert`
|
||||
* a === b // values match
|
||||
*
|
||||
* @typedef {Object} SimpleDiff
|
||||
* @property {Number} pos The index where changes were applied
|
||||
* @property {Number} remove The number of characters to delete starting
|
||||
* at `index`.
|
||||
* @property {String} insert The new text to insert at `index` after applying
|
||||
* `delete`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a diff between two strings. This diff implementation is highly
|
||||
* efficient, but not very sophisticated.
|
||||
*
|
||||
* @public
|
||||
* @param {String} a The old version of the string
|
||||
* @param {String} b The updated version of the string
|
||||
* @return {SimpleDiff} The diff description.
|
||||
*/
|
||||
export default function 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]) {
|
||||
left++
|
||||
}
|
||||
if (left !== a.length || left !== b.length) {
|
||||
// Only check right if a !== b
|
||||
while (right + left < a.length && right + left < b.length && a[a.length - right - 1] === b[b.length - right - 1]) {
|
||||
right++
|
||||
}
|
||||
}
|
||||
return {
|
||||
pos: left, // TODO: rename to index (also in type above)
|
||||
remove: a.length - left - right,
|
||||
insert: b.slice(left, b.length - right)
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const fromCharCode = String.fromCharCode
|
||||
export const fromCodePoint = String.fromCodePoint
|
||||
33
lib/test.js
33
lib/test.js
@@ -1,33 +0,0 @@
|
||||
import * as logging from './logging.js'
|
||||
import simpleDiff from './simpleDiff.js'
|
||||
|
||||
export const run = async (name, f) => {
|
||||
console.log(`%cStart:%c ${name}`, 'color:blue;', '')
|
||||
const start = new Date()
|
||||
try {
|
||||
await f(name)
|
||||
} catch (e) {
|
||||
logging.print(`%cFailure:%c ${name} in %c${new Date().getTime() - start.getTime()}ms`, 'color:red;font-weight:bold', '', 'color:grey')
|
||||
throw e
|
||||
}
|
||||
logging.print(`%cSuccess:%c ${name} in %c${new Date().getTime() - start.getTime()}ms`, 'color:green;font-weight:bold', '', 'color:grey')
|
||||
}
|
||||
|
||||
export const compareArrays = (as, bs) => {
|
||||
if (as.length !== bs.length) {
|
||||
return false
|
||||
}
|
||||
for (let i = 0; i < as.length; i++) {
|
||||
if (as[i] !== bs[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const compareStrings = (a, b) => {
|
||||
if (a !== b) {
|
||||
const diff = simpleDiff(a, b)
|
||||
logging.print(`%c${a.slice(0, diff.pos)}%c${a.slice(diff.pos, diff.remove)}%c${diff.insert}%c${a.slice(diff.pos + diff.remove)}`, 'color:grey', 'color:red', 'color:green', 'color:grey')
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
|
||||
export const getDate = () => new Date()
|
||||
export const getUnixTime = () => getDate().getTime()
|
||||
5469
package-lock.json
generated
5469
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
89
package.json
89
package.json
@@ -1,35 +1,41 @@
|
||||
{
|
||||
"name": "yjs",
|
||||
"version": "13.0.0-71",
|
||||
"description": "A framework for real-time p2p shared editing on any data",
|
||||
"main": "./build/node/index.js",
|
||||
"module": "./src/index.js",
|
||||
"version": "13.0.0-78",
|
||||
"description": "A ",
|
||||
"main": "./dist/yjs.js",
|
||||
"module": "./dist/yjs.mjs'",
|
||||
"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",
|
||||
"test": "npm run dist && PRODUCTION=1 node ./dist/tests.js --repitition-time 50 --production",
|
||||
"test-exhaustive": "npm run lint && npm run dist && node ./dist/tests.js --repitition-time 10000",
|
||||
"dist": "rm -rf dist examples/build && rollup -c",
|
||||
"watch": "rollup -wc",
|
||||
"lint": "standard && tsc",
|
||||
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.v13.md --package ./package.json || true",
|
||||
"serve-docs": "npm run docs && serve ./docs/",
|
||||
"dist": "rollup -c rollup.browser.js; rollup -c rollup.node.js",
|
||||
"watch": "concurrently 'rollup -wc rollup.browser.js' 'rollup -wc rollup.node.js'",
|
||||
"postversion": "npm run dist"
|
||||
"postversion": "npm run lint && PRODUCTION=1 npm run dist && node ./dist/tests.js --repitition-time 1000",
|
||||
"debug": "concurrently 'live-server --port=3443 --entry-file=test.html' 'npm run watch'",
|
||||
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.js",
|
||||
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.js"
|
||||
},
|
||||
"files": [
|
||||
"src/*",
|
||||
".esdoc.json",
|
||||
"dist/*",
|
||||
"examples/*",
|
||||
"docs/*",
|
||||
"build/*",
|
||||
"lib/*",
|
||||
"provider/*",
|
||||
"bindings/*"
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"bin": {
|
||||
"y-websockets": "provider/websocket/server.js"
|
||||
"dictionaries": {
|
||||
"doc": "docs",
|
||||
"example": "examples",
|
||||
"test": "tests"
|
||||
},
|
||||
"standard": {
|
||||
"ignore": [
|
||||
"/y.js",
|
||||
"/y.js.map"
|
||||
"/dist",
|
||||
"/node_modules",
|
||||
"/docs",
|
||||
"/examples/build"
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
@@ -37,13 +43,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",
|
||||
@@ -52,34 +52,29 @@
|
||||
"url": "https://github.com/y-js/yjs/issues"
|
||||
},
|
||||
"homepage": "http://y-js.org",
|
||||
"dependencies": {
|
||||
"lib0": "0.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-plugin-external-helpers": "^6.22.0",
|
||||
"babel-plugin-transform-regenerator": "^6.26.0",
|
||||
"babel-plugin-transform-runtime": "^6.23.0",
|
||||
"babel-preset-latest": "^6.24.1",
|
||||
"codemirror": "^5.42.0",
|
||||
"concurrently": "^3.6.1",
|
||||
"cutest": "^0.1.9",
|
||||
"esdoc": "^1.1.0",
|
||||
"esdoc-standard-plugin": "^1.0.0",
|
||||
"jsdoc": "^3.5.5",
|
||||
"live-server": "^1.2.1",
|
||||
"prosemirror-example-setup": "^1.0.1",
|
||||
"prosemirror-schema-basic": "^1.0.0",
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-view": "^1.6.5",
|
||||
"quill": "^1.3.6",
|
||||
"quill-cursors": "^1.0.3",
|
||||
"rollup": "^0.58.2",
|
||||
"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-regenerator-runtime": "^6.23.1",
|
||||
"rollup-watch": "^3.2.2",
|
||||
"standard": "^11.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^6.1.0"
|
||||
"rollup": "^1.1.2",
|
||||
"rollup-cli": "^1.0.9",
|
||||
"rollup-plugin-commonjs": "^9.2.0",
|
||||
"rollup-plugin-node-resolve": "^4.0.0",
|
||||
"rollup-plugin-terser": "^4.0.4",
|
||||
"standard": "^11.0.1",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
"typescript": "^3.3.3333"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
/* eslint-env browser */
|
||||
|
||||
import * as Y from '../../src/index.js'
|
||||
export * from '../../src/index.js'
|
||||
|
||||
const messageSync = 0
|
||||
const messageAwareness = 1
|
||||
|
||||
const reconnectTimeout = 100
|
||||
|
||||
const setupWS = (doc, url) => {
|
||||
const websocket = new WebSocket(url)
|
||||
websocket.binaryType = 'arraybuffer'
|
||||
doc.ws = websocket
|
||||
websocket.onmessage = event => {
|
||||
const decoder = Y.createDecoder(event.data)
|
||||
const encoder = Y.createEncoder()
|
||||
const messageType = Y.readVarUint(decoder)
|
||||
switch (messageType) {
|
||||
case messageSync:
|
||||
Y.writeVarUint(encoder, messageSync)
|
||||
doc.mux(() =>
|
||||
Y.readSyncMessage(decoder, encoder, doc)
|
||||
)
|
||||
break
|
||||
case messageAwareness:
|
||||
Y.readAwarenessMessage(decoder, doc)
|
||||
break
|
||||
}
|
||||
if (Y.length(encoder) > 1) {
|
||||
websocket.send(Y.toBuffer(encoder))
|
||||
}
|
||||
}
|
||||
websocket.onclose = () => {
|
||||
doc.ws = null
|
||||
doc.wsconnected = false
|
||||
doc.emit('status', {
|
||||
status: 'connected'
|
||||
})
|
||||
setTimeout(setupWS, reconnectTimeout, doc, url)
|
||||
}
|
||||
websocket.onopen = () => {
|
||||
doc.wsconnected = true
|
||||
doc.emit('status', {
|
||||
status: 'disconnected'
|
||||
})
|
||||
// always send sync step 1 when connected
|
||||
const encoder = Y.createEncoder()
|
||||
Y.writeVarUint(encoder, messageSync)
|
||||
Y.writeSyncStep1(encoder, doc)
|
||||
websocket.send(Y.toBuffer(encoder))
|
||||
// force send stored awareness info
|
||||
doc.setAwarenessField(null, null)
|
||||
}
|
||||
}
|
||||
|
||||
const broadcastUpdate = (y, transaction) => {
|
||||
if (y.wsconnected && transaction.encodedStructsLen > 0) {
|
||||
y.mux(() => {
|
||||
const encoder = Y.createEncoder()
|
||||
Y.writeVarUint(encoder, messageSync)
|
||||
Y.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs)
|
||||
y.ws.send(Y.toBuffer(encoder))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class WebsocketsSharedDocument extends Y.Y {
|
||||
constructor (url) {
|
||||
super()
|
||||
this.wsconnected = false
|
||||
this.mux = Y.createMutex()
|
||||
this.ws = null
|
||||
this._localAwarenessState = {}
|
||||
this.awareness = new Map()
|
||||
setupWS(this, url)
|
||||
this.on('afterTransaction', broadcastUpdate)
|
||||
}
|
||||
getLocalAwarenessInfo () {
|
||||
return this._localAwarenessState
|
||||
}
|
||||
getAwarenessInfo () {
|
||||
return this.awareness
|
||||
}
|
||||
setAwarenessField (field, value) {
|
||||
if (field !== null) {
|
||||
this._localAwarenessState[field] = value
|
||||
}
|
||||
if (this.wsconnected) {
|
||||
const encoder = Y.createEncoder()
|
||||
Y.writeVarUint(encoder, messageAwareness)
|
||||
Y.writeUsersStateChange(encoder, [{ userID: this.userID, state: this._localAwarenessState }])
|
||||
this.ws.send(Y.toBuffer(encoder))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class WebsocketProvider {
|
||||
constructor (url) {
|
||||
// ensure that url is always ends with /
|
||||
while (url[url.length - 1] === '/') {
|
||||
url = url.slice(0, url.length - 1)
|
||||
}
|
||||
this.url = url + '/'
|
||||
/**
|
||||
* @type {Map<string, WebsocketsSharedDocument>}
|
||||
*/
|
||||
this.docs = new Map()
|
||||
}
|
||||
/**
|
||||
* @param {string} name
|
||||
* @return {WebsocketsSharedDocument}
|
||||
*/
|
||||
get (name) {
|
||||
let doc = this.docs.get(name)
|
||||
if (doc === undefined) {
|
||||
doc = new WebsocketsSharedDocument(this.url + name)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
const Y = require('../../build/node/index.js')
|
||||
const WebSocket = require('ws')
|
||||
const wss = new WebSocket.Server({ port: 1234 })
|
||||
const docs = new Map()
|
||||
|
||||
const messageSync = 0
|
||||
const messageAwareness = 1
|
||||
|
||||
const afterTransaction = (doc, transaction) => {
|
||||
if (transaction.encodedStructsLen > 0) {
|
||||
const encoder = Y.createEncoder()
|
||||
Y.writeVarUint(encoder, messageSync)
|
||||
Y.writeUpdate(encoder, transaction.encodedStructsLen, transaction.encodedStructs)
|
||||
const message = Y.toBuffer(encoder)
|
||||
doc.conns.forEach((_, conn) => conn.send(message))
|
||||
}
|
||||
}
|
||||
|
||||
class WSSharedDoc extends Y.Y {
|
||||
constructor () {
|
||||
super()
|
||||
this.mux = Y.createMutex()
|
||||
/**
|
||||
* Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn is closed
|
||||
* @type {Map<Object, Set<number>>}
|
||||
*/
|
||||
this.conns = new Map()
|
||||
this.awareness = new Map()
|
||||
this.on('afterTransaction', afterTransaction)
|
||||
}
|
||||
}
|
||||
|
||||
const messageListener = (conn, doc, message) => {
|
||||
const encoder = Y.createEncoder()
|
||||
const decoder = Y.createDecoder(message)
|
||||
const messageType = Y.readVarUint(decoder)
|
||||
switch (messageType) {
|
||||
case messageSync:
|
||||
Y.writeVarUint(encoder, messageSync)
|
||||
Y.readSyncMessage(decoder, encoder, doc)
|
||||
if (Y.length(encoder) > 1) {
|
||||
conn.send(Y.toBuffer(encoder))
|
||||
}
|
||||
break
|
||||
case messageAwareness: {
|
||||
Y.writeVarUint(encoder, messageAwareness)
|
||||
const updates = Y.forwardAwarenessMessage(decoder, encoder)
|
||||
updates.forEach(update => {
|
||||
doc.awareness.set(update.userID, update.state)
|
||||
doc.conns.get(conn).add(update.userID)
|
||||
})
|
||||
const buff = Y.toBuffer(encoder)
|
||||
doc.conns.forEach((_, c) => {
|
||||
c.send(buff)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setupConnection = (conn, req) => {
|
||||
conn.binaryType = 'arraybuffer'
|
||||
// get doc, create if it does not exist yet
|
||||
let doc = docs.get(req.url.slice(1))
|
||||
if (doc === undefined) {
|
||||
doc = new WSSharedDoc()
|
||||
docs.set(req.url.slice(1), doc)
|
||||
}
|
||||
doc.conns.set(conn, new Set())
|
||||
// listen and reply to events
|
||||
conn.on('message', message => messageListener(conn, doc, message))
|
||||
conn.on('close', () => {
|
||||
const controlledIds = doc.conns.get(conn)
|
||||
doc.conns.delete(conn)
|
||||
const encoder = Y.createEncoder()
|
||||
Y.writeVarUint(encoder, messageAwareness)
|
||||
Y.writeUsersStateChange(encoder, Array.from(controlledIds).map(userID => {
|
||||
doc.awareness.delete(userID)
|
||||
return { userID, state: null }
|
||||
}))
|
||||
const buf = Y.toBuffer(encoder)
|
||||
doc.conns.forEach((_, conn) => conn.send(buf))
|
||||
})
|
||||
// send sync step 1
|
||||
const encoder = Y.createEncoder()
|
||||
Y.writeVarUint(encoder, messageSync)
|
||||
Y.writeSyncStep1(encoder, doc)
|
||||
conn.send(Y.toBuffer(encoder))
|
||||
if (doc.awareness.size > 0) {
|
||||
const encoder = Y.createEncoder()
|
||||
const userStates = []
|
||||
doc.awareness.forEach((state, userID) => {
|
||||
userStates.push({ state, userID })
|
||||
})
|
||||
Y.writeVarUint(encoder, messageAwareness)
|
||||
Y.writeUsersStateChange(encoder, userStates)
|
||||
conn.send(Y.toBuffer(encoder))
|
||||
}
|
||||
}
|
||||
|
||||
wss.on('connection', setupConnection)
|
||||
@@ -1,31 +0,0 @@
|
||||
import babel from 'rollup-plugin-babel'
|
||||
import uglify from 'rollup-plugin-uglify'
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
var pkg = require('./package.json')
|
||||
|
||||
export default {
|
||||
input: 'src/index.js',
|
||||
name: 'Y',
|
||||
sourcemap: true,
|
||||
output: {
|
||||
file: 'build/umd/index.js',
|
||||
format: 'umd'
|
||||
},
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
main: true,
|
||||
module: true,
|
||||
browser: true
|
||||
}),
|
||||
commonjs(),
|
||||
babel(),
|
||||
],
|
||||
banner: `
|
||||
/**
|
||||
* ${pkg.name} - ${pkg.description}
|
||||
* @version v${pkg.version}
|
||||
* @license ${pkg.license}
|
||||
*/
|
||||
`
|
||||
}
|
||||
105
rollup.config.js
Normal file
105
rollup.config.js
Normal file
@@ -0,0 +1,105 @@
|
||||
import nodeResolve from 'rollup-plugin-node-resolve'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
import { terser } from 'rollup-plugin-terser'
|
||||
|
||||
const customModules = new Set([
|
||||
'y-websocket',
|
||||
'y-codemirror',
|
||||
'y-ace',
|
||||
'y-textarea',
|
||||
'y-quill',
|
||||
'y-dom',
|
||||
'y-prosemirror'
|
||||
])
|
||||
/**
|
||||
* @type {Set<any>}
|
||||
*/
|
||||
const customLibModules = new Set([
|
||||
'lib0',
|
||||
'y-protocols'
|
||||
])
|
||||
const debugResolve = {
|
||||
resolveId (importee) {
|
||||
if (importee === 'yjs') {
|
||||
return `${process.cwd()}/src/index.js`
|
||||
}
|
||||
if (customModules.has(importee.split('/')[0])) {
|
||||
return `${process.cwd()}/../${importee}/src/${importee}.js`
|
||||
}
|
||||
if (customLibModules.has(importee.split('/')[0])) {
|
||||
return `${process.cwd()}/../${importee}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const minificationPlugins = process.env.PRODUCTION ? [terser({
|
||||
module: true,
|
||||
compress: {
|
||||
hoist_vars: true,
|
||||
module: true,
|
||||
passes: 5,
|
||||
pure_getters: true,
|
||||
unsafe_comps: true,
|
||||
unsafe_undefined: true
|
||||
},
|
||||
mangle: {
|
||||
toplevel: true
|
||||
}
|
||||
})] : []
|
||||
|
||||
export default [{
|
||||
input: './src/index.js',
|
||||
output: [{
|
||||
name: 'Y',
|
||||
file: 'dist/yjs.js',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
paths: path => {
|
||||
if (/^lib0\//.test(path)) {
|
||||
return `lib0/dist/${path.slice(5)}`
|
||||
}
|
||||
return path
|
||||
}
|
||||
}, {
|
||||
name: 'Y',
|
||||
file: 'dist/yjs.mjs',
|
||||
format: 'es',
|
||||
sourcemap: true
|
||||
}],
|
||||
external: id => /^lib0\//.test(id)
|
||||
}, {
|
||||
input: './tests/index.js',
|
||||
output: {
|
||||
name: 'test',
|
||||
file: 'dist/tests.js',
|
||||
format: 'iife',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
sourcemap: true,
|
||||
module: true,
|
||||
browser: true
|
||||
}),
|
||||
commonjs()
|
||||
]
|
||||
}, {
|
||||
input: ['./examples/codemirror.js', './examples/textarea.js'], // './examples/quill.js', './examples/dom.js', './examples/prosemirror.js'
|
||||
output: {
|
||||
dir: 'examples/build',
|
||||
format: 'esm',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: [
|
||||
debugResolve,
|
||||
nodeResolve({
|
||||
sourcemap: true,
|
||||
module: true,
|
||||
browser: true
|
||||
}),
|
||||
commonjs(),
|
||||
...minificationPlugins
|
||||
]
|
||||
}]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user