From 0fd9390a99fc0905044cf4b11fe1ada7a35a3fff Mon Sep 17 00:00:00 2001 From: George Date: Sun, 10 May 2020 16:50:12 +0300 Subject: [PATCH] feat: update to slate 0.5x (#10) Update Slate-Collaboration to be compatible with Slate 0.5x versions. --- .gitignore | 1 + License.md | 9 + README.md | 58 +++-- lerna.json | 11 +- package.json | 29 +-- packages/backend/.babelrc | 3 +- packages/backend/.npmrc | 1 - packages/backend/package.json | 21 +- packages/backend/src/AutomergeBackend.ts | 125 +++++++++ packages/backend/src/Connection.ts | 196 --------------- packages/backend/src/SocketIOConnection.ts | 237 ++++++++++++++++++ packages/backend/src/index.ts | 4 +- packages/backend/src/model.ts | 19 -- packages/backend/src/utils/defaultValue.ts | 21 -- packages/backend/src/utils/index.ts | 14 +- packages/backend/tsconfig.json | 22 +- packages/bridge/.babelrc | 15 +- packages/bridge/.npmrc | 1 - packages/bridge/package.json | 19 +- packages/bridge/src/apply/annotation.ts | 60 ----- packages/bridge/src/apply/apply.spec.ts | 233 +++++++++++++++++ packages/bridge/src/apply/index.ts | 23 +- packages/bridge/src/apply/mark.ts | 72 ------ packages/bridge/src/apply/node.ts | 107 -------- packages/bridge/src/apply/node/index.ts | 15 ++ packages/bridge/src/apply/node/insertNode.ts | 19 ++ packages/bridge/src/apply/node/mergeNode.ts | 24 ++ packages/bridge/src/apply/node/moveNode.ts | 26 ++ packages/bridge/src/apply/node/removeNode.ts | 18 ++ packages/bridge/src/apply/node/setNode.ts | 18 ++ packages/bridge/src/apply/node/splitNode.ts | 27 ++ packages/bridge/src/apply/text.ts | 5 +- packages/bridge/src/convert/convert.spec.ts | 30 +-- packages/bridge/src/convert/create.ts | 5 +- packages/bridge/src/convert/index.ts | 7 +- packages/bridge/src/convert/insert.ts | 56 +++-- packages/bridge/src/convert/remove.ts | 59 ++--- packages/bridge/src/convert/set.ts | 71 ++---- packages/bridge/src/cursor/index.ts | 87 ++----- packages/bridge/src/model/automerge.ts | 5 - packages/bridge/src/model/index.ts | 25 +- packages/bridge/src/model/slate.ts | 9 +- packages/bridge/src/path/index.ts | 22 +- packages/bridge/src/utils/index.ts | 15 +- packages/bridge/src/utils/testUtils.ts | 29 +-- packages/bridge/src/utils/toSync.ts | 16 +- packages/bridge/tsconfig.json | 8 +- packages/client/.babelrc | 3 +- packages/client/.npmrc | 1 - packages/client/package.json | 18 +- packages/client/src/Connection.ts | 206 --------------- packages/client/src/Controller.tsx | 76 ------ packages/client/src/automerge-editor.ts | 156 ++++++++++++ packages/client/src/index.ts | 6 + packages/client/src/index.tsx | 30 --- packages/client/src/model.ts | 43 ---- packages/client/src/onChange.ts | 13 - packages/client/src/renderAnnotation.tsx | 31 --- packages/client/src/renderCursor.tsx | 7 - packages/client/src/renderEditor.tsx | 20 -- packages/client/src/useCursor.ts | 68 +++++ packages/client/src/withAutomerge.ts | 105 ++++++++ packages/client/src/withIOCollaboration.ts | 20 ++ packages/client/src/withSocketIO.ts | 122 +++++++++ packages/client/tsconfig.json | 20 +- packages/example/.gitignore | 23 -- packages/example/extend.tsconfig.json | 10 + packages/example/package.json | 20 +- packages/example/public/index.html | 6 + packages/example/server.js | 23 +- packages/example/src/App.tsx | 65 ++--- .../src/Cursor.tsx => example/src/Caret.tsx} | 58 +++-- packages/example/src/Client.tsx | 152 +++++------ packages/example/src/EditorFrame.tsx | 193 ++++++++++++++ packages/example/src/Room.tsx | 114 ++++----- packages/example/src/defaultValue.js | 17 -- packages/example/src/elements.tsx | 16 ++ packages/example/tsconfig.json | 41 ++- tsconfig.base.json | 25 +- 79 files changed, 2013 insertions(+), 1592 deletions(-) create mode 100644 License.md delete mode 100644 packages/backend/.npmrc create mode 100644 packages/backend/src/AutomergeBackend.ts delete mode 100644 packages/backend/src/Connection.ts create mode 100644 packages/backend/src/SocketIOConnection.ts delete mode 100644 packages/backend/src/model.ts delete mode 100644 packages/backend/src/utils/defaultValue.ts delete mode 100644 packages/bridge/.npmrc delete mode 100644 packages/bridge/src/apply/annotation.ts create mode 100644 packages/bridge/src/apply/apply.spec.ts delete mode 100644 packages/bridge/src/apply/mark.ts delete mode 100644 packages/bridge/src/apply/node.ts create mode 100644 packages/bridge/src/apply/node/index.ts create mode 100644 packages/bridge/src/apply/node/insertNode.ts create mode 100644 packages/bridge/src/apply/node/mergeNode.ts create mode 100644 packages/bridge/src/apply/node/moveNode.ts create mode 100644 packages/bridge/src/apply/node/removeNode.ts create mode 100644 packages/bridge/src/apply/node/setNode.ts create mode 100644 packages/bridge/src/apply/node/splitNode.ts delete mode 100644 packages/bridge/src/model/automerge.ts delete mode 100644 packages/client/.npmrc delete mode 100644 packages/client/src/Connection.ts delete mode 100644 packages/client/src/Controller.tsx create mode 100644 packages/client/src/automerge-editor.ts create mode 100644 packages/client/src/index.ts delete mode 100644 packages/client/src/index.tsx delete mode 100644 packages/client/src/model.ts delete mode 100644 packages/client/src/onChange.ts delete mode 100644 packages/client/src/renderAnnotation.tsx delete mode 100644 packages/client/src/renderCursor.tsx delete mode 100644 packages/client/src/renderEditor.tsx create mode 100644 packages/client/src/useCursor.ts create mode 100644 packages/client/src/withAutomerge.ts create mode 100644 packages/client/src/withIOCollaboration.ts create mode 100644 packages/client/src/withSocketIO.ts delete mode 100644 packages/example/.gitignore create mode 100644 packages/example/extend.tsconfig.json rename packages/{client/src/Cursor.tsx => example/src/Caret.tsx} (71%) create mode 100644 packages/example/src/EditorFrame.tsx delete mode 100644 packages/example/src/defaultValue.js diff --git a/.gitignore b/.gitignore index c917297..ae2b124 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ lib/ +build/ node_modules/ .DS_Store lerna-debug.log diff --git a/License.md b/License.md new file mode 100644 index 0000000..8233070 --- /dev/null +++ b/License.md @@ -0,0 +1,9 @@ +The MIT License + +Copyright © 2019–2020, [George Kukushin](https://github.com/cudr) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index e23fa57..d2cfd64 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# slate-collaborative. Check [Demo](https://slate-collaborative.herokuapp.com/) +# slate-collaborative. Check [demo](https://slate-collaborative.herokuapp.com/) slatejs collaborative plugin & microservice ![screencast2019-10-2820-06-10](https://user-images.githubusercontent.com/23132107/67700384-ebff7280-f9be-11e9-9005-6ddadcafec47.gif) @@ -13,52 +13,58 @@ Based on idea of https://github.com/humandx/slate-automerge Use it as a simple slatejs plugin -check [example](https://github.com/cudr/slate-collaborative/blob/221d8929915c49cbe30a2f92550c9a604b9a527e/packages/example/src/Client.tsx#L43) +```ts +import { withIOCollaboration } from '@slate-collaborative/client' +const collaborationEditor = withIOCollaboration(editor, options) ``` -import ColaborativeClient from '@slate-collaborative/client' -const plugins = [ColaborativeClient(options)] -``` +Check [detailed example](https://github.com/cudr/slate-collaborative/blob/master/packages/example/src/Client.tsx) -### options: -``` +### Options: +```ts { + docId?: // document id url?: string // url to open connection connectOpts?: SocketIOClient.ConnectOpts // socket.io-client options - cursorAnnotationType?: string // type string for cursor annotations - annotationDataMixin?: Data // any data passed to cursor annotation - renderPreloader?: () => ReactNode // optional preloader render - renderCursor?: (data: Data) => ReactNode | any // custom cursor render - onConnect?: (connection: Connection) => void // connect callback - onDisconnect?: (connection: Connection) => void // disconnect callback + cursorData?: any // any data passed to cursor + onConnect?: () => void // connect callback + onDisconnect?: () => void // disconnect callback } ``` -## Backend +You need to attach the useCursor decorator to provide custom cursor data in renderLeaf function + +```ts +import { useCursor } from '@slate-collaborative/client' + +const decorator = useCursor(editor) ``` -const CollaborativeBackend = require('@slate-collaborative/backend') -const connection = new CollaborativeBackend(options) + + +## Backend +```ts +const { SocketIOConnection } = require('@slate-collaborative/backend') + +const connection = new SocketIOConnection(options) ``` ### options: -``` +```ts { - entry: number | Server // port or Server for listen io connection - connectOpts?: SocketIO.ServerOptions - defaultValue?: ValueJSON // default value - saveTreshold?: number // theshold of onDocumentSave callback execution - cursorAnnotationType?: string // type string for cursor annotations - onAuthRequest?: ( // auth callback + entry: Server // or specify port to start io server + defaultValue: Node[] // default value + saveFrequency: number // frequency of onDocumentSave callback execution in ms + onAuthRequest: ( // auth callback query: Object, socket?: SocketIO.Socket ) => Promise | boolean - onDocumentLoad?: ( // request slatejs document callback + onDocumentLoad: ( // request slate document callback pathname: string, query?: Object - ) => ValueJSON | null | false | undefined - onDocumentSave?: (pathname: string, json: ValueJSON) => Promise | void // save document callback + ) => Node[] + onDocumentSave: (pathname: string, doc: Node[]) => Promise | void // save document callback } ``` diff --git a/lerna.json b/lerna.json index 5230748..9cb34aa 100644 --- a/lerna.json +++ b/lerna.json @@ -1,11 +1,6 @@ { - "packages": ["packages/*"], + "lerna": "2.7.1", + "version": "0.5.0", "npmClient": "yarn", - "useWorkspaces": true, - "version": "independent", - "command": { - "publish": { - "verifyAccess": false - } - } + "useWorkspaces": true } diff --git a/package.json b/package.json index 313146d..0a27d85 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,28 @@ { "private": true, - "version": "0.0.1", + "version": "0.5.0", "description": "Slate collaborative plugin & microservice", "scripts": { "bootstrap": "lerna bootstrap", + "clean": "rimraf ./packages/**/lib/ && rimraf ./packages/**/tsconfig.tsbuildinfo && lerna clean --yes", "release": "yarn prebuild && yarn build && lerna publish from-package", - "dev": "concurrently \"yarn watch\" \"lerna run dev --stream\"", - "build": "lerna run build --stream", + "dev": "lerna run --stream build:js && concurrently \"yarn watch\" \"lerna run dev --stream\"", + "build": "lerna run build --stream && lerna run build:example --stream", "watch": "lerna run --parallel watch", "clean:module": "lerna clean --yes", - "prebuild": "rm -rf ./packages/**/lib/", + "prebuild": "yarn clean", "test": "lerna run test --stream", "format": "prettier --write" }, - "workspaces": { - "packages": [ - "packages/*" - ], - "nohoist": [ - "**/jest" - ] - }, + "workspaces": [ + "packages/*" + ], "author": "cudr", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/cudr/slate-collaborative.git" + }, "husky": { "hooks": { "pre-commit": "lint-staged" @@ -37,8 +37,9 @@ "devDependencies": { "concurrently": "^4.1.2", "husky": "^3.0.5", - "lerna": "^3.16.4", + "lerna": "^3.20.2", "lint-staged": "^9.2.5", - "prettier": "^1.18.2" + "prettier": "^1.18.2", + "rimraf": "^3.0.2" } } diff --git a/packages/backend/.babelrc b/packages/backend/.babelrc index 8b54a5d..ff47042 100644 --- a/packages/backend/.babelrc +++ b/packages/backend/.babelrc @@ -3,6 +3,7 @@ "plugins": [ "@babel/plugin-transform-runtime", "@babel/proposal-class-properties", - "@babel/proposal-object-rest-spread" + "@babel/proposal-object-rest-spread", + "@babel/plugin-proposal-optional-chaining" ] } diff --git a/packages/backend/.npmrc b/packages/backend/.npmrc deleted file mode 100644 index a21347f..0000000 --- a/packages/backend/.npmrc +++ /dev/null @@ -1 +0,0 @@ -scripts-prepend-node-path=true \ No newline at end of file diff --git a/packages/backend/package.json b/packages/backend/package.json index 94fb73f..3951d44 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,11 +1,11 @@ { "name": "@slate-collaborative/backend", - "version": "0.0.3", + "version": "0.5.0", "files": [ "lib" ], "main": "lib/index.js", - "types": "lib/model.d.ts", + "types": "lib/index.d.ts", "description": "slate-collaborative bridge", "repository": { "type": "git", @@ -18,20 +18,22 @@ "license": "MIT", "scripts": { "prepublishOnly": "yarn run build", - "type-check": "tsc --noEmit", "build": "yarn run build:types && yarn run build:js", "build:types": "tsc --emitDeclarationOnly", "build:js": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline", "watch": "yarn build:js -w" }, "dependencies": { + "@babel/plugin-proposal-optional-chaining": "^7.9.0", "@babel/runtime": "^7.6.3", - "@slate-collaborative/bridge": "^0.0.3", - "automerge": "^0.12.1", + "@slate-collaborative/bridge": "^0.5.0", + "@types/lodash": "^4.14.150", + "@types/socket.io": "^2.1.4", + "automerge": "^0.14.0", "lodash": "^4.17.15", - "slate": "^0.47.8", - "socket.io": "^2.2.0", - "typescript": "^3.6.3" + "slate": "^0.57.2", + "socket.io": "^2.3.0", + "typescript": "^3.8.3" }, "devDependencies": { "@babel/cli": "^7.6.0", @@ -40,8 +42,7 @@ "@babel/plugin-proposal-object-rest-spread": "^7.5.5", "@babel/plugin-transform-runtime": "^7.6.0", "@babel/preset-env": "^7.6.0", - "@babel/preset-typescript": "^7.6.0", - "@types/socket.io": "^2.1.2" + "@babel/preset-typescript": "^7.6.0" }, "directories": { "lib": "lib" diff --git a/packages/backend/src/AutomergeBackend.ts b/packages/backend/src/AutomergeBackend.ts new file mode 100644 index 0000000..8d07a3b --- /dev/null +++ b/packages/backend/src/AutomergeBackend.ts @@ -0,0 +1,125 @@ +import * as Automerge from 'automerge' + +import { Element } from 'slate' + +import { + toCollabAction, + toSync, + SyncDoc, + CollabAction +} from '@slate-collaborative/bridge' + +export interface Connections { + [key: string]: Automerge.Connection +} + +/** + * AutomergeBackend contains collaboration with Automerge + */ + +class AutomergeBackend { + connections: Connections = {} + + docSet: Automerge.DocSet = new Automerge.DocSet() + + /** + * Create Autmorge Connection + */ + + createConnection = (id: string, send: any) => { + if (this.connections[id]) { + console.warn( + `Already has connection with id: ${id}. It will be terminated before creating new connection` + ) + + this.closeConnection(id) + } + + this.connections[id] = new Automerge.Connection( + this.docSet, + toCollabAction('operation', send) + ) + } + + /** + * Start Automerge Connection + */ + + openConnection = (id: string) => this.connections[id].open() + + /** + * Close Automerge Connection and remove it from connections + */ + + closeConnection(id: string) { + this.connections[id]?.close() + + delete this.connections[id] + } + + /** + * Receive and apply operation to Automerge Connection + */ + + receiveOperation = (id: string, data: CollabAction) => { + try { + this.connections[id].receiveMsg(data.payload) + } catch (e) { + console.error('Unexpected error in receiveOperation', e) + } + } + + /** + * Get document from Automerge DocSet + */ + + getDocument = (docId: string) => this.docSet.getDoc(docId) + + /** + * Append document to Automerge DocSet + */ + + appendDocument = (docId: string, data: Element[]) => { + try { + if (this.getDocument(docId)) { + throw new Error(`Already has document with id: ${docId}`) + } + + const sync = toSync({ cursors: {}, children: data }) + + const doc = Automerge.from(sync) + + this.docSet.setDoc(docId, doc) + } catch (e) { + console.error(e, docId) + } + } + + /** + * Remove document from Automerge DocSet + */ + + removeDocument = (docId: string) => this.docSet.removeDoc(docId) + + /** + * Remove client cursor data + */ + + garbageCursor = (docId: string, id: string) => { + try { + const doc = this.getDocument(docId) + + if (!doc.cursors) return + + const change = Automerge.change(doc, d => { + delete d.cursors[id] + }) + + this.docSet.setDoc(docId, change) + } catch (e) { + console.error('Unexpected error in garbageCursor', e) + } + } +} + +export default AutomergeBackend diff --git a/packages/backend/src/Connection.ts b/packages/backend/src/Connection.ts deleted file mode 100644 index e070290..0000000 --- a/packages/backend/src/Connection.ts +++ /dev/null @@ -1,196 +0,0 @@ -import io from 'socket.io' -import { ValueJSON } from 'slate' -import * as Automerge from 'automerge' -import throttle from 'lodash/throttle' -import merge from 'lodash/merge' - -import { toSync, toJS } from '@slate-collaborative/bridge' - -import { getClients, defaultValue, defaultOptions } from './utils' -import { ConnectionOptions } from './model' - -export default class Connection { - private io: any - private docSet: any - private connections: { [key: string]: Automerge.Connection } - private options: ConnectionOptions - - constructor(options: ConnectionOptions = defaultOptions) { - this.io = io(options.entry, options.connectOpts) - this.docSet = new Automerge.DocSet() - this.connections = {} - this.options = merge(defaultOptions, options) - - this.configure() - - return this - } - - private configure = () => - this.io - .of(this.nspMiddleware) - .use(this.authMiddleware) - .on('connect', this.onConnect) - - private appendDoc = (path: string, value: ValueJSON) => { - const sync = toSync(value) - - sync.annotations = {} - - const doc = Automerge.from(sync) - - this.docSet.setDoc(path, doc) - } - - private saveDoc = throttle(pathname => { - try { - if (this.options.onDocumentSave) { - const doc = this.docSet.getDoc(pathname) - - if (doc) { - const data = toJS(doc) - - delete data.annotations - - this.options.onDocumentSave(pathname, data) - } - } - } catch (e) { - console.log(e) - } - }, (this.options && this.options.saveTreshold) || 2000) - - private nspMiddleware = async (path, query, next) => { - const { onDocumentLoad } = this.options - - if (!this.docSet.getDoc(path)) { - const valueJson = onDocumentLoad - ? await onDocumentLoad(path) - : this.options.defaultValue || defaultValue - - if (!valueJson) return next(null, false) - - this.appendDoc(path, valueJson) - } - - return next(null, true) - } - - private authMiddleware = async (socket, next) => { - const { query } = socket.handshake - const { onAuthRequest } = this.options - - if (onAuthRequest) { - const permit = await onAuthRequest(query, socket) - - if (!permit) - return next(new Error(`Authentification error: ${socket.id}`)) - } - - return next() - } - - private onConnect = socket => { - const { id, conn } = socket - const { name } = socket.nsp - - const doc = this.docSet.getDoc(name) - - const data = Automerge.save(doc) - - this.connections[id] = new Automerge.Connection(this.docSet, data => { - socket.emit('operation', { id: conn.id, ...data }) - }) - - socket.join(id, () => { - this.connections[id].open() - - socket.emit('document', data) - }) - - socket.on('operation', this.onOperation(id, name)) - - socket.on('disconnect', this.onDisconnect(id, socket)) - - this.garbageCursors(name) - } - - private onOperation = (id, name) => data => { - try { - this.connections[id].receiveMsg(data) - - this.saveDoc(name) - - this.garbageCursors(name) - } catch (e) { - console.log(e) - } - } - - private onDisconnect = (id, socket) => () => { - this.connections[id].close() - delete this.connections[id] - - socket.leave(id) - - this.garbageCursor(socket.nsp.name, id) - this.garbageCursors(socket.nsp.name) - - this.garbageNsp() - } - - garbageNsp = () => { - Object.keys(this.io.nsps) - .filter(n => n !== '/') - .forEach(nsp => { - getClients(this.io, nsp).then((clientsList: any[]) => { - if (!clientsList.length) this.removeDoc(nsp) - }) - }) - } - - garbageCursor = (nsp, id) => { - const doc = this.docSet.getDoc(nsp) - - if (!doc.annotations) return - - const change = Automerge.change(doc, `remove cursor ${id}`, (d: any) => { - delete d.annotations[id] - }) - - this.docSet.setDoc(nsp, change) - } - - garbageCursors = nsp => { - const doc = this.docSet.getDoc(nsp) - - if (!doc.annotations) return - - const namespace = this.io.of(nsp) - - Object.keys(doc.annotations).forEach(key => { - if ( - !namespace.sockets[key] && - doc.annotations[key].type === this.options.cursorAnnotationType - ) { - this.garbageCursor(nsp, key) - } - }) - } - - removeDoc = async nsp => { - const doc = this.docSet.getDoc(nsp) - - if (this.options.onDocumentSave) { - await this.options.onDocumentSave(nsp, toJS(doc)) - } - - this.docSet.removeDoc(nsp) - - delete this.io.nsps[nsp] - } - - destroy = async () => { - this.io.close() - } -} diff --git a/packages/backend/src/SocketIOConnection.ts b/packages/backend/src/SocketIOConnection.ts new file mode 100644 index 0000000..3856406 --- /dev/null +++ b/packages/backend/src/SocketIOConnection.ts @@ -0,0 +1,237 @@ +import io from 'socket.io' +import * as Automerge from 'automerge' +import { Element } from 'slate' +import { Server } from 'http' + +import throttle from 'lodash/throttle' + +import { SyncDoc, CollabAction, toJS } from '@slate-collaborative/bridge' + +import { getClients } from './utils' + +import AutomergeBackend from './AutomergeBackend' + +export interface SocketIOCollaborationOptions { + entry: number | Server + connectOpts?: SocketIO.ServerOptions + defaultValue?: Element[] + saveFrequency?: number + onAuthRequest?: ( + query: Object, + socket?: SocketIO.Socket + ) => Promise | boolean + onDocumentLoad?: (pathname: string, query?: Object) => Element[] + onDocumentSave?: (pathname: string, doc: Element[]) => Promise | void +} + +export default class SocketIOCollaboration { + private io: SocketIO.Server + private options: SocketIOCollaborationOptions + private backend: AutomergeBackend + + /** + * Constructor + */ + + constructor(options: SocketIOCollaborationOptions) { + this.io = io(options.entry, options.connectOpts) + + this.backend = new AutomergeBackend() + + this.options = options + + this.configure() + + return this + } + + /** + * Initial IO configuration + */ + + private configure = () => + this.io + .of(this.nspMiddleware) + .use(this.authMiddleware) + .on('connect', this.onConnect) + + /** + * Namespace SocketIO middleware. Load document value and append it to CollaborationBackend. + */ + + private nspMiddleware = async (path: string, query: any, next: any) => { + const { onDocumentLoad } = this.options + + if (!this.backend.getDocument(path)) { + const doc = onDocumentLoad + ? await onDocumentLoad(path) + : this.options.defaultValue + + if (!doc) return next(null, false) + + this.backend.appendDocument(path, doc) + } + + return next(null, true) + } + + /** + * SocketIO auth middleware. Used for user authentification. + */ + + private authMiddleware = async ( + socket: SocketIO.Socket, + next: (e?: any) => void + ) => { + const { query } = socket.handshake + const { onAuthRequest } = this.options + + if (onAuthRequest) { + const permit = await onAuthRequest(query, socket) + + if (!permit) + return next(new Error(`Authentification error: ${socket.id}`)) + } + + return next() + } + + /** + * On 'connect' handler. + */ + + private onConnect = (socket: SocketIO.Socket) => { + const { id, conn } = socket + const { name } = socket.nsp + + this.backend.createConnection(id, ({ type, payload }: CollabAction) => { + socket.emit('msg', { type, payload: { id: conn.id, ...payload } }) + }) + + socket.on('msg', this.onMessage(id, name)) + + socket.on('disconnect', this.onDisconnect(id, socket)) + + socket.join(id, () => { + const doc = this.backend.getDocument(name) + + socket.emit('msg', { + type: 'document', + payload: Automerge.save(doc) + }) + + this.backend.openConnection(id) + }) + + this.garbageCursors(name) + } + + /** + * On 'message' handler + */ + + private onMessage = (id: string, name: string) => (data: any) => { + switch (data.type) { + case 'operation': + try { + this.backend.receiveOperation(id, data) + + this.autoSaveDoc(name) + + this.garbageCursors(name) + } catch (e) { + console.log(e) + } + } + } + + /** + * Save document with throttle + */ + + private autoSaveDoc = throttle( + async (docId: string) => + this.backend.getDocument(docId) && this.saveDocument(docId), + this.options?.saveFrequency || 2000 + ) + + /** + * Save document + */ + + private saveDocument = async (docId: string) => { + try { + const { onDocumentSave } = this.options + + const doc = this.backend.getDocument(docId) + + if (!doc) { + throw new Error(`Can't receive document by id: ${docId}`) + } + + onDocumentSave && (await onDocumentSave(docId, toJS(doc.children))) + } catch (e) { + console.error(e, docId) + } + } + + /** + * On 'disconnect' handler + */ + + private onDisconnect = (id: string, socket: SocketIO.Socket) => async () => { + this.backend.closeConnection(id) + + await this.saveDocument(socket.nsp.name) + + this.garbageCursors(socket.nsp.name) + + socket.leave(id) + + this.garbageNsp() + } + + /** + * Clean up unused SocketIO namespaces. + */ + + garbageNsp = () => { + Object.keys(this.io.nsps) + .filter(n => n !== '/') + .forEach(nsp => { + getClients(this.io, nsp).then((clientsList: any) => { + if (!clientsList.length) { + this.backend.removeDocument(nsp) + + delete this.io.nsps[nsp] + } + }) + }) + } + + /** + * Clean up unused cursor data. + */ + + garbageCursors = (nsp: string) => { + const doc = this.backend.getDocument(nsp) + + if (!doc.cursors) return + + const namespace = this.io.of(nsp) + + Object.keys(doc?.cursors)?.forEach(key => { + if (!namespace.sockets[key]) { + this.backend.garbageCursor(nsp, key) + } + }) + } + + /** + * Destroy SocketIO connection + */ + + destroy = async () => { + this.io.close() + } +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7c2d9f6..9794ea9 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,3 +1,3 @@ -import Connection from './Connection' +import SocketIOConnection from './SocketIOConnection' -module.exports = Connection +export { SocketIOConnection } diff --git a/packages/backend/src/model.ts b/packages/backend/src/model.ts deleted file mode 100644 index aefb42b..0000000 --- a/packages/backend/src/model.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ValueJSON } from 'slate' -import { Server } from 'http' - -export interface ConnectionOptions { - entry: number | Server - connectOpts?: SocketIO.ServerOptions - defaultValue?: ValueJSON - saveTreshold?: number - cursorAnnotationType?: string - onAuthRequest?: ( - query: Object, - socket?: SocketIO.Socket - ) => Promise | boolean - onDocumentLoad?: ( - pathname: string, - query?: Object - ) => ValueJSON | null | false | undefined - onDocumentSave?: (pathname: string, json: ValueJSON) => Promise | void -} diff --git a/packages/backend/src/utils/defaultValue.ts b/packages/backend/src/utils/defaultValue.ts deleted file mode 100644 index d33d77b..0000000 --- a/packages/backend/src/utils/defaultValue.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ValueJSON } from 'slate' - -const json: ValueJSON = { - document: { - nodes: [ - { - object: 'block', - type: 'paragraph', - nodes: [ - { - object: 'text', - marks: [], - text: '' - } - ] - } - ] - } -} - -export default json diff --git a/packages/backend/src/utils/index.ts b/packages/backend/src/utils/index.ts index 20fc09b..5c55ce9 100644 --- a/packages/backend/src/utils/index.ts +++ b/packages/backend/src/utils/index.ts @@ -1,14 +1,4 @@ -import defaultValue from './defaultValue' - -export const getClients = (io, nsp) => +export const getClients = (io: SocketIO.Server, nsp: string) => new Promise((r, j) => { - io.of(nsp).clients((e, c) => (e ? j(e) : r(c))) + io.of(nsp).clients((e: any, c: any) => (e ? j(e) : r(c))) }) - -export const defaultOptions = { - entry: 9000, - saveTreshold: 2000, - cursorAnnotationType: 'collaborative_selection' -} - -export { defaultValue } diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index b589fe8..36db2ed 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -1,12 +1,16 @@ { - "include": ["src/**/*"], "extends": "../../tsconfig.base.json", + "include": ["./src/**/*"], "compilerOptions": { - "outDir": "lib", - "rootDir": "src", - "baseUrl": "src", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "lib": ["dom", "dom.iterable", "esnext"] - } -} + "rootDir": "./src", + "baseUrl": "./src", + "outDir": "./lib", + "composite": true, + "paths": { + "@slate-collaborative/bridge": ["../../bridge"] + } + }, + "references": [ + { "path": "../bridge" } + ] +} \ No newline at end of file diff --git a/packages/bridge/.babelrc b/packages/bridge/.babelrc index 89aef8b..36dba11 100644 --- a/packages/bridge/.babelrc +++ b/packages/bridge/.babelrc @@ -1,17 +1,8 @@ { - "presets": [ - [ - "@babel/preset-env", - { - "targets": { - "esmodules": false - } - } - ], - "@babel/typescript" - ], + "presets": ["@babel/env", "@babel/typescript"], "plugins": [ "@babel/proposal-class-properties", - "@babel/proposal-object-rest-spread" + "@babel/proposal-object-rest-spread", + "@babel/plugin-proposal-optional-chaining" ] } diff --git a/packages/bridge/.npmrc b/packages/bridge/.npmrc deleted file mode 100644 index a21347f..0000000 --- a/packages/bridge/.npmrc +++ /dev/null @@ -1 +0,0 @@ -scripts-prepend-node-path=true \ No newline at end of file diff --git a/packages/bridge/package.json b/packages/bridge/package.json index d34ae9b..f374163 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -1,6 +1,6 @@ { "name": "@slate-collaborative/bridge", - "version": "0.0.3", + "version": "0.5.0", "files": [ "lib" ], @@ -25,20 +25,21 @@ "test": "jest" }, "dependencies": { - "automerge": "^0.12.1", - "slate": "^0.47.8", - "typescript": "^3.6.3" + "automerge": "^0.14.0", + "slate": "^0.57.2", + "typescript": "^3.8.3" }, "devDependencies": { "@babel/cli": "^7.6.0", "@babel/core": "^7.6.0", "@babel/plugin-proposal-class-properties": "^7.5.5", "@babel/plugin-proposal-object-rest-spread": "^7.5.5", + "@babel/plugin-proposal-optional-chaining": "^7.9.0", "@babel/preset-env": "^7.6.0", "@babel/preset-typescript": "^7.6.0", - "@types/jest": "^24.0.19", + "@types/jest": "^24.9.0", "jest": "^24.9.0", - "ts-jest": "^24.1.0" + "ts-jest": "^25.4.0" }, "directories": { "lib": "lib" @@ -49,6 +50,12 @@ "bridge" ], "jest": { + "preset": "ts-jest", + "globals": { + "ts-jest": { + "babelConfig": ".babelrc" + } + }, "roots": [ "/src" ], diff --git a/packages/bridge/src/apply/annotation.ts b/packages/bridge/src/apply/annotation.ts deleted file mode 100644 index 359d0ca..0000000 --- a/packages/bridge/src/apply/annotation.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { SyncDoc } from '../model/index' -import { toSync } from '../utils' -import { - AddAnnotationOperation, - RemoveAnnotationOperation, - SetAnnotationOperation -} from 'slate' - -export const addAnnotation = ( - doc: SyncDoc, - op: AddAnnotationOperation -): SyncDoc => { - if (!doc.annotations) { - doc['annotations'] = {} - } - - const annotation = op.annotation.toJSON() - - doc.annotations[annotation.key] = toSync(annotation) - - return doc -} - -export const removeAnnotation = ( - doc: SyncDoc, - op: RemoveAnnotationOperation -): SyncDoc => { - if (doc.annotations) { - delete doc.annotations[op.annotation.key] - } - - return doc -} - -export const setAnnotation = ( - doc: SyncDoc, - op: SetAnnotationOperation -): SyncDoc => { - /** - * Looks like set_annotation option is broken, temporary disabled - */ - - // const { newProperties }: any = op.toJSON() - - // if (!doc.annotations || !newProperties) return doc - - // if (!doc.annotations[newProperties.key]) { - // return addAnnotation(doc, newProperties) - // } else { - // doc.annotations[newProperties.key] = { ...doc.annotations[newProperties.key], ...newProperties } - // } - - return doc -} - -export default { - add_annotation: addAnnotation, - remove_annotation: removeAnnotation, - set_annotation: setAnnotation -} diff --git a/packages/bridge/src/apply/apply.spec.ts b/packages/bridge/src/apply/apply.spec.ts new file mode 100644 index 0000000..39c6cdd --- /dev/null +++ b/packages/bridge/src/apply/apply.spec.ts @@ -0,0 +1,233 @@ +import * as Automerge from 'automerge' + +import { createDoc, toJS, createNode, createText } from '../utils' + +import { applySlateOps } from './' + +const transforms = [ + [ + 'insert_text', + [createNode('paragraph', '')], + [ + { + marks: [], + offset: 0, + path: [0, 0], + text: 'Hello ', + type: 'insert_text' + }, + { + marks: [], + offset: 6, + path: [0, 0], + text: 'collaborator', + type: 'insert_text' + }, + { + marks: [], + offset: 18, + path: [0, 0], + text: '!', + type: 'insert_text' + } + ], + [createNode('paragraph', 'Hello collaborator!')] + ], + [ + 'remove_text', + [createNode('paragraph', 'Hello collaborator!')], + [ + { + offset: 11, + path: [0, 0], + text: 'borator', + type: 'remove_text' + }, + { + offset: 5, + path: [0, 0], + text: ' colla', + type: 'remove_text' + } + ], + [createNode('paragraph', 'Hello!')] + ], + [ + 'insert_node', + null, + [ + { + type: 'insert_node', + path: [1], + node: { type: 'paragraph', children: [] } + }, + { + type: 'insert_node', + path: [1, 0], + node: { text: 'Hello collaborator!' } + } + ], + [createNode(), createNode('paragraph', 'Hello collaborator!')] + ], + [ + 'merge_node', + [ + createNode('paragraph', 'Hello '), + createNode('paragraph', 'collaborator!') + ], + [ + { + path: [1], + position: 1, + properties: { type: 'paragraph' }, + target: null, + type: 'merge_node' + }, + { + path: [0, 1], + position: 6, + properties: {}, + target: null, + type: 'merge_node' + } + ], + [createNode('paragraph', 'Hello collaborator!')] + ], + [ + 'move_node', + [ + createNode('paragraph', 'first'), + createNode('paragraph', 'second'), + createNode('paragraph', 'third'), + createNode('paragraph', 'fourth') + ], + [ + { + newPath: [0], + path: [1], + type: 'move_node' + }, + { + newPath: [3, 0], + path: [2, 0], + type: 'move_node' + } + ], + [ + createNode('paragraph', 'second'), + createNode('paragraph', 'first'), + { + type: 'paragraph', + children: [] + }, + { + type: 'paragraph', + children: [createText('third'), createText('fourth')] + } + ] + ], + [ + 'remove_node', + [ + createNode('paragraph', 'first'), + createNode('paragraph', 'second'), + createNode('paragraph', 'third') + ], + [ + { + path: [1, 0], + type: 'remove_node' + }, + { + path: [0], + type: 'remove_node' + } + ], + [ + { + type: 'paragraph', + children: [] + }, + createNode('paragraph', 'third') + ] + ], + [ + 'set_node', + [ + createNode('paragraph', 'first', { test: '1234' }), + createNode('paragraph', 'second') + ], + [ + { + path: [0], + type: 'set_node', + properties: { + test: '1234' + }, + newProperties: { + test: '4567' + } + }, + { + path: [1, 0], + type: 'set_node', + newProperties: { + data: '4567' + } + } + ], + [ + createNode('paragraph', 'first', { test: '4567' }), + { + type: 'paragraph', + children: [ + { + data: '4567', + text: 'second' + } + ] + } + ] + ], + [ + 'split_node', + [createNode('paragraph', 'Hello collaborator!')], + [ + { + path: [0, 0], + position: 6, + target: null, + type: 'split_node' + }, + { + path: [0], + position: 1, + properties: { + type: 'paragraph' + }, + target: 6, + type: 'split_node' + } + ], + [ + createNode('paragraph', 'Hello '), + createNode('paragraph', 'collaborator!') + ] + ] +] + +describe('apply slate operations to Automerge document', () => { + transforms.forEach(([op, input, operations, output]) => { + it(`apply ${op} operations`, () => { + const doc = createDoc(input) + + const updated = Automerge.change(doc, (d: any) => { + applySlateOps(d.children, operations as any) + }) + + const expected = createDoc(output) + + expect(toJS(expected)).toStrictEqual(toJS(updated)) + }) + }) +}) diff --git a/packages/bridge/src/apply/index.ts b/packages/bridge/src/apply/index.ts index a99b026..821c89d 100644 --- a/packages/bridge/src/apply/index.ts +++ b/packages/bridge/src/apply/index.ts @@ -1,20 +1,17 @@ -import { Operation, Operations, SyncDoc } from '../model' +import { Operation } from 'slate' import node from './node' -import mark from './mark' import text from './text' -import annotation from './annotation' -const setSelection = doc => doc -const setValue = doc => doc +import { SyncDoc } from '../model' +import { toJS } from '../utils' + +const setSelection = (doc: any) => doc const opType = { ...text, - ...annotation, ...node, - ...mark, set_selection: setSelection - // set_value: setValue } const applyOperation = (doc: SyncDoc, op: Operation): SyncDoc => { @@ -22,19 +19,19 @@ const applyOperation = (doc: SyncDoc, op: Operation): SyncDoc => { const applyOp = opType[op.type] if (!applyOp) { - console.log('operation', op.toJS()) throw new TypeError(`Unsupported operation type: ${op.type}!`) } - return applyOp(doc, op) + return applyOp(doc, op as any) } catch (e) { - console.error(e) + console.error(e, op, toJS(doc)) return doc } } -const applySlateOps = (doc: SyncDoc, operations: Operations) => - operations.reduce(applyOperation, doc) +const applySlateOps = (doc: SyncDoc, operations: Operation[]): SyncDoc => { + return operations.reduce(applyOperation, doc) +} export { applyOperation, applySlateOps } diff --git a/packages/bridge/src/apply/mark.ts b/packages/bridge/src/apply/mark.ts deleted file mode 100644 index c38cee6..0000000 --- a/packages/bridge/src/apply/mark.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { getTarget } from '../path' -import { toSync } from '../utils' -import { SyncDoc } from '../model' - -import { AddMarkOperation, RemoveMarkOperation, SetMarkOperation } from 'slate' - -const findIndex = (node, mark) => - node.marks.findIndex(m => m.type === mark.type) - -export const addMark = (doc: SyncDoc, op: AddMarkOperation) => { - const node = getTarget(doc, op.path) - - if (node.object !== 'text') { - throw new TypeError('cannot set marks on non-text node') - } - - if (findIndex(node, op.mark) < 0) node.marks.push(toSync(op.mark.toJS())) - - return doc -} - -export const removeMark = (doc: SyncDoc, op: RemoveMarkOperation) => { - const node = getTarget(doc, op.path) - - if (node.object !== 'text') { - throw new TypeError('cannot set marks on non-text node') - } - - const index = findIndex(node, op.mark) - - if (index >= 0) node.marks.splice(index, 1) - - return doc -} - -export const setMark = (doc: SyncDoc, op: SetMarkOperation) => { - const node = getTarget(doc, op.path) - - if (node.object !== 'text') { - throw new TypeError('cannot set marks on non-text node') - } - - const index = findIndex(node, op.properties) - - if (index === -1) { - console.warn('did not find old mark with properties', op.properties) - - if (!op.newProperties.type) { - throw new TypeError('no old mark, and new mark missing type') - } - - node.marks.push({ - object: 'mark', - type: op.newProperties.type, - ...op.newProperties - }) - } else { - node.marks[index] = { - object: 'mark', - ...node.marks[index], - ...op.newProperties - } - } - - return doc -} - -export default { - add_mark: addMark, - remove_mark: removeMark, - set_mark: setMark -} diff --git a/packages/bridge/src/apply/node.ts b/packages/bridge/src/apply/node.ts deleted file mode 100644 index aad2c3f..0000000 --- a/packages/bridge/src/apply/node.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { SyncDoc } from '../model' - -import { - SplitNodeOperation, - InsertNodeOperation, - MoveNodeOperation, - RemoveNodeOperation, - MergeNodeOperation, - SetNodeOperation -} from 'slate' - -import { getTarget, getParent } from '../path' -import { toJS, cloneNode, toSync } from '../utils' - -export const insertNode = (doc: SyncDoc, op: InsertNodeOperation): SyncDoc => { - const [parent, index] = getParent(doc, op.path) - - if (parent.object === 'text') { - throw new TypeError('cannot insert node into text node') - } - - parent.nodes.splice(index, 0, toSync(op.node.toJS())) - - return doc -} - -export const moveNode = (doc: SyncDoc, op: MoveNodeOperation): SyncDoc => { - const [from, fromIndex] = getParent(doc, op.path) - const [to, toIndex] = getParent(doc, op.newPath) - - if (from.object === 'text' || to.object === 'text') { - throw new TypeError('cannot move node as child of a text node') - } - - to.nodes.splice(toIndex, 0, ...from.nodes.splice(fromIndex, 1)) - - return doc -} - -export const removeNode = (doc: SyncDoc, op: RemoveNodeOperation): SyncDoc => { - const [parent, index] = getParent(doc, op.path) - - if (parent.object === 'text') { - throw new TypeError('cannot remove node from text node') - } - - parent.nodes.splice(index, 1) - - return doc -} - -export const splitNode = (doc: SyncDoc, op: SplitNodeOperation): SyncDoc => { - const [parent, index]: [any, number] = getParent(doc, op.path) - - const target = parent.nodes[index] - const inject = cloneNode(target) - - if (target.object === 'text') { - target.text.length > op.position && - target.text.deleteAt(op.position, target.text.length - op.position) - op.position && inject.text.deleteAt(0, op.position) - } else { - target.nodes.splice(op.position, target.nodes.length - op.position) - op.position && inject.nodes.splice(0, op.position) - } - - parent.nodes.insertAt(index + 1, inject) - - return doc -} - -export const mergeNode = (doc: SyncDoc, op: MergeNodeOperation) => { - const [parent, index]: [any, number] = getParent(doc, op.path) - - const prev = parent.nodes[index - 1] - const next = parent.nodes[index] - - if (prev.object === 'text') { - prev.text.insertAt(prev.text.length, ...toJS(next.text).split('')) - } else { - next.nodes.forEach(n => prev.nodes.push(cloneNode(n))) - } - - parent.nodes.deleteAt(index, 1) - - return doc -} - -export const setNode = (doc: SyncDoc, op: SetNodeOperation) => { - const node = getTarget(doc, op.path) - - const { type, data }: any = op.newProperties - - if (type) node.type = type - if (node.object !== 'text' && data) node.data = data.toJSON() - - return doc -} - -export default { - insert_node: insertNode, - move_node: moveNode, - remove_node: removeNode, - split_node: splitNode, - merge_node: mergeNode, - set_node: setNode -} diff --git a/packages/bridge/src/apply/node/index.ts b/packages/bridge/src/apply/node/index.ts new file mode 100644 index 0000000..01cfdad --- /dev/null +++ b/packages/bridge/src/apply/node/index.ts @@ -0,0 +1,15 @@ +import insertNode from './insertNode' +import mergeNode from './mergeNode' +import moveNode from './moveNode' +import removeNode from './removeNode' +import setNode from './setNode' +import splitNode from './splitNode' + +export default { + insert_node: insertNode, + merge_node: mergeNode, + move_node: moveNode, + remove_node: removeNode, + set_node: setNode, + split_node: splitNode +} diff --git a/packages/bridge/src/apply/node/insertNode.ts b/packages/bridge/src/apply/node/insertNode.ts new file mode 100644 index 0000000..d206548 --- /dev/null +++ b/packages/bridge/src/apply/node/insertNode.ts @@ -0,0 +1,19 @@ +import { InsertNodeOperation } from 'slate' + +import { SyncDoc } from '../../model' +import { getParent, getChildren } from '../../path' +import { toSync } from '../../utils' + +const insertNode = (doc: SyncDoc, op: InsertNodeOperation): SyncDoc => { + const [parent, index] = getParent(doc, op.path) + + if (parent.text) { + throw new TypeError("Can't insert node into text node") + } + + getChildren(parent).splice(index, 0, toSync(op.node)) + + return doc +} + +export default insertNode diff --git a/packages/bridge/src/apply/node/mergeNode.ts b/packages/bridge/src/apply/node/mergeNode.ts new file mode 100644 index 0000000..cbb7beb --- /dev/null +++ b/packages/bridge/src/apply/node/mergeNode.ts @@ -0,0 +1,24 @@ +import { MergeNodeOperation, Node } from 'slate' + +import { SyncDoc } from '../../model' +import { getParent, getChildren } from '../../path' +import { toJS, cloneNode } from '../../utils' + +const mergeNode = (doc: SyncDoc, op: MergeNodeOperation): SyncDoc => { + const [parent, index]: [any, number] = getParent(doc, op.path) + + const prev = parent[index - 1] || parent.children[index - 1] + const next = parent[index] || parent.children[index] + + if (prev.text) { + prev.text.insertAt(prev.text.length, ...toJS(next.text).split('')) + } else { + getChildren(next).forEach((n: Node) => getChildren(prev).push(cloneNode(n))) + } + + getChildren(parent).deleteAt(index, 1) + + return doc +} + +export default mergeNode diff --git a/packages/bridge/src/apply/node/moveNode.ts b/packages/bridge/src/apply/node/moveNode.ts new file mode 100644 index 0000000..0d5b86f --- /dev/null +++ b/packages/bridge/src/apply/node/moveNode.ts @@ -0,0 +1,26 @@ +import { MoveNodeOperation } from 'slate' + +import { cloneNode } from '../../utils' +import { SyncDoc } from '../../model' +import { getParent, getChildren } from '../../path' + +const moveNode = (doc: SyncDoc, op: MoveNodeOperation): SyncDoc => { + const [from, fromIndex] = getParent(doc, op.path) + const [to, toIndex] = getParent(doc, op.newPath) + + if (from.text || to.text) { + throw new TypeError("Can't move node as child of a text node") + } + + getChildren(to).splice( + toIndex, + 0, + ...getChildren(from) + .splice(fromIndex, 1) + .map(cloneNode) + ) + + return doc +} + +export default moveNode diff --git a/packages/bridge/src/apply/node/removeNode.ts b/packages/bridge/src/apply/node/removeNode.ts new file mode 100644 index 0000000..5e9d0d1 --- /dev/null +++ b/packages/bridge/src/apply/node/removeNode.ts @@ -0,0 +1,18 @@ +import { RemoveNodeOperation } from 'slate' + +import { SyncDoc } from '../../model' +import { getParent, getChildren } from '../../path' + +export const removeNode = (doc: SyncDoc, op: RemoveNodeOperation): SyncDoc => { + const [parent, index] = getParent(doc, op.path) + + if (parent.text) { + throw new TypeError("Can't remove node from text node") + } + + getChildren(parent).splice(index, 1) + + return doc +} + +export default removeNode diff --git a/packages/bridge/src/apply/node/setNode.ts b/packages/bridge/src/apply/node/setNode.ts new file mode 100644 index 0000000..6af9b78 --- /dev/null +++ b/packages/bridge/src/apply/node/setNode.ts @@ -0,0 +1,18 @@ +import { SetNodeOperation } from 'slate' + +import { SyncDoc } from '../../model' +import { getTarget } from '../../path' + +const setNode = (doc: SyncDoc, op: SetNodeOperation): SyncDoc => { + const node = getTarget(doc, op.path) + + const { newProperties } = op + + for (let key in newProperties) { + node[key] = newProperties[key] + } + + return doc +} + +export default setNode diff --git a/packages/bridge/src/apply/node/splitNode.ts b/packages/bridge/src/apply/node/splitNode.ts new file mode 100644 index 0000000..5735f82 --- /dev/null +++ b/packages/bridge/src/apply/node/splitNode.ts @@ -0,0 +1,27 @@ +import { SplitNodeOperation } from 'slate' + +import { SyncDoc } from '../../model' +import { getParent, getChildren } from '../../path' +import { cloneNode } from '../../utils' + +const splitNode = (doc: SyncDoc, op: SplitNodeOperation): SyncDoc => { + const [parent, index]: [any, number] = getParent(doc, op.path) + + const target = getChildren(parent)[index] + const inject = cloneNode(target) + + if (target.text) { + target.text.length > op.position && + target.text.deleteAt(op.position, target.text.length - op.position) + op.position && inject.text.deleteAt(0, op.position) + } else { + target.children.splice(op.position, target.children.length - op.position) + op.position && inject.children.splice(0, op.position) + } + + getChildren(parent).insertAt(index + 1, inject) + + return doc +} + +export default splitNode diff --git a/packages/bridge/src/apply/text.ts b/packages/bridge/src/apply/text.ts index 830c3fa..15e8d9f 100644 --- a/packages/bridge/src/apply/text.ts +++ b/packages/bridge/src/apply/text.ts @@ -1,11 +1,12 @@ -import { SyncDoc } from '../model' import { InsertTextOperation, RemoveTextOperation } from 'slate' + import { getTarget } from '../path' +import { SyncDoc } from '../model' export const insertText = (doc: SyncDoc, op: InsertTextOperation): SyncDoc => { const node = getTarget(doc, op.path) - node.text.insertAt(op.offset, op.text) + node.text.insertAt(op.offset, ...op.text.split('')) return doc } diff --git a/packages/bridge/src/convert/convert.spec.ts b/packages/bridge/src/convert/convert.spec.ts index 3408323..5d5d5ed 100644 --- a/packages/bridge/src/convert/convert.spec.ts +++ b/packages/bridge/src/convert/convert.spec.ts @@ -1,15 +1,15 @@ import * as Automerge from 'automerge' import { toSlateOp } from './index' -import { createDoc, cloneDoc, createBlockJSON } from '../utils' +import { createDoc, cloneDoc, createNode } from '../utils' describe('convert operations to slatejs model', () => { it('convert insert operations', () => { const doc1 = createDoc() const doc2 = cloneDoc(doc1) - const change = Automerge.change(doc1, 'change', d => { - d.document.nodes.push(createBlockJSON('paragraph', 'hello!')) - d.document.nodes[1].nodes[0].text = 'hello!' + const change = Automerge.change(doc1, d => { + d.children.push(createNode('paragraph', 'hello!')) + d.children[1].children[0].text = 'hello!' }) const operations = Automerge.diff(doc2, change) @@ -20,12 +20,12 @@ describe('convert operations to slatejs model', () => { { type: 'insert_node', path: [1], - node: { object: 'block', type: 'paragraph', nodes: [] } + node: { type: 'paragraph', children: [] } }, { type: 'insert_node', path: [1, 0], - node: { object: 'text', marks: [], text: 'hello!' } + node: { text: 'hello!' } } ] @@ -33,17 +33,17 @@ describe('convert operations to slatejs model', () => { }) it('convert remove operations', () => { - const doc1 = Automerge.change(createDoc(), 'change', d => { - d.document.nodes.push(createBlockJSON('paragraph', 'hello!')) - d.document.nodes.push(createBlockJSON('paragraph', 'hello twice!')) - d.document.nodes[1].nodes[0].text = 'hello!' + const doc1 = Automerge.change(createDoc(), d => { + d.children.push(createNode('paragraph', 'hello!')) + d.children.push(createNode('paragraph', 'hello twice!')) + d.children[1].children[0].text = 'hello!' }) const doc2 = cloneDoc(doc1) - const change = Automerge.change(doc1, 'change', d => { - delete d.document.nodes[1] - delete d.document.nodes[0].nodes[0] + const change = Automerge.change(doc1, d => { + delete d.children[1] + delete d.children[0].children[0] }) const operations = Automerge.diff(doc2, change) @@ -55,14 +55,14 @@ describe('convert operations to slatejs model', () => { type: 'remove_node', path: [1], node: { - object: 'text' + text: '*' } }, { type: 'remove_node', path: [0, 0], node: { - object: 'text' + text: '*' } } ] diff --git a/packages/bridge/src/convert/create.ts b/packages/bridge/src/convert/create.ts index 9256fc4..2cb8135 100644 --- a/packages/bridge/src/convert/create.ts +++ b/packages/bridge/src/convert/create.ts @@ -1,8 +1,9 @@ import * as Automerge from 'automerge' -const createByType = type => (type === 'map' ? {} : type === 'list' ? [] : '') +const createByType = (type: any) => + type === 'map' ? {} : type === 'list' ? [] : '' -const opCreate = ({ obj, type }: Automerge.Diff, [map, ops]) => { +const opCreate = ({ obj, type }: Automerge.Diff, [map, ops]: any) => { map[obj] = createByType(type) return [map, ops] diff --git a/packages/bridge/src/convert/index.ts b/packages/bridge/src/convert/index.ts index 782e14c..a7a0422 100644 --- a/packages/bridge/src/convert/index.ts +++ b/packages/bridge/src/convert/index.ts @@ -1,4 +1,5 @@ import * as Automerge from 'automerge' +import { Node } from 'slate' import opInsert from './insert' import opRemove from './remove' @@ -14,11 +15,11 @@ const byAction = { const rootKey = '00000000-0000-0000-0000-000000000000' -const toSlateOp = (ops: Automerge.Diff[], doc) => { - const iterate = (acc, op) => { +const toSlateOp = (ops: Automerge.Diff[], doc: Automerge.Doc) => { + const iterate = (acc: [any, any[]], op: Automerge.Diff): any => { const action = byAction[op.action] - const result = action ? action(op, acc) : acc + const result = action ? action(op, acc, doc) : acc return result } diff --git a/packages/bridge/src/convert/insert.ts b/packages/bridge/src/convert/insert.ts index 5b5a8a7..95b3787 100644 --- a/packages/bridge/src/convert/insert.ts +++ b/packages/bridge/src/convert/insert.ts @@ -1,5 +1,8 @@ import * as Automerge from 'automerge' -import { toSlatePath, toJS } from '../utils/index' + +import { toSlatePath, toJS } from '../utils' + +import { SyncDoc } from '../model' const insertTextOp = ({ index, path, value }: Automerge.Diff) => () => ({ type: 'insert_text', @@ -9,32 +12,31 @@ const insertTextOp = ({ index, path, value }: Automerge.Diff) => () => ({ marks: [] }) -const insertNodeOp = ({ value, obj, index, path }: Automerge.Diff) => map => { - const ops = [] - - const iterate = ({ nodes, ...json }, path) => { - const node = nodes ? { ...json, nodes: [] } : json - - if (node.object) { - if (node.object === 'mark') { - ops.push({ - type: 'add_mark', - path: path.slice(0, -1), - mark: node - }) - } else { - ops.push({ - type: 'insert_node', - path, - node - }) - } - } +const insertNodeOp = ( + { value, obj, index, path }: Automerge.Diff, + doc: any +) => (map: any) => { + const ops: any = [] + + const iterate = ({ children, ...json }: any, path: any) => { + const node = children ? { ...json, children: [] } : json + + ops.push({ + type: 'insert_node', + path, + node + }) + + children && + children.forEach((n: any, i: any) => { + const node = map[n] || Automerge.getObjectById(doc, n) - nodes && nodes.forEach((n, i) => iterate(n, [...path, i])) + iterate((node && toJS(node)) || n, [...path, i]) + }) } - const source = map[value] || (map[obj] && toJS(map[obj])) + const source = + map[value] || toJS(map[obj] || Automerge.getObjectById(doc, value)) source && iterate(source, [...toSlatePath(path), index]) @@ -46,11 +48,11 @@ const insertByType = { list: insertNodeOp } -const opInsert = (op: Automerge.Diff, [map, ops]) => { +const opInsert = (op: Automerge.Diff, [map, ops]: any, doc: SyncDoc) => { try { const { link, obj, path, index, type, value } = op - if (link && map[obj]) { + if (link && map.hasOwnProperty(obj)) { map[obj].splice(index, 0, map[value] || value) } else if ((type === 'text' || type === 'list') && !path) { map[obj] = map[obj] @@ -62,7 +64,7 @@ const opInsert = (op: Automerge.Diff, [map, ops]) => { } else { const insert = insertByType[type] - const operation = insert && insert(op, map) + const operation = insert && insert(op, doc) ops.push(operation) } diff --git a/packages/bridge/src/convert/remove.ts b/packages/bridge/src/convert/remove.ts index 5b89b27..0c988d4 100644 --- a/packages/bridge/src/convert/remove.ts +++ b/packages/bridge/src/convert/remove.ts @@ -1,29 +1,20 @@ import * as Automerge from 'automerge' -import { toSlatePath, toJS } from '../utils/index' + +import { toSlatePath, toJS } from '../utils' import { getTarget } from '../path' const removeTextOp = ({ index, path }: Automerge.Diff) => () => ({ type: 'remove_text', - path: toSlatePath(path).slice(0, path.length), + path: toSlatePath(path).slice(0, path?.length), offset: index, text: '*', marks: [] }) -const removeMarkOp = ({ path, index }: Automerge.Diff) => (map, doc) => { - const slatePath = toSlatePath(path) - const target = getTarget(doc, slatePath) - - return { - type: 'remove_mark', - path: slatePath, - mark: { - type: target.marks[index].type - } - } -} - -const removeNodesOp = ({ index, obj, path }: Automerge.Diff) => (map, doc) => { +const removeNodeOp = ({ index, obj, path }: Automerge.Diff) => ( + map: any, + doc: any +) => { const slatePath = toSlatePath(path) if (!map.hasOwnProperty(obj)) { const target = getTarget(doc, [...slatePath, index] as any) @@ -35,34 +26,20 @@ const removeNodesOp = ({ index, obj, path }: Automerge.Diff) => (map, doc) => { type: 'remove_node', path: slatePath.length ? slatePath.concat(index) : [index], node: { - object: 'text' + text: '*' } } } -const removeAnnotationOp = ({ key }: Automerge.Diff) => (map, doc) => { - const annotation = toJS(doc.annotations[key]) - - if (annotation) { - return { - type: 'remove_annotation', - annotation - } - } -} - -const removeByType = { - text: removeTextOp, - nodes: removeNodesOp, - marks: removeMarkOp, - annotations: removeAnnotationOp -} - -const opRemove = (op: Automerge.Diff, [map, ops]) => { +const opRemove = (op: Automerge.Diff, [map, ops]: any) => { try { - const { index, path, obj } = op + const { index, path, obj, type } = op - if (map.hasOwnProperty(obj) && op.type !== 'text') { + if ( + map.hasOwnProperty(obj) && + typeof map[obj] !== 'string' && + type !== 'text' + ) { map[obj].splice(index, 1) return [map, ops] @@ -70,7 +47,11 @@ const opRemove = (op: Automerge.Diff, [map, ops]) => { if (!path) return [map, ops] - const fn = removeByType[path[path.length - 1]] + const key = path[path.length - 1] + + if (key === 'cursors') return [map, ops] + + const fn = key === 'text' ? removeTextOp : removeNodeOp return [map, [...ops, fn(op)]] } catch (e) { diff --git a/packages/bridge/src/convert/set.ts b/packages/bridge/src/convert/set.ts index 25451a0..d41c968 100644 --- a/packages/bridge/src/convert/set.ts +++ b/packages/bridge/src/convert/set.ts @@ -1,62 +1,31 @@ import * as Automerge from 'automerge' -import { toSlatePath, toJS } from '../utils/index' -const setDataOp = ({ path, value }: Automerge.Diff) => map => ({ - type: 'set_node', - path: toSlatePath(path), - properties: {}, - newProperties: { - data: map[value] - } -}) - -const AnnotationSetOp = ({ key, value }: Automerge.Diff) => (map, doc) => { - if (!doc.annotations) { - doc.annotations = {} - } - - let op - - /** - * Looks like set_annotation option is broken, temporary disabled - */ - - // if (!doc.annotations[key]) { - op = { - type: 'add_annotation', - annotation: map[value] +import { toSlatePath, toJS } from '../utils' + +const setDataOp = ( + { key = '', obj, path, value }: Automerge.Diff, + doc: any +) => (map: any) => { + return { + type: 'set_node', + path: toSlatePath(path), + properties: { + [key]: Automerge.getObjectById(doc, obj)?.[key] + }, + newProperties: { + [key]: value + } } - // } else { - // op = { - // type: 'set_annotation', - // properties: toJS(doc.annotations[key]), - // newProperties: map[value] - // } - // } - - return op -} - -const setByType = { - data: setDataOp } -const opSet = (op: Automerge.Diff, [map, ops]) => { +const opSet = (op: Automerge.Diff, [map, ops]: any, doc: any) => { const { link, value, path, obj, key } = op - try { - const set = setByType[key] - if (set && path) { - ops.push(set(op)) + try { + if (path && path[0] !== 'cursors') { + ops.push(setDataOp(op, doc)) } else if (map[obj]) { - map[obj][key] = link ? map[value] : value - } - - /** - * Annotation - */ - if (path && path.length === 1 && path[0] === 'annotations') { - ops.push(AnnotationSetOp(op)) + map[obj][key as any] = link ? map[value] : value } return [map, ops] diff --git a/packages/bridge/src/cursor/index.ts b/packages/bridge/src/cursor/index.ts index fd191f6..c4693e7 100644 --- a/packages/bridge/src/cursor/index.ts +++ b/packages/bridge/src/cursor/index.ts @@ -1,68 +1,35 @@ -import { Selection } from 'slate' -import merge from 'lodash/merge' +import { Operation, Range } from 'slate' -import { toJS } from '../utils' -import { SyncDoc, CursorKey } from '../model' +import { CursorData } from '../model' export const setCursor = ( - doc: SyncDoc, - key: CursorKey, - selection: Selection, - type, - data + id: string, + selection: Range | null, + doc: any, + operations: Operation[], + cursorData: CursorData ) => { - if (!doc) return - - if (!doc.annotations) { - doc.annotations = {} - } - - if (!doc.annotations[key]) { - doc.annotations[key] = { - key, - type, - data: {} - } - } - - const annotation = toJS(doc.annotations[key]) - - annotation.focus = selection.end.toJSON() - annotation.anchor = selection.start.toJSON() - - annotation.data = merge(annotation.data, data, { - isBackward: selection.isBackward, - targetPath: selection.isBackward - ? annotation.anchor.path - : annotation.focus.path - }) - - doc.annotations[key] = annotation - - return doc -} - -export const removeCursor = (doc: SyncDoc, key: CursorKey) => { - if (doc.annotations && doc.annotations[key]) { - delete doc.annotations[key] + const cursorOps = operations.filter(op => op.type === 'set_selection') + + if (!doc.cursors) doc.cursors = {} + + const newCursor = cursorOps[cursorOps.length - 1]?.newProperties || {} + + if (selection) { + doc.cursors[id] = JSON.stringify( + Object.assign( + (doc.cursors[id] && JSON.parse(doc.cursors[id])) || {}, + newCursor, + selection, + { + ...cursorData, + isForward: Boolean(newCursor.focus) + } + ) + ) + } else { + delete doc.cursors[id] } return doc } - -export const cursorOpFilter = (ops, type: string) => - ops.filter(op => { - if (op.type === 'set_annotation') { - return !( - (op.properties && op.properties.type === type) || - (op.newProperties && op.newProperties.type === type) - ) - } else if ( - op.type === 'add_annotation' || - op.type === 'remove_annotation' - ) { - return op.annotation.type !== type - } - - return true - }) diff --git a/packages/bridge/src/model/automerge.ts b/packages/bridge/src/model/automerge.ts deleted file mode 100644 index 219a4f0..0000000 --- a/packages/bridge/src/model/automerge.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ValueJSON } from 'slate' - -export type CursorKey = string - -export interface SyncDoc extends ValueJSON {} diff --git a/packages/bridge/src/model/index.ts b/packages/bridge/src/model/index.ts index 0771630..7114dc0 100644 --- a/packages/bridge/src/model/index.ts +++ b/packages/bridge/src/model/index.ts @@ -1,2 +1,23 @@ -export * from './automerge' -export * from './slate' +import Automerge from 'automerge' +import { Node, Range } from 'slate' + +export type SyncDoc = Automerge.Doc + +export type CollabActionType = 'operation' | 'document' + +export interface CollabAction { + type: CollabActionType + payload: any +} + +export interface CursorData { + [key: string]: any +} + +export interface Cursor extends Range, CursorData { + isForward: boolean +} + +export interface Cursors { + [key: string]: Cursor +} diff --git a/packages/bridge/src/model/slate.ts b/packages/bridge/src/model/slate.ts index 859b13a..88ab58a 100644 --- a/packages/bridge/src/model/slate.ts +++ b/packages/bridge/src/model/slate.ts @@ -1,8 +1,5 @@ -import { Operation, NodeJSON } from 'slate' -import { List } from 'immutable' +import { Operation, Path, NodeEntry } from 'slate' -export type Operations = List -export type SyncNode = NodeJSON -export type Path = List +export type SyncNode = NodeEntry -export { Operation } +export { Operation, Path } diff --git a/packages/bridge/src/path/index.ts b/packages/bridge/src/path/index.ts index 94491a0..11c936b 100644 --- a/packages/bridge/src/path/index.ts +++ b/packages/bridge/src/path/index.ts @@ -1,38 +1,42 @@ -import { SyncDoc, Path } from '../model' -import { NodeJSON } from 'slate' +import { Node, Path } from 'slate' -export const isTree = (node: NodeJSON): any => node && node.object !== 'text' +import { SyncDoc } from '../model' + +export const isTree = (node: Node): boolean => Boolean(node?.children) export const getTarget = (doc: SyncDoc, path: Path) => { const iterate = (current: any, idx: number) => { - if (!isTree(current) || !current.nodes) { + if (!(isTree(current) || current[idx])) { throw new TypeError( `path ${path.toString()} does not match tree ${JSON.stringify(current)}` ) } - return current.nodes[idx] + return current[idx] || current?.children[idx] } - return path.reduce(iterate, doc.document) + return path.reduce(iterate, doc) } export const getParentPath = ( path: Path, level: number = 1 ): [number, Path] => { - if (level > path.size) { + if (level > path.length) { throw new TypeError('requested ancestor is higher than root') } - return [path.get(path.size - level), path.slice(0, path.size - level) as Path] + return [path[path.length - level], path.slice(0, path.length - level)] } export const getParent = ( doc: SyncDoc, path: Path, level = 1 -): [NodeJSON, number] => { +): [any, number] => { const [idx, parentPath] = getParentPath(path, level) + return [getTarget(doc, parentPath), idx] } + +export const getChildren = (node: Node) => node.children || node diff --git a/packages/bridge/src/utils/index.ts b/packages/bridge/src/utils/index.ts index d0368fd..137a2c4 100644 --- a/packages/bridge/src/utils/index.ts +++ b/packages/bridge/src/utils/index.ts @@ -1,9 +1,11 @@ import toSync from './toSync' import hexGen from './hexGen' +import { CollabAction } from '../model' + export * from './testUtils' -const toJS = node => { +const toJS = (node: any) => { try { return JSON.parse(JSON.stringify(node)) } catch (e) { @@ -12,8 +14,13 @@ const toJS = node => { } } -const cloneNode = node => toSync(toJS(node)) +const cloneNode = (node: any) => toSync(toJS(node)) + +const toSlatePath = (path: any) => + path ? path.filter((d: any) => Number.isInteger(d)) : [] -const toSlatePath = path => (path ? path.filter(d => Number.isInteger(d)) : []) +const toCollabAction = (type: any, fn: (action: CollabAction) => void) => ( + payload: any +) => fn({ type, payload }) -export { toSync, toJS, toSlatePath, hexGen, cloneNode } +export { toSync, toJS, toSlatePath, hexGen, cloneNode, toCollabAction } diff --git a/packages/bridge/src/utils/testUtils.ts b/packages/bridge/src/utils/testUtils.ts index 3ae84d7..9363029 100644 --- a/packages/bridge/src/utils/testUtils.ts +++ b/packages/bridge/src/utils/testUtils.ts @@ -1,27 +1,28 @@ import * as Automerge from 'automerge' -import { TextJSON } from 'slate' -export const createTextJSON = (text: string = ''): TextJSON => ({ - object: 'text', - marks: [], +import { toSync } from '../' + +import { Node } from 'slate' + +export const createText = (text: string = '') => ({ text }) -export const createBlockJSON = ( +export const createNode = ( type: string = 'paragraph', - text: string = '' + text: string = '', + data?: { [key: string]: any } ) => ({ - object: 'block', type, - nodes: [createTextJSON(text)] + children: [createText(text)], + ...data }) -export const createValueJSON = () => ({ - document: { - nodes: [createBlockJSON()] - } +export const createValue = (children?: any): { children: Node[] } => ({ + children: children || [createNode()] }) -export const createDoc = () => Automerge.from(createValueJSON()) +export const createDoc = (children?: any) => + Automerge.from(toSync(createValue(children))) -export const cloneDoc = doc => Automerge.change(doc, '', d => d) +export const cloneDoc = (doc: any) => Automerge.change(doc, '', d => d) diff --git a/packages/bridge/src/utils/toSync.ts b/packages/bridge/src/utils/toSync.ts index a0f51f8..033eac9 100644 --- a/packages/bridge/src/utils/toSync.ts +++ b/packages/bridge/src/utils/toSync.ts @@ -1,6 +1,6 @@ import * as Automerge from 'automerge' -const toSync = node => { +const toSync = (node: any) => { if (!node) { return } @@ -10,20 +10,10 @@ const toSync = node => { ...node, text: new Automerge.Text(node.text) } - } else if (node.nodes) { + } else if (node.children) { return { ...node, - nodes: node.nodes.map(toSync) - } - } else if (node.leaves) { - return { - ...node, - leaves: node.leaves.map(toSync) - } - } else if (node.document) { - return { - ...node, - document: toSync(node.document) + children: node.children.map(toSync) } } diff --git a/packages/bridge/tsconfig.json b/packages/bridge/tsconfig.json index 2cf697c..f518710 100644 --- a/packages/bridge/tsconfig.json +++ b/packages/bridge/tsconfig.json @@ -1,11 +1,9 @@ { - "include": ["src/**/*"], "extends": "../../tsconfig.base.json", + "include": ["./src/**/*"], "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], - "outDir": "lib", - "rootDir": "src", - "baseUrl": "src", + "rootDir": "./src", + "outDir": "./lib", "composite": true } } diff --git a/packages/client/.babelrc b/packages/client/.babelrc index 975d19a..cc4cab9 100644 --- a/packages/client/.babelrc +++ b/packages/client/.babelrc @@ -2,6 +2,7 @@ "presets": ["@babel/env", "@babel/react", "@babel/typescript"], "plugins": [ "@babel/proposal-class-properties", - "@babel/proposal-object-rest-spread" + "@babel/proposal-object-rest-spread", + "@babel/plugin-proposal-optional-chaining" ] } diff --git a/packages/client/.npmrc b/packages/client/.npmrc deleted file mode 100644 index a21347f..0000000 --- a/packages/client/.npmrc +++ /dev/null @@ -1 +0,0 @@ -scripts-prepend-node-path=true \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json index 126afce..85e1bdc 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@slate-collaborative/client", - "version": "0.0.3", + "version": "0.5.0", "files": [ "lib" ], @@ -24,14 +24,13 @@ "watch": "yarn build:js -w" }, "dependencies": { + "@babel/plugin-proposal-optional-chaining": "^7.9.0", "@babel/preset-react": "^7.0.0", - "@slate-collaborative/bridge": "^0.0.3", - "automerge": "^0.12.1", - "immutable": "^4.0.0-rc.12", - "react": "^16.9.0", - "slate": "^0.47.8", - "socket.io-client": "^2.2.0", - "typescript": "^3.6.3" + "@slate-collaborative/bridge": "^0.5.0", + "automerge": "^0.14.0", + "slate": "^0.57.2", + "socket.io-client": "^2.3.0", + "typescript": "^3.8.3" }, "devDependencies": { "@babel/cli": "^7.6.0", @@ -40,8 +39,7 @@ "@babel/plugin-proposal-object-rest-spread": "^7.5.5", "@babel/preset-env": "^7.6.0", "@babel/preset-typescript": "^7.6.0", - "@types/react": "^16.9.2", - "@types/slate": "^0.47.1", + "@types/react": "^16.9.34", "@types/socket.io-client": "^1.4.32" }, "directories": { diff --git a/packages/client/src/Connection.ts b/packages/client/src/Connection.ts deleted file mode 100644 index f8edeb9..0000000 --- a/packages/client/src/Connection.ts +++ /dev/null @@ -1,206 +0,0 @@ -import Automerge from 'automerge' -import Immutable from 'immutable' -import io from 'socket.io-client' - -import { Value, Operation } from 'slate' -import { ConnectionModel, ExtendedEditor } from './model' - -import { - setCursor, - removeCursor, - cursorOpFilter, - applySlateOps, - toSlateOp, - toJS -} from '@slate-collaborative/bridge' - -class Connection { - url: string - docId: string - docSet: Automerge.DocSet - connection: Automerge.Connection - socket: SocketIOClient.Socket - editor: ExtendedEditor - connectOpts: any - annotationDataMixin: any - cursorAnnotationType: string - onConnect?: () => void - onDisconnect?: () => void - - constructor({ - editor, - url, - connectOpts, - onConnect, - onDisconnect, - cursorAnnotationType, - annotationDataMixin - }: ConnectionModel) { - this.url = url - this.editor = editor - this.connectOpts = connectOpts - this.cursorAnnotationType = cursorAnnotationType - this.annotationDataMixin = annotationDataMixin - - this.onConnect = onConnect - this.onDisconnect = onDisconnect - - this.docId = connectOpts.path || new URL(url).pathname - - this.docSet = new Automerge.DocSet() - - this.connect() - - return this - } - - sendData = (data: any) => { - this.socket.emit('operation', data) - } - - recieveData = async (data: any) => { - if (this.docId !== data.docId || !this.connection) { - return - } - - try { - const currentDoc = this.docSet.getDoc(this.docId) - const docNew = this.connection.receiveMsg(data) - - if (!docNew) { - return - } - - const operations = Automerge.diff(currentDoc, docNew) - - if (operations.length !== 0) { - const slateOps = toSlateOp(operations, currentDoc) - - this.editor.remote = true - - this.editor.withoutSaving(() => { - slateOps.forEach(o => { - this.editor.applyOperation(o) - }) - }) - - await Promise.resolve() - - this.editor.remote = false - - this.garbageCursors() - } - } catch (e) { - console.error(e) - } - } - - garbageCursors = async () => { - const doc = this.docSet.getDoc(this.docId) - const { value } = this.editor - - if (value.annotations.size === Object.keys(doc.annotations).length) { - return - } - - const garbage = [] - - value.annotations.forEach(annotation => { - if ( - annotation.type === this.cursorAnnotationType && - !doc.annotations[annotation.key] - ) { - garbage.push(annotation) - } - }) - - if (garbage.length) { - this.editor.withoutSaving(() => { - garbage.forEach(annotation => { - this.editor.removeAnnotation(annotation) - }) - }) - } - } - - receiveSlateOps = (operations: Immutable.List) => { - const doc = this.docSet.getDoc(this.docId) - const message = `change from ${this.socket.id}` - - if (!doc) return - - const { - value: { selection } - } = this.editor - - const withCursor = selection.isFocused ? setCursor : removeCursor - - const changed = Automerge.change(doc, message, (d: any) => - withCursor( - applySlateOps(d, cursorOpFilter(operations, this.cursorAnnotationType)), - this.socket.id, - selection, - this.cursorAnnotationType, - this.annotationDataMixin - ) - ) - - this.docSet.setDoc(this.docId, changed) - } - - recieveDocument = data => { - const currentDoc = this.docSet.getDoc(this.docId) - - if (!currentDoc) { - const doc = Automerge.load(data) - - this.docSet.removeDoc(this.docId) - - this.docSet.setDoc(this.docId, doc) - - this.editor.controller.setValue(Value.fromJSON(toJS(doc))) - } - - this.editor.setFocus() - - this.connection.open() - - this.onConnect && setTimeout(this.onConnect, 0) - } - - connect = () => { - this.socket = io(this.url, { ...this.connectOpts }) - - this.socket.on('connect', () => { - this.connection = new Automerge.Connection(this.docSet, this.sendData) - - this.socket.on('document', this.recieveDocument) - - this.socket.on('operation', this.recieveData) - - this.socket.on('disconnect', this.disconnect) - }) - } - - disconnect = () => { - this.onDisconnect() - - console.log('disconnect', this.socket) - - this.connection && this.connection.close() - - delete this.connection - - this.socket.removeListener('document') - this.socket.removeListener('operation') - } - - close = () => { - this.onDisconnect() - - this.socket.close() - // this.socket.destroy() - } -} - -export default Connection diff --git a/packages/client/src/Controller.tsx b/packages/client/src/Controller.tsx deleted file mode 100644 index 17c2e40..0000000 --- a/packages/client/src/Controller.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { Component } from 'react' -import { KeyUtils } from 'slate' - -import { hexGen } from '@slate-collaborative/bridge' - -import Connection from './Connection' -import { ControllerProps } from './model' - -class Controller extends Component { - connection?: Connection - - state = { - preloading: true - } - - componentDidMount() { - const { - editor, - url, - cursorAnnotationType, - annotationDataMixin, - connectOpts - } = this.props - - KeyUtils.setGenerator(() => hexGen()) - - editor.connection = new Connection({ - editor, - url, - connectOpts, - cursorAnnotationType, - annotationDataMixin, - onConnect: this.onConnect, - onDisconnect: this.onDisconnect - }) - } - - componentWillUnmount() { - const { editor } = this.props - - if (editor.connection) editor.connection.close() - - delete editor.connection - } - - render() { - const { children, renderPreloader } = this.props - const { preloading } = this.state - - if (renderPreloader && preloading) return renderPreloader() - - return children - } - - onConnect = () => { - const { onConnect, editor } = this.props - - this.setState({ - preloading: false - }) - - onConnect && onConnect(editor.connection) - } - - onDisconnect = () => { - const { onDisconnect, editor } = this.props - - this.setState({ - preloading: true - }) - - onDisconnect && onDisconnect(editor.connection) - } -} - -export default Controller diff --git a/packages/client/src/automerge-editor.ts b/packages/client/src/automerge-editor.ts new file mode 100644 index 0000000..55404c6 --- /dev/null +++ b/packages/client/src/automerge-editor.ts @@ -0,0 +1,156 @@ +import Automerge from 'automerge' + +import { Editor, Operation } from 'slate' + +import { + toJS, + SyncDoc, + CollabAction, + toCollabAction, + applyOperation, + setCursor, + toSlateOp, + CursorData +} from '@slate-collaborative/bridge' + +export interface AutomergeEditor extends Editor { + clientId: string + + isRemote: boolean + + docSet: Automerge.DocSet + connection: Automerge.Connection + + onConnectionMsg: (msg: Automerge.Message) => void + + openConnection: () => void + closeConnection: () => void + + receiveDocument: (data: string) => void + receiveOperation: (data: Automerge.Message) => void + + gabageCursor: () => void + + onCursor: (data: any) => void +} + +/** + * `AutomergeEditor` contains methods for collaboration-enabled editors. + */ + +export const AutomergeEditor = { + /** + * Create Automerge connection + */ + + createConnection: (e: AutomergeEditor, emit: (data: CollabAction) => void) => + new Automerge.Connection(e.docSet, toCollabAction('operation', emit)), + + /** + * Apply Slate operations to Automerge + */ + + applySlateOps: async ( + e: AutomergeEditor, + docId: string, + operations: Operation[], + cursorData?: CursorData + ) => { + try { + const doc = e.docSet.getDoc(docId) + + if (!doc) { + throw new TypeError(`Unknown docId: ${docId}!`) + } + + let changed + + for await (let op of operations) { + changed = Automerge.change(changed || doc, d => + applyOperation(d.children, op) + ) + } + + changed = Automerge.change(changed || doc, d => { + setCursor(e.clientId, e.selection, d, operations, cursorData || {}) + }) + + e.docSet.setDoc(docId, changed as any) + } catch (e) { + console.error(e) + } + }, + + /** + * Receive and apply document to Automerge docSet + */ + + receiveDocument: (e: AutomergeEditor, docId: string, data: string) => { + const currentDoc = e.docSet.getDoc(docId) + + const externalDoc = Automerge.load(data) + + const mergedDoc = Automerge.merge( + externalDoc, + currentDoc || Automerge.init() + ) + + e.docSet.setDoc(docId, mergedDoc) + + Editor.withoutNormalizing(e, () => { + e.children = toJS(mergedDoc).children + + e.onChange() + }) + }, + + /** + * Generate automerge diff, convert and apply operations to Editor + */ + + applyOperation: ( + e: AutomergeEditor, + docId: string, + data: Automerge.Message + ) => { + try { + const current: any = e.docSet.getDoc(docId) + + const updated = e.connection.receiveMsg(data) + + const operations = Automerge.diff(current, updated) + + if (operations.length) { + const slateOps = toSlateOp(operations, current) + + e.isRemote = true + + Editor.withoutNormalizing(e, () => { + slateOps.forEach((o: Operation) => { + e.apply(o) + }) + }) + + e.onCursor && e.onCursor(updated.cursors) + + Promise.resolve().then(_ => (e.isRemote = false)) + } + } catch (e) { + console.error(e) + } + }, + + garbageCursor: (e: AutomergeEditor, docId: string) => { + const doc = e.docSet.getDoc(docId) + + const changed = Automerge.change(doc, d => { + delete d.cusors + }) + + e.onCursor && e.onCursor(null) + + e.docSet.setDoc(docId, changed) + + e.onChange() + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..91d7f3f --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,6 @@ +import useCursor from './useCursor' +import withAutomerge from './withAutomerge' +import withSocketIO from './withSocketIO' +import withIOCollaboration from './withIOCollaboration' + +export { withAutomerge, withSocketIO, withIOCollaboration, useCursor } diff --git a/packages/client/src/index.tsx b/packages/client/src/index.tsx deleted file mode 100644 index 0623a68..0000000 --- a/packages/client/src/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import onChange from './onChange' -import renderEditor from './renderEditor' -import renderAnnotation from './renderAnnotation' - -import renderCursor from './renderCursor' - -import { PluginOptions } from './model' - -export const defaultOpts = { - url: 'http://localhost:9000', - cursorAnnotationType: 'collaborative_selection', - renderCursor, - annotationDataMixin: { - name: 'an collaborator name', - color: 'palevioletred', - alphaColor: 'rgba(233, 30, 99, 0.2)' - } -} - -const plugin = (opts: PluginOptions = defaultOpts) => { - const options = { ...defaultOpts, ...opts } - - return { - onChange: onChange(options), - renderEditor: renderEditor(options), - renderAnnotation: renderAnnotation(options) - } -} - -export default plugin diff --git a/packages/client/src/model.ts b/packages/client/src/model.ts deleted file mode 100644 index ba98209..0000000 --- a/packages/client/src/model.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ReactNode } from 'react' -import { Editor, Controller, Value } from 'slate' - -import Connection from './Connection' - -type Data = { - [key: string]: any -} - -interface FixedController extends Controller { - setValue: (value: Value) => void -} - -export interface ExtendedEditor extends Editor { - remote?: boolean - connection?: Connection - controller: FixedController - setFocus: () => void -} - -export interface ConnectionModel extends PluginOptions { - editor: ExtendedEditor - cursorAnnotationType: string - onConnect: () => void - onDisconnect: () => void -} - -export interface ControllerProps extends PluginOptions { - editor: ExtendedEditor - url?: string - connectOpts?: SocketIOClient.ConnectOpts -} - -export interface PluginOptions { - url?: string - connectOpts?: SocketIOClient.ConnectOpts - cursorAnnotationType?: string - annotationDataMixin?: Data - renderPreloader?: () => ReactNode - renderCursor?: (data: Data) => ReactNode | any - onConnect?: (connection: Connection) => void - onDisconnect?: (connection: Connection) => void -} diff --git a/packages/client/src/onChange.ts b/packages/client/src/onChange.ts deleted file mode 100644 index 3632c16..0000000 --- a/packages/client/src/onChange.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ExtendedEditor } from './model' - -const onChange = opts => (editor: ExtendedEditor, next: () => void) => { - if (editor.connection && !editor.remote) { - const operations: any = editor.operations - - editor.connection.receiveSlateOps(operations) - } - - return next() -} - -export default onChange diff --git a/packages/client/src/renderAnnotation.tsx b/packages/client/src/renderAnnotation.tsx deleted file mode 100644 index 4114f0d..0000000 --- a/packages/client/src/renderAnnotation.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react' - -const renderAnnotation = ({ cursorAnnotationType, renderCursor }) => ( - props, - editor, - next -) => { - const { children, annotation, attributes, node } = props - - if (annotation.type !== cursorAnnotationType) return next() - - const data = annotation.data.toJS() - - const { targetPath, alphaColor } = data - const { document } = editor.value - - const targetNode = document.getNode(targetPath) - const showCursor = targetNode && targetNode.key === node.key - - return ( - - {showCursor ? renderCursor(data) : null} - {children} - - ) -} - -export default renderAnnotation diff --git a/packages/client/src/renderCursor.tsx b/packages/client/src/renderCursor.tsx deleted file mode 100644 index d70c4f4..0000000 --- a/packages/client/src/renderCursor.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react' - -import Cursor from './Cursor' - -const renderCursor = data => - -export default renderCursor diff --git a/packages/client/src/renderEditor.tsx b/packages/client/src/renderEditor.tsx deleted file mode 100644 index 7355c6c..0000000 --- a/packages/client/src/renderEditor.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react' - -import { PluginOptions } from './model' -import Controller from './Controller' - -const renderEditor = (opts: PluginOptions) => ( - props: any, - editor: any, - next: any -) => { - const children = next() - - return ( - - {children} - - ) -} - -export default renderEditor diff --git a/packages/client/src/useCursor.ts b/packages/client/src/useCursor.ts new file mode 100644 index 0000000..31fa037 --- /dev/null +++ b/packages/client/src/useCursor.ts @@ -0,0 +1,68 @@ +import { useState, useCallback, useEffect, useMemo } from 'react' + +import { Text, Range, Path, NodeEntry } from 'slate' + +import { toJS, Cursor, Cursors } from '@slate-collaborative/bridge' + +import { AutomergeEditor } from './automerge-editor' + +const useCursor = ( + e: AutomergeEditor +): { decorate: (entry: NodeEntry) => Range[]; cursors: Cursor[] } => { + const [cursorData, setSursorData] = useState([]) + + useEffect(() => { + e.onCursor = (data: Cursors) => { + const ranges: Cursor[] = [] + + const cursors = toJS(data) + + for (let cursor in cursors) { + if (cursor !== e.clientId && cursors[cursor]) { + ranges.push(JSON.parse(cursors[cursor])) + } + } + + setSursorData(ranges) + } + }, []) + + const cursors = useMemo(() => cursorData, [cursorData]) + + const decorate = useCallback( + ([node, path]: NodeEntry) => { + const ranges: Range[] = [] + + if (Text.isText(node) && cursors?.length) { + cursors.forEach(cursor => { + if (Range.includes(cursor, path)) { + const { focus, anchor, isForward } = cursor + + ranges.push({ + ...cursor, + isCaret: isForward + ? Path.equals(focus.path, path) + : Path.equals(anchor.path, path), + anchor: Path.isBefore(anchor.path, path) + ? { ...anchor, offset: 0 } + : anchor, + focus: Path.isAfter(focus.path, path) + ? { ...focus, offset: node.text.length } + : focus + }) + } + }) + } + + return ranges + }, + [cursors] + ) + + return { + cursors, + decorate + } +} + +export default useCursor diff --git a/packages/client/src/withAutomerge.ts b/packages/client/src/withAutomerge.ts new file mode 100644 index 0000000..f675840 --- /dev/null +++ b/packages/client/src/withAutomerge.ts @@ -0,0 +1,105 @@ +import Automerge from 'automerge' + +import { Editor } from 'slate' + +import { AutomergeEditor } from './automerge-editor' + +import { CursorData, CollabAction } from '@slate-collaborative/bridge' + +export interface AutomergeOptions { + docId: string + cursorData?: CursorData +} + +/** + * The `withAutomerge` plugin contains core collaboration logic. + */ + +const withAutomerge = ( + editor: T, + options: AutomergeOptions +) => { + const e = editor as T & AutomergeEditor + + const { onChange } = e + + const { docId, cursorData } = options || {} + + e.docSet = new Automerge.DocSet() + + const createConnection = () => { + if (e.connection) e.connection.close() + + e.connection = AutomergeEditor.createConnection(e, (data: CollabAction) => + e.send(data) + ) + + e.connection.open() + } + + createConnection() + + /** + * Open Automerge Connection + */ + + e.openConnection = () => { + e.connection.open() + } + + /** + * Close Automerge Connection + */ + + e.closeConnection = () => { + e.connection.close() + } + + /** + * Clear cursor data + */ + + e.gabageCursor = () => { + AutomergeEditor.garbageCursor(e, docId) + } + + /** + * Editor onChange + */ + + e.onChange = () => { + const operations: any = e.operations + + if (!e.isRemote) { + AutomergeEditor.applySlateOps(e, docId, operations, cursorData) + } + + onChange() + + // console.log('e', e.children) + } + + /** + * Receive document value + */ + + e.receiveDocument = data => { + AutomergeEditor.receiveDocument(e, docId, data) + + createConnection() + } + + /** + * Receive Automerge sync operations + */ + + e.receiveOperation = data => { + if (docId !== data.docId) return + + AutomergeEditor.applyOperation(e, docId, data) + } + + return e +} + +export default withAutomerge diff --git a/packages/client/src/withIOCollaboration.ts b/packages/client/src/withIOCollaboration.ts new file mode 100644 index 0000000..cd18012 --- /dev/null +++ b/packages/client/src/withIOCollaboration.ts @@ -0,0 +1,20 @@ +import { Editor } from 'slate' +import { AutomergeEditor } from './automerge-editor' + +import withAutomerge, { AutomergeOptions } from './withAutomerge' +import withSocketIO, { + WithSocketIOEditor, + SocketIOPluginOptions +} from './withSocketIO' + +/** + * The `withIOCollaboration` plugin contains collaboration with SocketIO. + */ + +const withIOCollaboration = ( + editor: T, + options: AutomergeOptions & SocketIOPluginOptions +): T & WithSocketIOEditor & AutomergeEditor => + withSocketIO(withAutomerge(editor, options), options) + +export default withIOCollaboration diff --git a/packages/client/src/withSocketIO.ts b/packages/client/src/withSocketIO.ts new file mode 100644 index 0000000..e06dc80 --- /dev/null +++ b/packages/client/src/withSocketIO.ts @@ -0,0 +1,122 @@ +import io from 'socket.io-client' + +import { AutomergeEditor } from './automerge-editor' + +import { CollabAction } from '@slate-collaborative/bridge' + +export interface SocketIOPluginOptions { + url: string + connectOpts: SocketIOClient.ConnectOpts + autoConnect?: boolean + + onConnect?: () => void + onDisconnect?: () => void +} + +export interface WithSocketIOEditor { + socket: SocketIOClient.Socket + + connect: () => void + disconnect: () => void + + send: (op: CollabAction) => void + receive: (op: CollabAction) => void + + destroy: () => void +} + +/** + * The `withSocketIO` plugin contains SocketIO layer logic. + */ + +const withSocketIO = ( + editor: T, + options: SocketIOPluginOptions +) => { + const e = editor as T & WithSocketIOEditor + + const { onConnect, onDisconnect, connectOpts, url, autoConnect } = options + + /** + * Connect to Socket. + */ + + e.connect = () => { + if (!e.socket) { + e.socket = io(url, { ...connectOpts }) + + e.socket.on('connect', () => { + e.clientId = e.socket.id + + e.openConnection() + + onConnect && onConnect() + }) + } + + e.socket.on('msg', (data: CollabAction) => { + e.receive(data) + }) + + e.socket.on('disconnect', () => { + e.gabageCursor() + + onDisconnect && onDisconnect() + }) + + e.socket.connect() + + return e + } + + /** + * Disconnect from Socket. + */ + + e.disconnect = () => { + e.socket.removeListener('msg') + + e.socket.close() + + e.closeConnection() + + return e + } + + /** + * Receive transport msg. + */ + + e.receive = (msg: CollabAction) => { + switch (msg.type) { + case 'operation': + return e.receiveOperation(msg.payload) + case 'document': + return e.receiveDocument(msg.payload) + } + } + + /** + * Send message to socket. + */ + + e.send = (msg: CollabAction) => { + e.socket.emit('msg', msg) + } + + /** + * Close socket and connection. + */ + + e.destroy = () => { + e.socket.close() + + e.closeConnection() + } + + autoConnect && e.connect() + + return e +} + +export default withSocketIO diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 2c802cc..f9e898b 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -1,12 +1,16 @@ { - "include": ["src/**/*"], "extends": "../../tsconfig.base.json", + "include": ["./src/**/*"], "compilerOptions": { - "outDir": "lib", - "rootDir": "src", - "baseUrl": "src", - "jsx": "react", - "lib": ["dom", "dom.iterable", "es6"], - "esModuleInterop": true - } + "rootDir": "./src", + "baseUrl": "./src", + "outDir": "./lib", + "composite": true, + "paths": { + "@slate-collaborative/bridge": ["../../bridge"] + } + }, + "references": [ + { "path": "../bridge" } + ] } diff --git a/packages/example/.gitignore b/packages/example/.gitignore deleted file mode 100644 index 4d29575..0000000 --- a/packages/example/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# production -/build - -# misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* diff --git a/packages/example/extend.tsconfig.json b/packages/example/extend.tsconfig.json new file mode 100644 index 0000000..31305f7 --- /dev/null +++ b/packages/example/extend.tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@slate-collaborative/bridge": ["../../bridge"], + "@slate-collaborative/client": ["../../client"] + } + } + } \ No newline at end of file diff --git a/packages/example/package.json b/packages/example/package.json index 7494c8d..83a77f5 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -1,18 +1,17 @@ { "name": "@slate-collaborative/example", - "version": "0.0.1", + "version": "0.5.0", "private": true, "dependencies": { "@emotion/core": "^10.0.17", "@emotion/styled": "^10.0.17", - "@slate-collaborative/backend": "^0.0.3", - "@slate-collaborative/client": "^0.0.3", + "@slate-collaborative/backend": "^0.5.0", + "@slate-collaborative/client": "^0.5.0", "@types/faker": "^4.1.5", "@types/jest": "24.0.18", "@types/node": "12.7.5", - "@types/react": "16.9.2", - "@types/react-dom": "16.9.0", - "@types/slate-react": "^0.22.5", + "@types/randomcolor": "^0.5.4", + "@types/react-dom": "^16.9.6", "concurrently": "^4.1.2", "cross-env": "^6.0.3", "express": "^4.17.1", @@ -23,14 +22,15 @@ "react": "^16.9.0", "react-dom": "^16.9.0", "react-scripts": "3.1.2", - "slate": "^0.47.8", - "slate-react": "^0.22.8", - "typescript": "3.6.3" + "slate": "^0.57.2", + "slate-history": "^0.57.2", + "slate-react": "^0.57.2", + "typescript": "^3.8.3" }, "scripts": { "start": "node server.js", "start:cra": "react-scripts start", - "build": "cross-env NODE_ENV=production && react-scripts build", + "build:example": "cross-env NODE_ENV=production && react-scripts build", "dev": "concurrently \"yarn start:cra\" \"yarn serve\"", "serve": "nodemon --watch ../backend/lib --inspect server.js" }, diff --git a/packages/example/public/index.html b/packages/example/public/index.html index 2a5dbe4..1a294f3 100644 --- a/packages/example/public/index.html +++ b/packages/example/public/index.html @@ -10,7 +10,13 @@ content="Web site created using create-react-app" /> + + + Slate collaborative diff --git a/packages/example/server.js b/packages/example/server.js index 208f164..9cca1b6 100644 --- a/packages/example/server.js +++ b/packages/example/server.js @@ -1,7 +1,17 @@ -const Connection = require('@slate-collaborative/backend') -const defaultValue = require('./src/defaultValue') +const { SocketIOConnection } = require('@slate-collaborative/backend') const express = require('express') +const defaultValue = [ + { + type: 'paragraph', + children: [ + { + text: 'Hello collaborator!' + } + ] + } +] + const PORT = process.env.PORT || 9000 const server = express() @@ -11,18 +21,19 @@ const server = express() const config = { entry: server, // or specify port to start io server defaultValue, - saveTreshold: 2000, + saveFrequency: 2000, onAuthRequest: async (query, socket) => { // some query validation return true }, onDocumentLoad: async pathname => { - // return initial document ValueJSON by pathnme + // request initial document ValueJSON by pathnme return defaultValue }, - onDocumentSave: async (pathname, document) => { + onDocumentSave: async (pathname, doc) => { // save document + // console.log('onDocumentSave', pathname, doc) } } -const connection = new Connection(config) +const connection = new SocketIOConnection(config) diff --git a/packages/example/src/App.tsx b/packages/example/src/App.tsx index 64d00e1..9ca373e 100644 --- a/packages/example/src/App.tsx +++ b/packages/example/src/App.tsx @@ -1,53 +1,38 @@ -import React, { Component } from 'react' +import React, { useState, useEffect } from 'react' import faker from 'faker' import styled from '@emotion/styled' import Room from './Room' -class App extends Component<{}, { rooms: string[] }> { - state = { - rooms: [] - } - - componentDidMount() { - this.addRoom() - } - - render() { - const { rooms } = this.state - - return ( - - - - Add Room - - - {rooms.map(room => ( - - ))} - - ) - } - - addRoom = () => { - const room = faker.lorem.slug(4) - - this.setState({ rooms: [...this.state.rooms, room] }) - } - - removeRoom = (room: string) => () => { - this.setState({ - rooms: this.state.rooms.filter(r => r !== room) - }) - } +const App = () => { + const [rooms, setRooms] = useState([]) + + const addRoom = () => setRooms(rooms.concat(faker.lorem.slug(4))) + + const removeRoom = (room: string) => () => + setRooms(rooms.filter(r => r !== room)) + + useEffect(() => { + addRoom() + }, []) + + return ( +
+ + + Add Room + + + {rooms.map(room => ( + + ))} +
+ ) } export default App -const Container = styled.div`` - const Panel = styled.div` display: flex; ` diff --git a/packages/client/src/Cursor.tsx b/packages/example/src/Caret.tsx similarity index 71% rename from packages/client/src/Cursor.tsx rename to packages/example/src/Caret.tsx index 3af7c45..a70bd9a 100644 --- a/packages/client/src/Cursor.tsx +++ b/packages/example/src/Caret.tsx @@ -1,4 +1,34 @@ -import React, { Fragment } from 'react' +import React from 'react' + +interface Caret { + color: string + isForward: boolean + name: string +} + +const Caret: React.FC = ({ color, isForward, name }) => { + const cursorStyles = { + ...cursorStyleBase, + background: color, + left: isForward ? '100%' : '0%' + } + const caretStyles = { + ...caretStyleBase, + background: color, + left: isForward ? '100%' : '0%' + } + + return ( + <> + + {name} + + + + ) +} + +export default Caret const cursorStyleBase = { position: 'absolute', @@ -17,31 +47,7 @@ const caretStyleBase = { top: 0, pointerEvents: 'none', userSelect: 'none', - height: '100%', + height: '1.2em', width: 2, background: 'palevioletred' } as any - -const Cursor = ({ color, isBackward, name }) => { - const cursorStyles = { - ...cursorStyleBase, - background: color, - left: isBackward ? '0%' : '100%' - } - const caretStyles = { - ...caretStyleBase, - background: color, - left: isBackward ? '0%' : '100%' - } - - return ( - - - {name} - - - - ) -} - -export default Cursor diff --git a/packages/example/src/Client.tsx b/packages/example/src/Client.tsx index 4530d42..7d94978 100644 --- a/packages/example/src/Client.tsx +++ b/packages/example/src/Client.tsx @@ -1,39 +1,53 @@ -import React, { Component } from 'react' +import React, { useState, useEffect, useMemo } from 'react' + +import { createEditor, Node } from 'slate' +import { withHistory } from 'slate-history' +import { withReact } from 'slate-react' -import { Value, ValueJSON } from 'slate' -import { Editor } from 'slate-react' import randomColor from 'randomcolor' import styled from '@emotion/styled' -import ClientPlugin from '@slate-collaborative/client' +import { withIOCollaboration, useCursor } from '@slate-collaborative/client' + +import { Instance, Title, H4, Button } from './Elements' -import defaultValue from './defaultValue' +import EditorFrame from './EditorFrame' -import { Instance, ClientFrame, Title, H4, Button } from './elements' +const defaultValue: Node[] = [ + { + type: 'paragraph', + children: [ + { + text: '' + } + ] + } +] -interface ClienProps { +interface ClientProps { name: string id: string slug: string removeUser: (id: any) => void } -class Client extends Component { - editor: any +const Client: React.FC = ({ id, name, slug, removeUser }) => { + const [value, setValue] = useState(defaultValue) + const [isOnline, setOnlineState] = useState(false) - state = { - value: Value.fromJSON(defaultValue as ValueJSON), - isOnline: false, - plugins: [] - } + const color = useMemo( + () => + randomColor({ + luminosity: 'dark', + format: 'rgba', + alpha: 1 + }), + [] + ) - componentDidMount() { - const color = randomColor({ - luminosity: 'dark', - format: 'rgba', - alpha: 1 - }) + const editor = useMemo(() => { + const slateEditor = withReact(withHistory(createEditor())) const origin = process.env.NODE_ENV === 'production' @@ -41,74 +55,60 @@ class Client extends Component { : 'http://localhost:9000' const options = { - url: `${origin}/${this.props.slug}`, + docId: '/' + slug, + cursorData: { + name, + color, + alphaColor: color.slice(0, -2) + '0.2)' + }, + url: `${origin}/${slug}`, connectOpts: { query: { - name: this.props.name, - token: this.props.id, - slug: this.props.slug + name, + token: id, + slug } }, - annotationDataMixin: { - name: this.props.name, - color, - alphaColor: color.slice(0, -2) + '0.2)' - }, - // renderPreloader: () =>
PRELOADER!!!!!!
, - onConnect: this.onConnect, - onDisconnect: this.onDisconnect + onConnect: () => setOnlineState(true), + onDisconnect: () => setOnlineState(false) } - const plugin = ClientPlugin(options) - - this.setState({ - plugins: [plugin] - }) - } - - render() { - const { plugins, isOnline, value } = this.state - const { id, name } = this.props - - return ( - - - <Head>Editor: {name}</Head> - <Button type="button" onClick={this.toggleOnline}> - Go {isOnline ? 'offline' : 'online'} - </Button> - <Button type="button" onClick={() => this.props.removeUser(id)}> - Remove - </Button> - - - - - - ) - } + return withIOCollaboration(slateEditor, options) + }, []) - onChange = ({ value }: any) => this.setState({ value }) + useEffect(() => { + editor.connect() - onConnect = () => this.setState({ isOnline: true }) + return editor.destroy + }, []) - onDisconnect = () => this.setState({ isOnline: false }) - - ref = node => { - this.editor = node - } - - toggleOnline = () => { - const { isOnline } = this.state - const { connect, disconnect } = this.editor.connection + const { decorate } = useCursor(editor) + const toggleOnline = () => { + const { connect, disconnect } = editor isOnline ? disconnect() : connect() } + + return ( + + + <Head>Editor: {name}</Head> + <Button type="button" onClick={toggleOnline}> + Go {isOnline ? 'offline' : 'online'} + </Button> + <Button type="button" onClick={() => removeUser(id)}> + Remove + </Button> + + + setValue(value)} + /> + + ) } export default Client diff --git a/packages/example/src/EditorFrame.tsx b/packages/example/src/EditorFrame.tsx new file mode 100644 index 0000000..30c9073 --- /dev/null +++ b/packages/example/src/EditorFrame.tsx @@ -0,0 +1,193 @@ +import React, { useCallback } from 'react' + +import { Transforms, Editor, Node } from 'slate' +import { + Slate, + ReactEditor, + Editable, + RenderLeafProps, + useSlate +} from 'slate-react' + +import { ClientFrame, IconButton, Icon } from './Elements' + +import Caret from './Caret' + +const LIST_TYPES = ['numbered-list', 'bulleted-list'] + +export interface EditorFrame { + editor: ReactEditor + value: Node[] + decorate: any + onChange: (value: Node[]) => void +} + +const renderElement = (props: any) => + +const EditorFrame: React.FC = ({ + editor, + value, + decorate, + onChange +}) => { + const renderLeaf = useCallback((props: any) => , [ + decorate + ]) + + return ( + + +
+ + + + + + + + + +
+ + +
+
+ ) +} + +export default EditorFrame + +const toggleBlock = (editor: any, format: any) => { + const isActive = isBlockActive(editor, format) + const isList = LIST_TYPES.includes(format) + + Transforms.unwrapNodes(editor, { + match: n => LIST_TYPES.includes(n.type), + split: true + }) + + Transforms.setNodes(editor, { + type: isActive ? 'paragraph' : isList ? 'list-item' : format + }) + + if (!isActive && isList) { + const block = { type: format, children: [] } + Transforms.wrapNodes(editor, block) + } +} + +const toggleMark = (editor: any, format: any) => { + const isActive = isMarkActive(editor, format) + + if (isActive) { + Editor.removeMark(editor, format) + } else { + Editor.addMark(editor, format, true) + } +} + +const isBlockActive = (editor: any, format: any) => { + const [match] = Editor.nodes(editor, { + match: n => n.type === format + }) + + return !!match +} + +const isMarkActive = (editor: any, format: any) => { + const marks = Editor.marks(editor) + return marks ? marks[format] === true : false +} + +const Element: React.FC = ({ attributes, children, element }) => { + switch (element.type) { + case 'block-quote': + return
{children}
+ case 'bulleted-list': + return
    {children}
+ case 'heading-one': + return

{children}

+ case 'heading-two': + return

{children}

+ case 'list-item': + return
  • {children}
  • + case 'numbered-list': + return
      {children}
    + default: + return

    {children}

    + } +} + +const Leaf: React.FC = ({ attributes, children, leaf }) => { + if (leaf.bold) { + children = {children} + } + + if (leaf.code) { + children = {children} + } + + if (leaf.italic) { + children = {children} + } + + if (leaf.underline) { + children = {children} + } + + return ( + + {leaf.isCaret ? : null} + {children} + + ) +} + +const BlockButton: React.FC = ({ format, icon }) => { + const editor = useSlate() + return ( + { + event.preventDefault() + toggleBlock(editor, format) + }} + > + {icon} + + ) +} + +const MarkButton: React.FC = ({ format, icon }) => { + const editor = useSlate() + return ( + { + event.preventDefault() + toggleMark(editor, format) + }} + > + {icon} + + ) +} diff --git a/packages/example/src/Room.tsx b/packages/example/src/Room.tsx index 1f1350e..b6fc613 100644 --- a/packages/example/src/Room.tsx +++ b/packages/example/src/Room.tsx @@ -1,8 +1,9 @@ -import React, { Component, ChangeEvent } from 'react' +import React, { useState, ChangeEvent } from 'react' + import faker from 'faker' import debounce from 'lodash/debounce' -import { RoomWrapper, H4, Title, Button, Grid, Input } from './elements' +import { RoomWrapper, H4, Title, Button, Grid, Input } from './Elements' import Client from './Client' @@ -16,78 +17,57 @@ interface RoomProps { removeRoom: () => void } -interface RoomState { - users: User[] - slug: string - rebuild: boolean -} - -class Room extends Component { - state = { - users: [], - slug: this.props.slug, - rebuild: false - } - - componentDidMount() { - this.addUser() - setTimeout(this.addUser, 10) - } +const createUser = (): User => ({ + id: faker.random.uuid(), + name: `${faker.name.firstName()} ${faker.name.lastName()}` +}) - render() { - const { users, slug, rebuild } = this.state +const Room: React.FC = ({ slug, removeRoom }) => { + const [users, setUsers] = useState([createUser(), createUser()]) + const [roomSlug, setRoomSlug] = useState(slug) + const [isRemounted, setRemountState] = useState(false) - return ( - - - <H4>Document slug:</H4> - <Input type="text" value={slug} onChange={this.changeSlug} /> - <Button type="button" onClick={this.addUser}> - Add random user - </Button> - <Button type="button" onClick={this.props.removeRoom}> - Remove Room - </Button> - - - {users.map( - (user: User) => - !rebuild && ( - - ) - )} - - - ) - } - - addUser = () => { - const user = { - id: faker.random.uuid(), - name: `${faker.name.firstName()} ${faker.name.lastName()}` - } + const remount = debounce(() => { + setRemountState(true) + setTimeout(setRemountState, 50, false) + }, 300) - this.setState({ users: [...this.state.users, user] }) + const changeSlug = (e: ChangeEvent) => { + setRoomSlug(e.target.value) + remount() } - removeUser = (userId: string) => { - this.setState({ - users: this.state.users.filter((u: User) => u.id !== userId) - }) - } + const addUser = () => setUsers(users => users.concat(createUser())) - changeSlug = (e: ChangeEvent) => { - this.setState({ slug: e.target.value }, this.rebuildClient) - } + const removeUser = (userId: string) => + setUsers(users => users.filter((u: User) => u.id !== userId)) - rebuildClient = debounce(() => { - this.setState({ rebuild: true }, () => this.setState({ rebuild: false })) - }, 300) + return ( + + + <H4>Document slug:</H4> + <Input type="text" value={roomSlug} onChange={changeSlug} /> + <Button type="button" onClick={addUser}> + Add random user + </Button> + <Button type="button" onClick={removeRoom}> + Remove Room + </Button> + + + {users.map((user: User) => + isRemounted ? null : ( + + ) + )} + + + ) } export default Room diff --git a/packages/example/src/defaultValue.js b/packages/example/src/defaultValue.js deleted file mode 100644 index fe8a584..0000000 --- a/packages/example/src/defaultValue.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - document: { - nodes: [ - { - object: 'block', - type: 'paragraph', - nodes: [ - { - object: 'text', - marks: [], - text: 'Hello collaborator!' - } - ] - } - ] - } -} diff --git a/packages/example/src/elements.tsx b/packages/example/src/elements.tsx index 6cda0de..3d7cd73 100644 --- a/packages/example/src/elements.tsx +++ b/packages/example/src/elements.tsx @@ -36,6 +36,14 @@ export const Button = styled.button` } ` +export const IconButton = styled(Button)((props: any) => ({ + color: props.active ? 'mediumvioletred' : 'lightpink', + border: 'none', + padding: 0 +})) + +export const Icon = styled.div`` + export const Grid = styled.div` display: grid; grid-gap: 2vw; @@ -61,4 +69,12 @@ export const ClientFrame = styled.div` margin-left: -10px; margin-right: -10px; background: white; + blockquote { + border-left: 2px solid #ddd; + margin-left: 0; + margin-right: 0; + padding-left: 10px; + color: #aaa; + font-style: italic; + } ` diff --git a/packages/example/tsconfig.json b/packages/example/tsconfig.json index d107b6f..db713a0 100644 --- a/packages/example/tsconfig.json +++ b/packages/example/tsconfig.json @@ -2,32 +2,25 @@ "include": [ "src/**/*" ], + "extends": "./extend.tsconfig.json", "compilerOptions": { - "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "baseUrl": "src", - "jsx": "react", + "rootDir": "../", + "baseUrl": "./src", "allowJs": true, - "declaration": false, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, + "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "module": "esnext", - "moduleResolution": "node", - "resolveJsonModule": true, - "noEmit": true, "isolatedModules": true, - "skipLibCheck": true, - "noImplicitAny": false, - "removeComments": true, - "noLib": false, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "sourceMap": true - } + "resolveJsonModule": true, + "declaration": false, + "declarationMap": false, + "noEmit": true + }, + "references": [ + { + "path": "../client" + }, + { + "path": "../backend" + } + ] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 9e0c8c1..a7a55f3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,16 +1,21 @@ { - "include": ["packages/*/src"], "compilerOptions": { - "module": "esnext", + "rootDir": ".", + "baseUrl": "./packages", + "lib": ["dom", "dom.iterable", "esnext"], + "allowSyntheticDefaultImports": true, "declaration": true, - "noImplicitAny": false, - "removeComments": true, - "noLib": false, + "skipLibCheck": true, + "declarationMap": true, + "esModuleInterop": true, + "jsx": "react", + "module": "esnext", "moduleResolution": "node", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "target": "es5", "sourceMap": true, - "lib": ["es6", "es5"] - } + "strict": true, + "suppressImplicitAnyIndexErrors": true, + "target": "esnext" + }, + "exclude": ["node_modules"], + "include": ["**/*.ts", "**/*.tsx"] }