diff --git a/README.v13.md b/README.v13.md index 9c5aaed3..5d345f43 100644 --- a/README.v13.md +++ b/README.v13.md @@ -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 diff --git a/bindings/codemirror.js b/bindings/codemirror.js new file mode 100644 index 00000000..a27305f8 --- /dev/null +++ b/bindings/codemirror.js @@ -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 + } +} diff --git a/bindings/prosemirror.js b/bindings/prosemirror.js index 514d27e3..bc8c57e8 100644 --- a/bindings/prosemirror.js +++ b/bindings/prosemirror.js @@ -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` })) } } }) diff --git a/examples/codemirror.html b/examples/codemirror.html new file mode 100644 index 00000000..0ac5d75f --- /dev/null +++ b/examples/codemirror.html @@ -0,0 +1,70 @@ + + +
+This example shows how to bind a YText type to CodeMirror editor.
+The content of this editor is shared with every client who visits this domain.
+