commit a817eb1cebf296495099e67a7939e7a09f0e5b48 Author: cudr Date: Sat Oct 5 11:44:49 2019 +0300 initial commit 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 0000000..e39507c Binary files /dev/null and b/packages/example/public/favicon.ico differ 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 0000000..b49f785 Binary files /dev/null and b/packages/example/public/logo192.png differ diff --git a/packages/example/public/logo512.png b/packages/example/public/logo512.png new file mode 100644 index 0000000..5063bc7 Binary files /dev/null and b/packages/example/public/logo512.png differ 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"] +}