implemented codemirror binding with cursor support

This commit is contained in:
Kevin Jahns 2018-12-21 13:51:38 +01:00
parent c0ba56a21f
commit 7f4ae9fe14
21 changed files with 338 additions and 30 deletions

View File

@ -13,7 +13,7 @@ Yjs is a CRDT implementatation that exposes its internal structure as actual dat
|---|:-:|---|
| [ProseMirror](https://prosemirror.net/) | ✔ | [link](https://yjs.website/tutorial-prosemirror.html) |
| [Quill](https://quilljs.com/) | | [link](https://yjs.website/tutorial-quill.html) |
| [CodeMirror](https://codemirror.net/) | | [link]() |
| [CodeMirror](https://codemirror.net/) | | [link](https://yjs.website/tutorial-codemirror.html) |
| [Ace](https://ace.c9.io/) | | [link]() |
| [Monaco](https://microsoft.github.io/monaco-editor/) | | [link]() |
@ -177,11 +177,11 @@ sharedDocument.on('status', event => {
#### Scaling
In this model, there is a central server that handles the content. You need to make sure that all connections to the same document are handled by the same websocket server.
These are mere suggestions how you could scale your server environment.
I recommend to implement a custom websocket proxy that routes server requests to the correct websocket server.
**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.
One way to find "the correct websocket server" is to implement a consistent hashing algorithm that maps each document to a unique server. When the hashing function changes, the websocket connections must be re-routed.
**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

180
bindings/codemirror.js Normal file
View File

@ -0,0 +1,180 @@
/**
* @module bindings/textarea
*/
import { createMutex } from '../lib/mutex.js'
import * as math from '../lib/math.js'
import * as ypos from '../utils/relativePosition.js'
const typeObserver = (binding, event) => {
binding._mux(() => {
const cm = binding.target
cm.operation(() => {
const delta = event.delta
let index = 0
for (let i = 0; i < event.delta.length; i++) {
const d = delta[i]
if (d.retain) {
index += d.retain
} else if (d.insert) {
const pos = cm.posFromIndex(index)
cm.replaceRange(d.insert, pos, pos, 'prosemirror-binding')
index += d.insert.length
} else if (d.delete) {
const start = cm.posFromIndex(index)
const end = cm.posFromIndex(index + d.delete)
cm.replaceRange('', start, end, 'prosemirror-binding')
}
}
})
})
}
const targetObserver = (binding, change) => {
binding._mux(() => {
const start = binding.target.indexFromPos(change.from)
const delLen = change.removed.map(s => s.length).reduce(math.add) + change.removed.length - 1
if (delLen > 0) {
binding.type.delete(start, delLen)
}
if (change.text.length > 0) {
binding.type.insert(start, change.text.join('\n'))
}
})
}
const createRemoteCaret = (username, color) => {
const caret = document.createElement('span')
caret.classList.add('remote-caret')
caret.setAttribute('style', `border-color: ${color}`)
const userDiv = document.createElement('div')
userDiv.setAttribute('style', `background-color: ${color}`)
userDiv.insertBefore(document.createTextNode(username), null)
caret.insertBefore(userDiv, null)
return caret
}
const updateRemoteSelection = (y, cm, type, cursors, clientId) => {
// destroy current text mark
const m = cursors.get(clientId)
if (m !== undefined) {
m.caret.clear()
if (m.sel !== null) {
m.sel.clear()
}
cursors.delete(clientId)
}
// redraw caret and selection for clientId
const aw = y.awareness.get(clientId)
if (aw === undefined) {
return
}
const user = aw.user || {}
if (user.color == null) {
user.color = '#ffa500'
}
if (user.name == null) {
user.name = `User: ${clientId}`
}
const cursor = aw.cursor
if (cursor == null || cursor.anchor == null || cursor.head == null) {
return
}
const anchor = ypos.fromRelativePosition(y, cursor.anchor || null)
const head = ypos.fromRelativePosition(y, cursor.head || null)
if (anchor !== null && head !== null && anchor.type === type && head.type === type) {
const headpos = cm.posFromIndex(head.offset)
const anchorpos = cm.posFromIndex(anchor.offset)
let from, to
if (head.offset < anchor.offset) {
from = headpos
to = anchorpos
} else {
from = anchorpos
to = headpos
}
const caretEl = createRemoteCaret(user.name, user.color)
const caret = cm.setBookmark(headpos, { widget: caretEl, insertLeft: true })
let sel = null
if (head.offset !== anchor.offset) {
sel = cm.markText(from, to, { css: `background-color: ${user.color}70`, inclusiveRight: true, inclusiveLeft: false })
}
cursors.set(clientId, { caret, sel })
}
}
const prosemirrorCursorActivity = (y, cm, type) => {
if (!cm.hasFocus()) {
return
}
const aw = y.getLocalAwarenessInfo()
const anchor = ypos.getRelativePosition(type, cm.indexFromPos(cm.getCursor('anchor')))
const head = ypos.getRelativePosition(type, cm.indexFromPos(cm.getCursor('head')))
if (aw.cursor == null || !ypos.equal(aw.cursor.anchor, anchor) || !ypos.equal(aw.cursor.head, head)) {
y.setAwarenessField('cursor', {
anchor, head
})
}
}
/**
* A binding that binds a YText to a CodeMirror editor.
*
* @example
* const ytext = ydocument.define('codemirror', Y.Text)
* const editor = new CodeMirror(document.querySelector('#container'), {
* mode: 'javascript',
* lineNumbers: true
* })
* const binding = new CodeMirrorBinding(editor)
*
*/
export class CodeMirrorBinding {
/**
* @param {YText} textType
* @param {CodeMirror} codeMirror
* @param {Object} [options={cursors: true}]
*/
constructor (textType, codeMirror, { cursors = true } = {}) {
const y = textType._y
this.type = textType
this.target = codeMirror
/**
* @private
*/
this._mux = createMutex()
// set initial value
codeMirror.setValue(textType.toString())
// observe type and target
this._typeObserver = event => typeObserver(this, event)
this._targetObserver = (_, change) => targetObserver(this, change)
this._cursors = new Map()
this._awarenessListener = event => {
const f = clientId => updateRemoteSelection(y, codeMirror, textType, this._cursors, clientId)
event.added.forEach(f)
event.removed.forEach(f)
event.updated.forEach(f)
}
this._cursorListener = () => prosemirrorCursorActivity(y, codeMirror, textType)
this._blurListeer = () =>
y.setAwarenessField('cursor', null)
textType.observe(this._typeObserver)
codeMirror.on('change', this._targetObserver)
if (cursors) {
y.on('awareness', this._awarenessListener)
codeMirror.on('cursorActivity', this._cursorListener)
codeMirror.on('blur', this._blurListeer)
codeMirror.on('focus', this._cursorListener)
}
}
destroy () {
this.type.unobserve(this._typeObserver)
this.target.off('change', this._targetObserver)
this.type.off('awareness', this._awarenessListener)
this.target.off('cursorActivity', this._cursorListener)
this.target.off('focus', this._cursorListener)
this.target.off('blur', this._blurListeer)
this.type = null
this.target = null
}
}

View File

@ -96,7 +96,7 @@ export const cursorPlugin = new Plugin({
if (aw.cursor != null) {
let user = aw.user || {}
if (user.color == null) {
user.color = '#ffa50070'
user.color = '#ffa500'
}
if (user.name == null) {
user.name = `User: ${userID}`
@ -119,7 +119,7 @@ export const cursorPlugin = new Plugin({
}, { key: userID + '' }))
const from = math.min(anchor, head)
const to = math.max(anchor, head)
decorations.push(Decoration.inline(from, to, { style: `background-color: ${user.color}` }))
decorations.push(Decoration.inline(from, to, { style: `background-color: ${user.color}70` }))
}
}
})

70
examples/codemirror.html Normal file
View File

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

23
examples/codemirror.js Normal file
View File

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

View File

@ -22,8 +22,9 @@
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('wss://api.yjs.website')
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 })

View File

@ -1,7 +1,6 @@
import * as Y from '../index.js'
import { WebsocketProvider } from '../provider/websocket.js'
import { DomBinding } from '../bindings/dom.js'
import * as conf from './exampleConfig.js'
const provider = new WebsocketProvider(conf.serverAddress)

View File

@ -1,6 +1,9 @@
{
"codemirror": {
"title": "CodeMirror Binding"
},
"prosemirror": {
"title": "Prosemirror Binding"
"title": "ProseMirror Binding"
},
"textarea": {
"title": "Textarea Binding"

View File

@ -58,8 +58,10 @@
<!-- The actual source file for the following code is found in ./prosemirror.js. Run `npm run watch` to compile the files -->
<script class="code-js" src="./build/prosemirror.js">
import * as Y from 'yjs'
import { WebsocketProvider } from '../provider/websocket.js'
import { prosemirrorPlugin, cursorPlugin } from '../bindings/prosemirror'
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'
@ -67,7 +69,7 @@ import { DOMParser } from 'prosemirror-model'
import { schema } from 'prosemirror-schema-basic'
import { exampleSetup } from 'prosemirror-example-setup'
const provider = new WebsocketProvider('wss://api.yjs.website')
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('prosemirror')
const type = ydocument.define('prosemirror', Y.XmlFragment)

View File

@ -19,9 +19,11 @@ 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('wss://api.yjs.website')
const provider = new WebsocketProvider(conf.serverAddress)
const ydocument = provider.get('quill')
const ytext = ydocument.define('quill', Y.Text)

View File

@ -16,7 +16,9 @@ import * as Y from 'yjs'
import { WebsocketProvider } from 'yjs/provider/websocket.js'
import { TextareaBinding } from 'yjs/bindings/textarea.js'
const provider = new WebsocketProvider('wss://api.yjs.website')
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')

View File

@ -162,7 +162,6 @@ export const peekVarUint = decoder => {
return s
}
/**
* Read string of variable length
* * varUint is used to store the length of the string
@ -204,4 +203,3 @@ export const peekVarString = decoder => {
decoder.pos = pos
return s
}

9
package-lock.json generated
View File

@ -336,7 +336,8 @@
"async-limiter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz",
"integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg=="
"integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==",
"optional": true
},
"asynckit": {
"version": "0.4.0",
@ -1826,6 +1827,11 @@
"integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=",
"dev": true
},
"codemirror": {
"version": "5.42.0",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.42.0.tgz",
"integrity": "sha512-pbApC8zDzItP3HRphD6kQVwS976qB5Qi0hU3MZMixLk+AyugOW1RF+8XJEjeyl5yWsHNe88tDUxzeRh5AOxPRw=="
},
"color-convert": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.1.tgz",
@ -7395,6 +7401,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.1.0.tgz",
"integrity": "sha512-H3dGVdGvW2H8bnYpIDc3u3LH8Wue3Qh+Zto6aXXFzvESkTVT6rAfKR6tR/+coaUvxs8yHtmNV0uioBF62ZGSTg==",
"optional": true,
"requires": {
"async-limiter": "~1.0.0"
}

View File

@ -100,5 +100,8 @@
},
"optionalDependencies": {
"ws": "^6.1.0"
},
"dependencies": {
"codemirror": "^5.42.0"
}
}

View File

@ -1,5 +0,0 @@
import * as idb from '../lib/idb.js'
const bc = new BroadcastChannel('ydb-client')
idb.openDB()

View File

@ -1,7 +1,7 @@
import * as encoding from '../lib/encoding.js'
import * as decoding from '../lib/decoding.js'
import { Y } from '../utils/Y.js';
import { Y } from '../utils/Y.js' // eslint-disable-line
export const messagePermissionDenied = 0
@ -21,10 +21,10 @@ export const writePermissionDenied = (encoder, reason) => {
*/
/**
*
* @param {decoding.Decoder} decoder
* @param {Y} y
* @param {PermissionDeniedHandler} permissionDeniedHandler
*
* @param {decoding.Decoder} decoder
* @param {Y} y
* @param {PermissionDeniedHandler} permissionDeniedHandler
*/
export const readAuthMessage = (decoder, y, permissionDeniedHandler) => {
switch (decoding.readVarUint(decoder)) {

View File

@ -9,7 +9,6 @@ import { getStruct } from '../utils/structReferences.js'
import { deleteItemRange } from '../utils/structManipulation.js'
import { integrateRemoteStruct } from '../utils/integrateRemoteStructs.js'
import { Y } from '../utils/Y.js' // eslint-disable-line
import { Item } from '../structs/Item.js'
import * as stringify from '../utils/structStringify.js'
/**

View File

@ -144,6 +144,16 @@ class WebsocketsSharedDocument extends Y.Y {
}
}
/**
* Websocket Provider for Yjs. Creates a single websocket connection to each document.
* The document name is attached to the provided url. I.e. the following example
* creates a websocket connection to http://localhost:1234/my-document-name
*
* @example
* import { WebsocketProvider } from 'yjs/provider/websocket/client.js'
* const provider = new WebsocketProvider('http://localhost:1234')
* const ydocument = provider.get('my-document-name')
*/
export class WebsocketProvider {
constructor (url) {
// ensure that url is always ends with /

View File

@ -29,6 +29,21 @@ export default [{
}),
commonjs()
]
}, {
input: './examples/codemirror.js',
output: {
name: 'codemirror',
file: 'examples/build/codemirror.js',
format: 'iife',
sourcemap: true
},
plugins: [
nodeResolve({
sourcemap: true,
module: true
}),
commonjs()
].concat(minificationPlugins)
}, {
input: './examples/prosemirror.js',
output: {

View File

@ -2,4 +2,4 @@
export const writeStructToTransaction = (transaction, struct) => {
transaction.encodedStructsLen++
struct._toBinary(transaction.encodedStructs)
}
}

View File

@ -29,7 +29,6 @@ export const stringifyItemID = item => {
return result
}
/**
* Helper utility to convert an item to a readable format.
*