mirror of
https://github.com/cudr/slate-collaborative.git
synced 2024-10-27 20:34:06 +00:00
initial commit
This commit is contained in:
commit
a817eb1ceb
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -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
|
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
11
lerna.json
Normal file
11
lerna.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"packages": ["packages/*"],
|
||||||
|
"npmClient": "yarn",
|
||||||
|
"useWorkspaces": true,
|
||||||
|
"version": "independent",
|
||||||
|
"command": {
|
||||||
|
"publish": {
|
||||||
|
"verifyAccess": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
package.json
Normal file
35
package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
18
packages/backend/.babelrc
Normal file
18
packages/backend/.babelrc
Normal file
@ -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"
|
||||||
|
]
|
||||||
|
}
|
1
packages/backend/.npmrc
Normal file
1
packages/backend/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
scripts-prepend-node-path=true
|
38
packages/backend/package.json
Normal file
38
packages/backend/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
147
packages/backend/src/Connection.ts
Normal file
147
packages/backend/src/Connection.ts
Normal file
@ -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<any> }
|
||||||
|
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
|
3
packages/backend/src/index.ts
Normal file
3
packages/backend/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import Connection from './Connection'
|
||||||
|
|
||||||
|
module.exports = Connection
|
17
packages/backend/src/model.ts
Normal file
17
packages/backend/src/model.ts
Normal file
@ -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> | boolean
|
||||||
|
onDocumentLoad?: (
|
||||||
|
pathname: string,
|
||||||
|
query?: Object
|
||||||
|
) => ValueJSON | null | false | undefined
|
||||||
|
onDocumentSave?: (pathname: string, json: ValueJSON) => Promise<void> | void
|
||||||
|
}
|
21
packages/backend/src/utils/defaultValue.ts
Normal file
21
packages/backend/src/utils/defaultValue.ts
Normal file
@ -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
|
13
packages/backend/src/utils/index.ts
Normal file
13
packages/backend/src/utils/index.ts
Normal file
@ -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 }
|
10
packages/backend/tsconfig.json
Normal file
10
packages/backend/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib",
|
||||||
|
"rootDir": "src",
|
||||||
|
"baseUrl": "src",
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
7
packages/bridge/.babelrc
Normal file
7
packages/bridge/.babelrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"presets": ["@babel/env", "@babel/typescript"],
|
||||||
|
"plugins": [
|
||||||
|
"@babel/proposal-class-properties",
|
||||||
|
"@babel/proposal-object-rest-spread"
|
||||||
|
]
|
||||||
|
}
|
1
packages/bridge/.npmrc
Normal file
1
packages/bridge/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
scripts-prepend-node-path=true
|
31
packages/bridge/package.json
Normal file
31
packages/bridge/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
17
packages/bridge/src/apply/annotation.ts
Normal file
17
packages/bridge/src/apply/annotation.ts
Normal file
@ -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
|
||||||
|
}
|
36
packages/bridge/src/apply/index.ts
Normal file
36
packages/bridge/src/apply/index.ts
Normal file
@ -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)
|
72
packages/bridge/src/apply/mark.ts
Normal file
72
packages/bridge/src/apply/mark.ts
Normal file
@ -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
|
||||||
|
}
|
107
packages/bridge/src/apply/node.ts
Normal file
107
packages/bridge/src/apply/node.ts
Normal file
@ -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
|
||||||
|
}
|
24
packages/bridge/src/apply/text.ts
Normal file
24
packages/bridge/src/apply/text.ts
Normal file
@ -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
|
||||||
|
}
|
9
packages/bridge/src/convert/create.ts
Normal file
9
packages/bridge/src/convert/create.ts
Normal file
@ -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
|
34
packages/bridge/src/convert/index.ts
Normal file
34
packages/bridge/src/convert/index.ts
Normal file
@ -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 }
|
51
packages/bridge/src/convert/insert.ts
Normal file
51
packages/bridge/src/convert/insert.ts
Normal file
@ -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
|
49
packages/bridge/src/convert/remove.ts
Normal file
49
packages/bridge/src/convert/remove.ts
Normal file
@ -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
|
16
packages/bridge/src/convert/set.ts
Normal file
16
packages/bridge/src/convert/set.ts
Normal file
@ -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
|
3
packages/bridge/src/index.ts
Normal file
3
packages/bridge/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './apply'
|
||||||
|
export * from './convert'
|
||||||
|
export * from './utils'
|
3
packages/bridge/src/model/automerge.ts
Normal file
3
packages/bridge/src/model/automerge.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { Doc } from 'automerge'
|
||||||
|
|
||||||
|
export type SyncDoc = Doc<any>
|
2
packages/bridge/src/model/index.ts
Normal file
2
packages/bridge/src/model/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './automerge'
|
||||||
|
export * from './slate'
|
8
packages/bridge/src/model/slate.ts
Normal file
8
packages/bridge/src/model/slate.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Operation, NodeJSON } from 'slate'
|
||||||
|
import { List } from 'immutable'
|
||||||
|
|
||||||
|
export type Operations = List<Operation>
|
||||||
|
export type SyncNode = NodeJSON
|
||||||
|
export type Path = List<number>
|
||||||
|
|
||||||
|
export { Operation }
|
38
packages/bridge/src/path/index.ts
Normal file
38
packages/bridge/src/path/index.ts
Normal file
@ -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]
|
||||||
|
}
|
9
packages/bridge/src/utils/index.ts
Normal file
9
packages/bridge/src/utils/index.ts
Normal file
@ -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 }
|
33
packages/bridge/src/utils/toSync.ts
Normal file
33
packages/bridge/src/utils/toSync.ts
Normal file
@ -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
|
10
packages/bridge/tsconfig.json
Normal file
10
packages/bridge/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib",
|
||||||
|
"rootDir": "src",
|
||||||
|
"baseUrl": "src",
|
||||||
|
"composite": true
|
||||||
|
}
|
||||||
|
}
|
7
packages/client/.babelrc
Normal file
7
packages/client/.babelrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"presets": ["@babel/env", "@babel/react", "@babel/typescript"],
|
||||||
|
"plugins": [
|
||||||
|
"@babel/proposal-class-properties",
|
||||||
|
"@babel/proposal-object-rest-spread"
|
||||||
|
]
|
||||||
|
}
|
1
packages/client/.npmrc
Normal file
1
packages/client/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
scripts-prepend-node-path=true
|
41
packages/client/package.json
Normal file
41
packages/client/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
141
packages/client/src/Connection.ts
Normal file
141
packages/client/src/Connection.ts
Normal file
@ -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<any>
|
||||||
|
connection: Automerge.Connection<any>
|
||||||
|
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<Operation>) => {
|
||||||
|
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
|
64
packages/client/src/Controller.tsx
Normal file
64
packages/client/src/Controller.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React, { PureComponent, ReactNode } from 'react'
|
||||||
|
|
||||||
|
import Connection from './Connection'
|
||||||
|
|
||||||
|
import { ControllerProps } from './model'
|
||||||
|
|
||||||
|
class Controller extends PureComponent<ControllerProps> {
|
||||||
|
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
|
29
packages/client/src/index.ts
Normal file
29
packages/client/src/index.ts
Normal file
@ -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
|
20
packages/client/src/model.ts
Normal file
20
packages/client/src/model.ts
Normal file
@ -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
|
||||||
|
}
|
11
packages/client/src/onChange.ts
Normal file
11
packages/client/src/onChange.ts
Normal file
@ -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
|
21
packages/client/src/renderEditor.tsx
Normal file
21
packages/client/src/renderEditor.tsx
Normal file
@ -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 (
|
||||||
|
<Controller {...opts} editor={editor}>
|
||||||
|
{children}
|
||||||
|
</Controller>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default renderEditor
|
12
packages/client/tsconfig.json
Normal file
12
packages/client/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib",
|
||||||
|
"rootDir": "src",
|
||||||
|
"baseUrl": "src",
|
||||||
|
"jsx": "react",
|
||||||
|
"lib": ["es6", "dom"],
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
23
packages/example/.gitignore
vendored
Normal file
23
packages/example/.gitignore
vendored
Normal file
@ -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*
|
51
packages/example/package.json
Normal file
51
packages/example/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
BIN
packages/example/public/favicon.ico
Normal file
BIN
packages/example/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
20
packages/example/public/index.html
Normal file
20
packages/example/public/index.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="logo192.png" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<title>Slate collaborative</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript></noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
packages/example/public/logo192.png
Normal file
BIN
packages/example/public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
BIN
packages/example/public/logo512.png
Normal file
BIN
packages/example/public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
25
packages/example/public/manifest.json
Normal file
25
packages/example/public/manifest.json
Normal file
@ -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"
|
||||||
|
}
|
2
packages/example/public/robots.txt
Normal file
2
packages/example/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
21
packages/example/server.js
Normal file
21
packages/example/server.js
Normal file
@ -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)
|
64
packages/example/src/App.tsx
Normal file
64
packages/example/src/App.tsx
Normal file
@ -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 (
|
||||||
|
<Container>
|
||||||
|
<AddButton type="button" onClick={this.addRoom}>
|
||||||
|
Add Room
|
||||||
|
</AddButton>
|
||||||
|
{rooms.map(room => (
|
||||||
|
<Room key={room} slug={room} removeRoom={this.removeRoom(room)} />
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
`
|
101
packages/example/src/Client.tsx
Normal file
101
packages/example/src/Client.tsx
Normal file
@ -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<ClienProps> {
|
||||||
|
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: () => <div>PRELOADER!!!!!!</div>,
|
||||||
|
onConnect: this.onConnect,
|
||||||
|
onDisconnect: this.onDisconnect
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
plugins: [plugin]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { plugins, isOnline, value } = this.state
|
||||||
|
const { id, name } = this.props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Instance online={isOnline}>
|
||||||
|
<Title>
|
||||||
|
<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>
|
||||||
|
</Title>
|
||||||
|
<ClientFrame>
|
||||||
|
<Editor
|
||||||
|
value={value}
|
||||||
|
ref={this.ref}
|
||||||
|
plugins={plugins}
|
||||||
|
onChange={this.onChange}
|
||||||
|
/>
|
||||||
|
</ClientFrame>
|
||||||
|
</Instance>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
`
|
92
packages/example/src/Room.tsx
Normal file
92
packages/example/src/Room.tsx
Normal file
@ -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<RoomProps, RoomState> {
|
||||||
|
state = {
|
||||||
|
users: [],
|
||||||
|
slug: this.props.slug,
|
||||||
|
rebuild: false
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.addUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { users, slug, rebuild } = this.state
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RoomWrapper>
|
||||||
|
<Title>
|
||||||
|
<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>
|
||||||
|
</Title>
|
||||||
|
<Grid>
|
||||||
|
{users.map(
|
||||||
|
(user: User) =>
|
||||||
|
!rebuild && (
|
||||||
|
<Client
|
||||||
|
{...user}
|
||||||
|
slug={slug}
|
||||||
|
key={user.id}
|
||||||
|
removeUser={this.removeUser}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</RoomWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
this.setState({ slug: e.target.value }, this.rebuildClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildClient = debounce(() => {
|
||||||
|
this.setState({ rebuild: true }, () => this.setState({ rebuild: false }))
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Room
|
17
packages/example/src/defaultValue.js
Normal file
17
packages/example/src/defaultValue.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
module.exports = {
|
||||||
|
document: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
object: 'block',
|
||||||
|
type: 'paragraph',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
object: 'text',
|
||||||
|
marks: [],
|
||||||
|
text: 'Hello collaborator!'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
64
packages/example/src/elements.tsx
Normal file
64
packages/example/src/elements.tsx
Normal file
@ -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;
|
||||||
|
`
|
6
packages/example/src/index.tsx
Normal file
6
packages/example/src/index.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'))
|
1
packages/example/src/react-app-env.d.ts
vendored
Normal file
1
packages/example/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
22
packages/example/tsconfig.json
Normal file
22
packages/example/tsconfig.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
19
tsconfig.base.json
Normal file
19
tsconfig.base.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3
tslint.json
Normal file
3
tslint.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["tslint:latest", "tslint-config-prettier"]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user