From a817eb1cebf296495099e67a7939e7a09f0e5b48 Mon Sep 17 00:00:00 2001 From: cudr Date: Sat, 5 Oct 2019 11:44:49 +0300 Subject: [PATCH] initial commit --- .gitignore | 11 ++ .prettierrc | 6 + lerna.json | 11 ++ package.json | 35 +++++ packages/backend/.babelrc | 18 +++ packages/backend/.npmrc | 1 + packages/backend/package.json | 38 ++++++ packages/backend/src/Connection.ts | 147 +++++++++++++++++++++ packages/backend/src/index.ts | 3 + packages/backend/src/model.ts | 17 +++ packages/backend/src/utils/defaultValue.ts | 21 +++ packages/backend/src/utils/index.ts | 13 ++ packages/backend/tsconfig.json | 10 ++ packages/bridge/.babelrc | 7 + packages/bridge/.npmrc | 1 + packages/bridge/package.json | 31 +++++ packages/bridge/src/apply/annotation.ts | 17 +++ packages/bridge/src/apply/index.ts | 36 +++++ packages/bridge/src/apply/mark.ts | 72 ++++++++++ packages/bridge/src/apply/node.ts | 107 +++++++++++++++ packages/bridge/src/apply/text.ts | 24 ++++ packages/bridge/src/convert/create.ts | 9 ++ packages/bridge/src/convert/index.ts | 34 +++++ packages/bridge/src/convert/insert.ts | 51 +++++++ packages/bridge/src/convert/remove.ts | 49 +++++++ packages/bridge/src/convert/set.ts | 16 +++ packages/bridge/src/index.ts | 3 + packages/bridge/src/model/automerge.ts | 3 + packages/bridge/src/model/index.ts | 2 + packages/bridge/src/model/slate.ts | 8 ++ packages/bridge/src/path/index.ts | 38 ++++++ packages/bridge/src/utils/index.ts | 9 ++ packages/bridge/src/utils/toSync.ts | 33 +++++ packages/bridge/tsconfig.json | 10 ++ packages/client/.babelrc | 7 + packages/client/.npmrc | 1 + packages/client/package.json | 41 ++++++ packages/client/src/Connection.ts | 141 ++++++++++++++++++++ packages/client/src/Controller.tsx | 64 +++++++++ packages/client/src/index.ts | 29 ++++ packages/client/src/model.ts | 20 +++ packages/client/src/onChange.ts | 11 ++ packages/client/src/renderEditor.tsx | 21 +++ packages/client/tsconfig.json | 12 ++ packages/example/.gitignore | 23 ++++ packages/example/package.json | 51 +++++++ packages/example/public/favicon.ico | Bin 0 -> 16958 bytes packages/example/public/index.html | 20 +++ packages/example/public/logo192.png | Bin 0 -> 19315 bytes packages/example/public/logo512.png | Bin 0 -> 26578 bytes packages/example/public/manifest.json | 25 ++++ packages/example/public/robots.txt | 2 + packages/example/server.js | 21 +++ packages/example/src/App.tsx | 64 +++++++++ packages/example/src/Client.tsx | 101 ++++++++++++++ packages/example/src/Room.tsx | 92 +++++++++++++ packages/example/src/defaultValue.js | 17 +++ packages/example/src/elements.tsx | 64 +++++++++ packages/example/src/index.tsx | 6 + packages/example/src/react-app-env.d.ts | 1 + packages/example/tsconfig.json | 22 +++ tsconfig.base.json | 19 +++ tslint.json | 3 + 63 files changed, 1769 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 lerna.json create mode 100644 package.json create mode 100644 packages/backend/.babelrc create mode 100644 packages/backend/.npmrc create mode 100644 packages/backend/package.json create mode 100644 packages/backend/src/Connection.ts create mode 100644 packages/backend/src/index.ts create mode 100644 packages/backend/src/model.ts create mode 100644 packages/backend/src/utils/defaultValue.ts create mode 100644 packages/backend/src/utils/index.ts create mode 100644 packages/backend/tsconfig.json create mode 100644 packages/bridge/.babelrc create mode 100644 packages/bridge/.npmrc create mode 100644 packages/bridge/package.json create mode 100644 packages/bridge/src/apply/annotation.ts create mode 100644 packages/bridge/src/apply/index.ts create mode 100644 packages/bridge/src/apply/mark.ts create mode 100644 packages/bridge/src/apply/node.ts create mode 100644 packages/bridge/src/apply/text.ts create mode 100644 packages/bridge/src/convert/create.ts create mode 100644 packages/bridge/src/convert/index.ts create mode 100644 packages/bridge/src/convert/insert.ts create mode 100644 packages/bridge/src/convert/remove.ts create mode 100644 packages/bridge/src/convert/set.ts create mode 100644 packages/bridge/src/index.ts create mode 100644 packages/bridge/src/model/automerge.ts create mode 100644 packages/bridge/src/model/index.ts create mode 100644 packages/bridge/src/model/slate.ts create mode 100644 packages/bridge/src/path/index.ts create mode 100644 packages/bridge/src/utils/index.ts create mode 100644 packages/bridge/src/utils/toSync.ts create mode 100644 packages/bridge/tsconfig.json create mode 100644 packages/client/.babelrc create mode 100644 packages/client/.npmrc create mode 100644 packages/client/package.json create mode 100644 packages/client/src/Connection.ts create mode 100644 packages/client/src/Controller.tsx create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/src/model.ts create mode 100644 packages/client/src/onChange.ts create mode 100644 packages/client/src/renderEditor.tsx create mode 100644 packages/client/tsconfig.json create mode 100644 packages/example/.gitignore create mode 100644 packages/example/package.json create mode 100644 packages/example/public/favicon.ico create mode 100644 packages/example/public/index.html create mode 100644 packages/example/public/logo192.png create mode 100644 packages/example/public/logo512.png create mode 100644 packages/example/public/manifest.json create mode 100644 packages/example/public/robots.txt create mode 100644 packages/example/server.js create mode 100644 packages/example/src/App.tsx create mode 100644 packages/example/src/Client.tsx create mode 100644 packages/example/src/Room.tsx create mode 100644 packages/example/src/defaultValue.js create mode 100644 packages/example/src/elements.tsx create mode 100644 packages/example/src/index.tsx create mode 100644 packages/example/src/react-app-env.d.ts create mode 100644 packages/example/tsconfig.json create mode 100644 tsconfig.base.json create mode 100644 tslint.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c917297 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +lib/ +node_modules/ +.DS_Store +lerna-debug.log +/.npmrc +yarn.lock +yarn-error.log +package-lock.json +.idea +tsconfig.tsbuildinfo +**.code-workspace diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f1476e8 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 80, + "semi": false, + "singleQuote": true, + "arrowParens": "avoid" +} diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000..5230748 --- /dev/null +++ b/lerna.json @@ -0,0 +1,11 @@ +{ + "packages": ["packages/*"], + "npmClient": "yarn", + "useWorkspaces": true, + "version": "independent", + "command": { + "publish": { + "verifyAccess": false + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..219ceab --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "private": true, + "version": "0.0.1", + "description": "Slate collaborative plugin & microservice", + "scripts": { + "bootstrap": "lerna bootstrap", + "dev": "concurrently \"lerna run --parallel watch\" \"lerna run dev --stream\"", + "build": "lerna run build --stream", + "prebuild": "lerna clean --yes && rm -rf ./packages/**/lib/", + "format": "prettier --write" + }, + "workspaces": [ + "packages/*" + ], + "author": "cudr", + "license": "MIT", + "husky": { + "hooks": { + "pre-commit": "lint-staged" + } + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,json,babelrc}": [ + "yarn run format", + "git add" + ] + }, + "devDependencies": { + "concurrently": "^4.1.2", + "husky": "^3.0.5", + "lerna": "^3.16.4", + "lint-staged": "^9.2.5", + "prettier": "^1.18.2" + } +} diff --git a/packages/backend/.babelrc b/packages/backend/.babelrc new file mode 100644 index 0000000..0d18e14 --- /dev/null +++ b/packages/backend/.babelrc @@ -0,0 +1,18 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "esmodules": false + } + } + ], + "@babel/typescript" + ], + "plugins": [ + "@babel/plugin-transform-runtime", + "@babel/proposal-class-properties", + "@babel/proposal-object-rest-spread" + ] +} diff --git a/packages/backend/.npmrc b/packages/backend/.npmrc new file mode 100644 index 0000000..a21347f --- /dev/null +++ b/packages/backend/.npmrc @@ -0,0 +1 @@ +scripts-prepend-node-path=true \ No newline at end of file diff --git a/packages/backend/package.json b/packages/backend/package.json new file mode 100644 index 0000000..ec9a079 --- /dev/null +++ b/packages/backend/package.json @@ -0,0 +1,38 @@ +{ + "name": "@slate-collaborative/backend", + "version": "0.0.1", + "files": [ + "lib" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "description": "slate-collaborative bridge", + "repository": "https://github.com/cudr/slate-collaborative", + "author": "cudr", + "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": { + "@slate-collaborative/bridge": "^0.0.1", + "automerge": "^0.12.1", + "lodash": "^4.17.15", + "socket.io": "^2.2.0", + "typescript": "^3.6.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-transform-runtime": "^7.6.0", + "@babel/preset-env": "^7.6.0", + "@babel/preset-typescript": "^7.6.0", + "@types/socket.io": "^2.1.2" + } +} diff --git a/packages/backend/src/Connection.ts b/packages/backend/src/Connection.ts new file mode 100644 index 0000000..6a81e63 --- /dev/null +++ b/packages/backend/src/Connection.ts @@ -0,0 +1,147 @@ +import io from 'socket.io' +import { ValueJSON } from 'slate' +import * as Automerge from 'automerge' +import throttle from 'lodash/throttle' + +import { toSync, toJS } from '@slate-collaborative/bridge' + +import { getClients, defaultValue, defaultOptions } from './utils' +import { ConnectionOptions } from './model' + +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.port, options.connectOpts) + this.docSet = new Automerge.DocSet() + this.connections = {} + this.options = options + + this.configure() + } + + private configure = () => + this.io + .of(this.nspMiddleware) + .use(this.authMiddleware) + .on('connect', this.onConnect) + + private appendDoc = (path: string, value: ValueJSON) => { + const sync = toSync(value) + + const doc = Automerge.from(sync) + + this.docSet.setDoc(path, doc) + } + + private saveDoc = throttle(pathname => { + if (this.options.onDocumentSave) { + const doc = this.docSet.getDoc(pathname) + + this.options.onDocumentSave(pathname, toJS(doc)) + } + }, (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)) + } + + private onOperation = (id, name) => data => { + try { + this.connections[id].receiveMsg(data) + + this.saveDoc(name) + } catch (e) { + console.log(e) + } + } + + private onDisconnect = (id, socket) => () => { + this.connections[id].close() + delete this.connections[id] + + socket.leave(id) + + 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) + }) + }) + } + + 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() + } +} + +export default Connection diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts new file mode 100644 index 0000000..7c2d9f6 --- /dev/null +++ b/packages/backend/src/index.ts @@ -0,0 +1,3 @@ +import Connection from './Connection' + +module.exports = Connection diff --git a/packages/backend/src/model.ts b/packages/backend/src/model.ts new file mode 100644 index 0000000..9f3ede9 --- /dev/null +++ b/packages/backend/src/model.ts @@ -0,0 +1,17 @@ +import { ValueJSON } from 'slate' + +export interface ConnectionOptions { + port: number + connectOpts?: SocketIO.ServerOptions + defaultValue?: ValueJSON + saveTreshold?: number + 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 new file mode 100644 index 0000000..d33d77b --- /dev/null +++ b/packages/backend/src/utils/defaultValue.ts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..d8567a8 --- /dev/null +++ b/packages/backend/src/utils/index.ts @@ -0,0 +1,13 @@ +import defaultValue from './defaultValue' + +export const getClients = (io, nsp) => + new Promise((r, j) => { + io.of(nsp).clients((e, c) => (e ? j(e) : r(c))) + }) + +export const defaultOptions = { + port: 9000, + saveTreshold: 2000 +} + +export { defaultValue } diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json new file mode 100644 index 0000000..81c719a --- /dev/null +++ b/packages/backend/tsconfig.json @@ -0,0 +1,10 @@ +{ + "include": ["src/**/*"], + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "baseUrl": "src", + "esModuleInterop": true + } +} diff --git a/packages/bridge/.babelrc b/packages/bridge/.babelrc new file mode 100644 index 0000000..a2309a2 --- /dev/null +++ b/packages/bridge/.babelrc @@ -0,0 +1,7 @@ +{ + "presets": ["@babel/env", "@babel/typescript"], + "plugins": [ + "@babel/proposal-class-properties", + "@babel/proposal-object-rest-spread" + ] +} diff --git a/packages/bridge/.npmrc b/packages/bridge/.npmrc new file mode 100644 index 0000000..a21347f --- /dev/null +++ b/packages/bridge/.npmrc @@ -0,0 +1 @@ +scripts-prepend-node-path=true \ No newline at end of file diff --git a/packages/bridge/package.json b/packages/bridge/package.json new file mode 100644 index 0000000..36b1f20 --- /dev/null +++ b/packages/bridge/package.json @@ -0,0 +1,31 @@ +{ + "name": "@slate-collaborative/bridge", + "version": "0.0.1", + "files": [ + "lib" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "description": "slate-collaborative bridge", + "repository": "https://github.com/cudr/slate-collaborative", + "author": "cudr", + "license": "MIT", + "scripts": { + "prepublishOnly": "yarn run build", + "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": { + "typescript": "^3.6.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/preset-env": "^7.6.0", + "@babel/preset-typescript": "^7.6.0" + } +} diff --git a/packages/bridge/src/apply/annotation.ts b/packages/bridge/src/apply/annotation.ts new file mode 100644 index 0000000..fdeea58 --- /dev/null +++ b/packages/bridge/src/apply/annotation.ts @@ -0,0 +1,17 @@ +export const addAnnotation = (doc: any, op: any) => { + return doc +} + +export const removeAnnotation = (doc: any, op: any) => { + return doc +} + +export const setAnnotation = (doc: any, op: any) => { + return doc +} + +export default { + add_annotation: addAnnotation, + remove_annotation: removeAnnotation, + set_annotation: setAnnotation +} diff --git a/packages/bridge/src/apply/index.ts b/packages/bridge/src/apply/index.ts new file mode 100644 index 0000000..7af5423 --- /dev/null +++ b/packages/bridge/src/apply/index.ts @@ -0,0 +1,36 @@ +import { Operation, Operations, SyncDoc } from '../model' + +import node from './node' +import mark from './mark' +import text from './text' +import annotation from './annotation' + +const setSelection = doc => doc +const setValue = (doc, op) => doc + +const opType: any = { + ...text, + ...annotation, + ...node, + ...mark, + + set_selection: setSelection, + set_value: setValue +} + +export const applyOperation = (doc: SyncDoc, op: Operation): SyncDoc => { + try { + const applyOp = opType[op.type] + + if (!applyOp) throw new TypeError('Invalid operation type!') + + return applyOp(doc, op) + } catch (e) { + console.error(e) + + return doc + } +} + +export const applySlateOps = (doc: SyncDoc, operations: Operations) => + operations.reduce(applyOperation, doc) diff --git a/packages/bridge/src/apply/mark.ts b/packages/bridge/src/apply/mark.ts new file mode 100644 index 0000000..c38cee6 --- /dev/null +++ b/packages/bridge/src/apply/mark.ts @@ -0,0 +1,72 @@ +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 new file mode 100644 index 0000000..aad2c3f --- /dev/null +++ b/packages/bridge/src/apply/node.ts @@ -0,0 +1,107 @@ +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/text.ts b/packages/bridge/src/apply/text.ts new file mode 100644 index 0000000..830c3fa --- /dev/null +++ b/packages/bridge/src/apply/text.ts @@ -0,0 +1,24 @@ +import { SyncDoc } from '../model' +import { InsertTextOperation, RemoveTextOperation } from 'slate' +import { getTarget } from '../path' + +export const insertText = (doc: SyncDoc, op: InsertTextOperation): SyncDoc => { + const node = getTarget(doc, op.path) + + node.text.insertAt(op.offset, op.text) + + return doc +} + +export const removeText = (doc: SyncDoc, op: RemoveTextOperation): SyncDoc => { + const node = getTarget(doc, op.path) + + node.text.deleteAt(op.offset, op.text.length) + + return doc +} + +export default { + insert_text: insertText, + remove_text: removeText +} diff --git a/packages/bridge/src/convert/create.ts b/packages/bridge/src/convert/create.ts new file mode 100644 index 0000000..7501304 --- /dev/null +++ b/packages/bridge/src/convert/create.ts @@ -0,0 +1,9 @@ +const createByType = type => (type === 'map' ? {} : type === 'list' ? [] : '') + +const opCreate = ({ obj, type }, [map, ops]) => { + map[obj] = createByType(type) + + return [map, ops] +} + +export default opCreate diff --git a/packages/bridge/src/convert/index.ts b/packages/bridge/src/convert/index.ts new file mode 100644 index 0000000..35a50cb --- /dev/null +++ b/packages/bridge/src/convert/index.ts @@ -0,0 +1,34 @@ +import opInsert from './insert' +import opRemove from './remove' +import opSet from './set' +import opCreate from './create' + +const byAction = { + create: opCreate, + remove: opRemove, + set: opSet, + insert: opInsert +} + +const rootKey = '00000000-0000-0000-0000-000000000000' + +const toSlateOp = ops => { + const iterate = (acc, op) => { + const action = byAction[op.action] + + const result = action ? action(op, acc) : acc + + return result + } + + const [tree, defer] = ops.reduce(iterate, [ + { + [rootKey]: {} + }, + [] + ]) + + return defer.map(op => op(tree)) +} + +export { toSlateOp } diff --git a/packages/bridge/src/convert/insert.ts b/packages/bridge/src/convert/insert.ts new file mode 100644 index 0000000..852d4bd --- /dev/null +++ b/packages/bridge/src/convert/insert.ts @@ -0,0 +1,51 @@ +import { toSlatePath, toJS } from '../utils/index' + +const insertTextOp = ({ index, path, value }) => () => ({ + type: 'insert_text', + path: toSlatePath(path), + offset: index, + text: value, + marks: [] +}) + +const insertNodeOp = ({ value, index, path }) => map => ({ + type: 'insert_node', + path: [...toSlatePath(path), index], + node: map[value] +}) + +const insertByType = { + text: insertTextOp, + list: insertNodeOp +} + +const opInsert = (op, [map, ops]) => { + try { + const { link, obj, path, index, type, value } = op + + if (link && map[obj]) { + map[obj].splice(index, 0, map[value] || value) + } else if (type === 'text' && !path) { + map[obj] = map[obj] + ? map[obj] + .slice(0, index) + .concat(value) + .concat(map[obj].slice(index)) + : value + } else { + const insert = insertByType[type] + + const operation = insert && insert(op, map) + + ops.push(operation) + } + + return [map, ops] + } catch (e) { + console.error(e, op, toJS(map)) + + return [map, ops] + } +} + +export default opInsert diff --git a/packages/bridge/src/convert/remove.ts b/packages/bridge/src/convert/remove.ts new file mode 100644 index 0000000..d6bbba0 --- /dev/null +++ b/packages/bridge/src/convert/remove.ts @@ -0,0 +1,49 @@ +import { toSlatePath, toJS } from '../utils/index' + +const removeTextOp = ({ index, path }) => () => ({ + type: 'remove_text', + path: toSlatePath(path).slice(0, path.length), + offset: index, + text: '*', + marks: [] +}) + +const removeNodesOp = ({ index, path }) => () => { + const nPath = toSlatePath(path) + return { + type: 'remove_node', + path: nPath.length ? nPath.concat(index) : [index], + node: { + object: 'text' + } + } +} + +const removeByType = { + text: removeTextOp, + nodes: removeNodesOp +} + +const opRemove = (op, [map, ops]) => { + try { + const { index, path, obj } = op + + if (map.hasOwnProperty(obj) && op.type !== 'text') { + map[obj].splice(index, 1) + + return [map, ops] + } + + if (!path) return [map, ops] + + const fn = removeByType[path[path.length - 1]] + + return [map, [...ops, fn(op)]] + } catch (e) { + console.error(e, op, toJS(map)) + + return [map, ops] + } +} + +export default opRemove diff --git a/packages/bridge/src/convert/set.ts b/packages/bridge/src/convert/set.ts new file mode 100644 index 0000000..3193e2c --- /dev/null +++ b/packages/bridge/src/convert/set.ts @@ -0,0 +1,16 @@ +import { toJS } from '../utils/index' + +const opSet = (op, [map, ops]) => { + const { link, value, obj, key } = op + try { + map[obj][key] = link ? map[value] : value + + return [map, ops] + } catch (e) { + console.error(e, op, toJS(map)) + + return [map, ops] + } +} + +export default opSet diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts new file mode 100644 index 0000000..26d4a2e --- /dev/null +++ b/packages/bridge/src/index.ts @@ -0,0 +1,3 @@ +export * from './apply' +export * from './convert' +export * from './utils' diff --git a/packages/bridge/src/model/automerge.ts b/packages/bridge/src/model/automerge.ts new file mode 100644 index 0000000..dce0000 --- /dev/null +++ b/packages/bridge/src/model/automerge.ts @@ -0,0 +1,3 @@ +import { Doc } from 'automerge' + +export type SyncDoc = Doc diff --git a/packages/bridge/src/model/index.ts b/packages/bridge/src/model/index.ts new file mode 100644 index 0000000..0771630 --- /dev/null +++ b/packages/bridge/src/model/index.ts @@ -0,0 +1,2 @@ +export * from './automerge' +export * from './slate' diff --git a/packages/bridge/src/model/slate.ts b/packages/bridge/src/model/slate.ts new file mode 100644 index 0000000..859b13a --- /dev/null +++ b/packages/bridge/src/model/slate.ts @@ -0,0 +1,8 @@ +import { Operation, NodeJSON } from 'slate' +import { List } from 'immutable' + +export type Operations = List +export type SyncNode = NodeJSON +export type Path = List + +export { Operation } diff --git a/packages/bridge/src/path/index.ts b/packages/bridge/src/path/index.ts new file mode 100644 index 0000000..7ef3fb4 --- /dev/null +++ b/packages/bridge/src/path/index.ts @@ -0,0 +1,38 @@ +import { SyncDoc, Path } from '../model' +import { NodeJSON } from 'slate' + +export const isTree = (node: NodeJSON): any => node.object !== 'text' + +export const getTarget = (doc: SyncDoc, path: Path) => { + const iterate = (current: any, idx: number) => { + if (!isTree(current) || !current.nodes) { + throw new TypeError( + `path ${path.toString()} does not match tree ${JSON.stringify(current)}` + ) + } + + return current.nodes[idx] + } + + return path.reduce(iterate, doc.document) +} + +export const getParentPath = ( + path: Path, + level: number = 1 +): [number, Path] => { + if (level > path.size) { + throw new TypeError('requested ancestor is higher than root') + } + + return [path.get(path.size - level), path.slice(0, path.size - level) as Path] +} + +export const getParent = ( + doc: SyncDoc, + path: Path, + level = 1 +): [NodeJSON, number] => { + const [idx, parentPath] = getParentPath(path, level) + return [getTarget(doc, parentPath), idx] +} diff --git a/packages/bridge/src/utils/index.ts b/packages/bridge/src/utils/index.ts new file mode 100644 index 0000000..19b47f6 --- /dev/null +++ b/packages/bridge/src/utils/index.ts @@ -0,0 +1,9 @@ +import toSync from './toSync' + +export const toJS = node => JSON.parse(JSON.stringify(node)) + +export const cloneNode = node => toSync(toJS(node)) + +const toSlatePath = path => (path ? path.filter(d => Number.isInteger(d)) : []) + +export { toSync, toSlatePath } diff --git a/packages/bridge/src/utils/toSync.ts b/packages/bridge/src/utils/toSync.ts new file mode 100644 index 0000000..9686604 --- /dev/null +++ b/packages/bridge/src/utils/toSync.ts @@ -0,0 +1,33 @@ +import * as Automerge from 'automerge' + +const toSync = (node: any): any => { + if (!node) { + return + } + + if (node.hasOwnProperty('text')) { + return { + ...node, + text: new Automerge.Text(node.text) + } + } else if (node.nodes) { + 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) + } + } + + return node +} + +export default toSync diff --git a/packages/bridge/tsconfig.json b/packages/bridge/tsconfig.json new file mode 100644 index 0000000..e619b21 --- /dev/null +++ b/packages/bridge/tsconfig.json @@ -0,0 +1,10 @@ +{ + "include": ["src/**/*"], + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "baseUrl": "src", + "composite": true + } +} diff --git a/packages/client/.babelrc b/packages/client/.babelrc new file mode 100644 index 0000000..975d19a --- /dev/null +++ b/packages/client/.babelrc @@ -0,0 +1,7 @@ +{ + "presets": ["@babel/env", "@babel/react", "@babel/typescript"], + "plugins": [ + "@babel/proposal-class-properties", + "@babel/proposal-object-rest-spread" + ] +} diff --git a/packages/client/.npmrc b/packages/client/.npmrc new file mode 100644 index 0000000..a21347f --- /dev/null +++ b/packages/client/.npmrc @@ -0,0 +1 @@ +scripts-prepend-node-path=true \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..561a31e --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,41 @@ +{ + "name": "@slate-collaborative/client", + "version": "0.0.1", + "files": [ + "lib" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "description": "slate-collaborative bridge", + "repository": "https://github.com/cudr/slate-collaborative", + "author": "cudr", + "license": "MIT", + "scripts": { + "prepublishOnly": "npm run build", + "build": "npm run build:types && npm 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/preset-react": "^7.0.0", + "@slate-collaborative/bridge": "^0.0.1", + "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" + }, + "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/preset-env": "^7.6.0", + "@babel/preset-typescript": "^7.6.0", + "@types/react": "^16.9.2", + "@types/slate": "^0.47.1", + "@types/socket.io-client": "^1.4.32" + } +} diff --git a/packages/client/src/Connection.ts b/packages/client/src/Connection.ts new file mode 100644 index 0000000..a26c644 --- /dev/null +++ b/packages/client/src/Connection.ts @@ -0,0 +1,141 @@ +import Automerge from 'automerge' +import Immutable from 'immutable' +import io from 'socket.io-client' + +import { Value, Operation } from 'slate' +import { ConnectionModel } from './model' + +import { applySlateOps, toSlateOp, toJS } from '@slate-collaborative/bridge' + +class Connection { + url: string + docId: string + docSet: Automerge.DocSet + connection: Automerge.Connection + socket: SocketIOClient.Socket + editor: any + connectOpts: any + onConnect?: () => void + onDisconnect?: () => void + + constructor({ + editor, + url, + connectOpts, + onConnect, + onDisconnect + }: ConnectionModel) { + this.url = url + this.editor = editor + this.connectOpts = connectOpts + this.onConnect = onConnect + this.onDisconnect = onDisconnect + + this.docId = connectOpts.path || new URL(url).pathname + + this.docSet = new Automerge.DocSet() + + this.connect() + } + + sendData = (data: any) => { + this.socket.emit('operation', data) + } + + recieveData = async (data: any) => { + if (this.docId !== data.docId || !this.connection) { + return + } + + const currentDoc = this.docSet.getDoc(this.docId) + const docNew = this.connection.receiveMsg(data) + + if (!docNew) { + return + } + + try { + const operations = Automerge.diff(currentDoc, docNew) + + if (operations.length !== 0) { + const slateOps = toSlateOp(operations, this.connectOpts.query.name) + + this.editor.remote = true + + this.editor.withoutSaving(() => { + slateOps.forEach(o => this.editor.applyOperation(o)) + }) + + setTimeout(() => (this.editor.remote = false), 5) + } + } catch (e) { + console.error(e) + } + } + + receiveSlateOps = (operations: Immutable.List) => { + const doc = this.docSet.getDoc(this.docId) + const message = `change from ${this.socket.id}` + + if (!doc) return + + const changed = Automerge.change(doc, message, (d: any) => + applySlateOps(d, operations) + ) + + 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() + + this.connection && this.connection.close() + + delete this.connection + + this.socket.removeListener('document') + this.socket.removeListener('operation') + } + + close = () => { + this.onDisconnect() + + this.socket.close() + } +} + +export default Connection diff --git a/packages/client/src/Controller.tsx b/packages/client/src/Controller.tsx new file mode 100644 index 0000000..0d6574b --- /dev/null +++ b/packages/client/src/Controller.tsx @@ -0,0 +1,64 @@ +import React, { PureComponent, ReactNode } from 'react' + +import Connection from './Connection' + +import { ControllerProps } from './model' + +class Controller extends PureComponent { + connection?: Connection + + state = { + preloading: true + } + + componentDidMount() { + const { editor, url, connectOpts } = this.props + + editor.connection = new Connection({ + editor, + url, + connectOpts, + onConnect: this.onConnect, + onDisconnect: this.onDisconnect + }) + } + + componentWillUnmount() { + const { editor } = this.props + + if (editor.connection) editor.connection.close() + + delete editor.connection + } + + render() { + const { children, preloader } = this.props + const { preloading } = this.state + + if (preloader && preloading) return preloader() + + 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/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..cd0dc30 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,29 @@ +import { ReactNode } from 'react' + +import onChange from './onChange' +import renderEditor from './renderEditor' + +import Connection from './Connection' + +export interface PluginOptions { + url?: string + connectOpts?: SocketIOClient.ConnectOpts + preloader?: () => ReactNode + onConnect?: (connection: Connection) => void + onDisconnect?: (connection: Connection) => void +} + +const defaultOpts = { + url: 'http://localhost:9000' +} + +const plugin = (opts: PluginOptions = {}) => { + const options = { ...defaultOpts, ...opts } + + return { + onChange: onChange(options), + renderEditor: renderEditor(options) + } +} + +export default plugin diff --git a/packages/client/src/model.ts b/packages/client/src/model.ts new file mode 100644 index 0000000..cc284c1 --- /dev/null +++ b/packages/client/src/model.ts @@ -0,0 +1,20 @@ +import { Editor } from 'slate' +import { PluginOptions } from './index' +import Connection from './Connection' + +export interface ConnectionModel extends PluginOptions { + editor: Editor + onConnect: () => void + onDisconnect: () => void +} + +export interface ExtendedEditor extends Editor { + remote: boolean + connection: Connection +} + +export interface ControllerProps extends PluginOptions { + editor: ExtendedEditor + url?: string + connectOpts?: SocketIOClient.ConnectOpts +} diff --git a/packages/client/src/onChange.ts b/packages/client/src/onChange.ts new file mode 100644 index 0000000..f3fb136 --- /dev/null +++ b/packages/client/src/onChange.ts @@ -0,0 +1,11 @@ +import { ExtendedEditor } from './model' + +const onChange = opts => (editor: ExtendedEditor, next: () => void) => { + if (!editor.remote) { + editor.connection.receiveSlateOps(editor.operations) + } + + return next() +} + +export default onChange diff --git a/packages/client/src/renderEditor.tsx b/packages/client/src/renderEditor.tsx new file mode 100644 index 0000000..6a51db3 --- /dev/null +++ b/packages/client/src/renderEditor.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +import { PluginOptions } from './index' + +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/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..5f504f8 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "include": ["src/**/*"], + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "baseUrl": "src", + "jsx": "react", + "lib": ["es6", "dom"], + "esModuleInterop": true + } +} diff --git a/packages/example/.gitignore b/packages/example/.gitignore new file mode 100644 index 0000000..4d29575 --- /dev/null +++ b/packages/example/.gitignore @@ -0,0 +1,23 @@ +# 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/package.json b/packages/example/package.json new file mode 100644 index 0000000..bacc6d7 --- /dev/null +++ b/packages/example/package.json @@ -0,0 +1,51 @@ +{ + "name": "@slate-collaborative/example", + "version": "0.0.1", + "private": true, + "dependencies": { + "@emotion/core": "^10.0.17", + "@emotion/styled": "^10.0.17", + "@slate-collaborative/backend": "0.0.1", + "@slate-collaborative/client": "0.0.1", + "@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", + "concurrently": "^4.1.2", + "faker": "^4.1.0", + "lodash": "^4.17.15", + "react": "^16.9.0", + "react-dom": "^16.9.0", + "react-scripts": "3.1.1", + "slate": "^0.47.8", + "slate-react": "^0.22.8", + "typescript": "3.6.3" + }, + "scripts": { + "start": "react-scripts start", + "dev": "concurrently \"yarn start\" \"yarn serve\"", + "serve": "nodemon --watch ../backend/lib --inspect server.js", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "nodemon": "^1.19.2" + } +} diff --git a/packages/example/public/favicon.ico b/packages/example/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e39507c9b7601137a1c1b825596d3fa32fe9950d GIT binary patch literal 16958 zcmeI4YlszP7{_Pb%FNOVGp*zp;-#ppq@pr*(XgT-DkTbwu&gjDyGW!~n+4JBgIE-j z9~2@Y2rDS)x{BRCq+P7gvNpSzc5zM1OY5F~|JixA*_WBKGiT-;cBB(OpP6^w=kkB& z^3FR8d0qqmH8*?wJJK6B$@7MKo;MC?@>)Rc{;%2dMxE3}SHu7R#*M(D*-Y>~e=015H{pF)3Cmy>91s1V`x=RhTg&!v{$N{m zPllUe9^3`eF&6fR$a+KV2eCa9R>N*6N6<;zMtB@b5E-+Ux)nQ|m+hlrE_?zzAYAtz z_EqmvI13uU_jlqPN&B-9r#l?ewm+o*nc;Y~*6q|R9ZjI?(`u?@f1#hQi31@#hKI1P zYrB$f+_AOzjkkDp)<227-=?fv=eGsI_rvY%cchFH9*9Sq&qRjtOsa$t`I-KXI? zl5YQn-*iyDdX}w+q`p!8bdOoQT1ofgl1DY`9)1j>YDwC*27is=eAt^YmU6xd1E3!I zN0a9}NUAlepRV^g@Lt9^7W)U{jDdRS*R`hlEqAqc6)}FxA&&Cvo|*zR)34Y8k6^3S z9QNBv9NoL&wraO^KBhrE^e@S&W@+D=f8a~ae_|Kgl#)?BFjw%T(Z_S;6BiBJ#y zEjiWvGjTL`hjmoD{RMH3f|}`9V8w?^U>8(dtNAS_#*Ca|JxQE?P!IhY=TE_ORAcqL zz6$i-zcOR2pYWdm{`slp09X#}Y{>H75`JgBh*+8nsu{<>gIa2?J#_sm*J1Etl{L5F z*S&6ITi5L!plc%S7<`3K3F@NX8G|dxoAx=d5g$E2{CY!H>!F#Tu}L!qn)kJ)tc!lj zgVi^w*CA=Gs`FiFf^O~3c&hzmcnZD&cWmSCw+-|?4r)FImIHee=$*sWulam4Xg>Y} zRGaSg888HTXt!roxSeg?4_X`PI=K@Tz~#9L*M;i%9VVH7Y=R62g4jAYPk^gk-{s~8ed21bI(sPY z!EC<_Zhq}+4XkfTe*UVHML1eaY!mznZvGEw8wR=OwsI^swi%+9GR%`LF zabvG}Ynt`Tny-?7kVE(RB5==v_CE%FkIn5oSS;+dpfPsyK2KZsJzlB)tjEaX>%xu$ z%@J-b+P@ES(Qk3EBOvP8t2NAN)-S8RB>t8QtGQt>xMQ%9HqE1%#~_HI?|lz}n@9Va zn=9Y#me8(uDsxM1V&m#ly>EctQ&ZPyK3KiuYmMdVe~Gq%kUDl& z?dET}v06)Q2De7NpWX_qV?X~qs# z3w9QCfLpWXXstym)@;YI{b4D%arV%515}KYQBG=is|R~HyaR6C+J6ozzN2y6*M-%( zb33?kUZZUg^eO!sFsliBG5iT`?K^0@6e4T4cI*+b5?p_+ zR=|)b4c3N@d#7B)F|9jlP}Fw zK72-;;RV#M^=#BPJkyobJ|OJ{)UTQ+Le$>{l4@U#e@WU!k$yqV4cIxb8XJA?ll`X?5*7`s+Jtcbp=fd#g26zn%s9ru{ZlS_^LCxYi6OK%uoq zjDhqFhAHqMEQeLF0+zvTFdj5k`-JwGF)(chpi|?awVl?A*6v&L-Pf$EGrYgAX)S`D za`0_-^>6W-%H>Y}*8yIsT<(yP!R7KU+5Z38_F%tjXY?jNd6xAy0=PH+5=+owP*>;4eW^89y?MZ5>{h<=`%9 z#>z$7+X*47Py}mi^T(#OL1{=9*fvk;e6hZzNYeSDO0o==ZyCHE*4jh;%`dI><61nk Z-2m%2=Qeg#Fs(Ae!1$x?_c5$X literal 0 HcmV?d00001 diff --git a/packages/example/public/index.html b/packages/example/public/index.html new file mode 100644 index 0000000..740f3a5 --- /dev/null +++ b/packages/example/public/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + Slate collaborative + + + +
+ + diff --git a/packages/example/public/logo192.png b/packages/example/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..b49f785f60a5e517e477780ae6ade8ae4f65da26 GIT binary patch literal 19315 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 literal 0 HcmV?d00001 diff --git a/packages/example/public/logo512.png b/packages/example/public/logo512.png new file mode 100644 index 0000000000000000000000000000000000000000..5063bc70e03b537f6962eb24a7295cfb1813d551 GIT binary patch literal 26578 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 z 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 literal 0 HcmV?d00001 diff --git a/packages/example/public/manifest.json b/packages/example/public/manifest.json new file mode 100644 index 0000000..23603f9 --- /dev/null +++ b/packages/example/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "Slate collaborative", + "name": "collaborative plugin & microservice", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/packages/example/public/robots.txt b/packages/example/public/robots.txt new file mode 100644 index 0000000..01b0f9a --- /dev/null +++ b/packages/example/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/packages/example/server.js b/packages/example/server.js new file mode 100644 index 0000000..0a437ec --- /dev/null +++ b/packages/example/server.js @@ -0,0 +1,21 @@ +const Connection = require('@slate-collaborative/backend') +const defaultValue = require('./src/defaultValue') + +const config = { + port: 9000, + defaultValue, + saveTreshold: 2000, + onAuthRequest: async (query, socket) => { + // some query validation + return true + }, + onDocumentLoad: async pathname => { + // return initial document ValueJSON by pathnme + return defaultValue + }, + onDocumentSave: async (pathname, document) => { + // save document + } +} + +const connection = new Connection(config) diff --git a/packages/example/src/App.tsx b/packages/example/src/App.tsx new file mode 100644 index 0000000..c1fbd9f --- /dev/null +++ b/packages/example/src/App.tsx @@ -0,0 +1,64 @@ +import React, { Component } 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) + }) + } +} + +export default App + +const Container = styled.div`` + +const Button = styled.button` + padding: 6px 14px; + display: block; + outline: none; + font-size: 14px; + max-width: 200px; + text-align: center; + color: palevioletred; + border: 2px solid palevioletred; +` + +const AddButton = styled(Button)` + margin-left: 30px; + color: violet; + border: 2px solid violet; +` diff --git a/packages/example/src/Client.tsx b/packages/example/src/Client.tsx new file mode 100644 index 0000000..359ced2 --- /dev/null +++ b/packages/example/src/Client.tsx @@ -0,0 +1,101 @@ +import React, { Component } from 'react' + +import { Value, ValueJSON } from 'slate' +import { Editor } from 'slate-react' + +import styled from '@emotion/styled' + +import ClientPlugin from '@slate-collaborative/client' + +import defaultValue from './defaultValue' + +import { Instance, ClientFrame, Title, H4, Button } from './elements' + +interface ClienProps { + name: string + id: string + slug: string + removeUser: (id: any) => void +} + +class Client extends Component { + editor: any + + state = { + value: Value.fromJSON(defaultValue as ValueJSON), + isOnline: true, + plugins: [] + } + + componentDidMount() { + const plugin = ClientPlugin({ + url: `http://localhost:9000/${this.props.slug}`, + connectOpts: { + query: { + name: this.props.name, + token: this.props.id, + slug: this.props.slug + } + }, + // preloader: () =>
PRELOADER!!!!!!
, + onConnect: this.onConnect, + onDisconnect: this.onDisconnect + }) + + 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> + + + + + + ) + } + + onChange = ({ value }: any) => { + this.setState({ value }) + } + + onConnect = () => this.setState({ isOnline: true }) + + onDisconnect = () => this.setState({ isOnline: false }) + + ref = node => { + this.editor = node + } + + toggleOnline = () => { + const { isOnline } = this.state + const { connect, disconnect } = this.editor.connection + + isOnline ? disconnect() : connect() + } +} + +export default Client + +const Head = styled(H4)` + margin-right: auto; +` diff --git a/packages/example/src/Room.tsx b/packages/example/src/Room.tsx new file mode 100644 index 0000000..a4c6623 --- /dev/null +++ b/packages/example/src/Room.tsx @@ -0,0 +1,92 @@ +import React, { Component, ChangeEvent } from 'react' +import faker from 'faker' +import debounce from 'lodash/debounce' + +import { RoomWrapper, H4, Title, Button, Grid, Input } from './elements' + +import Client from './Client' + +interface User { + id: string + name: string +} + +interface RoomProps { + slug: string + 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() + } + + render() { + const { users, slug, rebuild } = this.state + + 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()}` + } + + this.setState({ users: [...this.state.users, user] }) + } + + removeUser = (userId: string) => { + this.setState({ + users: this.state.users.filter((u: User) => u.id !== userId) + }) + } + + changeSlug = (e: ChangeEvent) => { + this.setState({ slug: e.target.value }, this.rebuildClient) + } + + rebuildClient = debounce(() => { + this.setState({ rebuild: true }, () => this.setState({ rebuild: false })) + }, 300) +} + +export default Room diff --git a/packages/example/src/defaultValue.js b/packages/example/src/defaultValue.js new file mode 100644 index 0000000..fe8a584 --- /dev/null +++ b/packages/example/src/defaultValue.js @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..6cda0de --- /dev/null +++ b/packages/example/src/elements.tsx @@ -0,0 +1,64 @@ +import styled from '@emotion/styled' + +export const RoomWrapper = styled.div` + padding: 30px; + border-bottom: 2px solid #e8e8e8; +` + +export const H4 = styled.h4` + margin: 0; + padding-right: 10px; +` + +export const Input = styled.input` + padding: 6px 14px; + font-size: 14px; + margin-right: 10px; + min-width: 240px; + outline: none; + border: 2px solid palevioletred; + & + button { + margin-left: auto; + } +` + +export const Button = styled.button` + padding: 6px 14px; + display: block; + outline: none; + font-size: 14px; + text-align: center; + color: palevioletred; + white-space: nowrap; + border: 2px solid palevioletred; + & + button { + margin-left: 10px; + } +` + +export const Grid = styled.div` + display: grid; + grid-gap: 2vw; + grid-template-columns: 1fr 1fr; +` + +export const Title = styled.div` + display: flex; + align-items: center; + margin-bottom: 20px; +` + +export const Instance = styled.div<{ online: boolean }>` + background: ${props => + props.online ? 'rgba(128, 128, 128, 0.1)' : 'rgba(247, 0, 0, 0.2)'}; + padding: 20px 30px 40px; +` + +export const ClientFrame = styled.div` + box-shadow: 2px 2px 4px rgba(128, 128, 128, 0.2); + padding: 10px; + min-height: 70px; + margin-left: -10px; + margin-right: -10px; + background: white; +` diff --git a/packages/example/src/index.tsx b/packages/example/src/index.tsx new file mode 100644 index 0000000..69f4ddf --- /dev/null +++ b/packages/example/src/index.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import ReactDOM from 'react-dom' + +import App from './App' + +ReactDOM.render(, document.getElementById('root')) diff --git a/packages/example/src/react-app-env.d.ts b/packages/example/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/packages/example/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/example/tsconfig.json b/packages/example/tsconfig.json new file mode 100644 index 0000000..3821f08 --- /dev/null +++ b/packages/example/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["src/**/*"], + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "baseUrl": "src", + "jsx": "react", + "allowJs": true, + "declaration": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..b6f1a1e --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "include": ["packages/*/src"], + "compilerOptions": { + "module": "esnext", + "declaration": true, + "noImplicitAny": false, + "removeComments": true, + "noLib": false, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "es5", + "sourceMap": true, + "lib": ["es6", "es5"], + "paths": { + "@slate-collaborative/*": ["packages/*/src"] + } + } +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..f28b04e --- /dev/null +++ b/tslint.json @@ -0,0 +1,3 @@ +{ + "extends": ["tslint:latest", "tslint-config-prettier"] +}