mirror of
https://github.com/cudr/slate-collaborative.git
synced 2024-10-27 20:34:06 +00:00
feat: update to slate 0.5x (#10)
Update Slate-Collaboration to be compatible with Slate 0.5x versions.
This commit is contained in:
parent
fee0098c3d
commit
0fd9390a99
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
lib/
|
||||
build/
|
||||
node_modules/
|
||||
.DS_Store
|
||||
lerna-debug.log
|
||||
|
9
License.md
Normal file
9
License.md
Normal file
@ -0,0 +1,9 @@
|
||||
The MIT License
|
||||
|
||||
Copyright © 2019–2020, [George Kukushin](https://github.com/cudr)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
62
README.md
62
README.md
@ -1,4 +1,4 @@
|
||||
# slate-collaborative. Check [Demo](https://slate-collaborative.herokuapp.com/)
|
||||
# slate-collaborative. Check [demo](https://slate-collaborative.herokuapp.com/)
|
||||
slatejs collaborative plugin & microservice
|
||||
|
||||
![screencast2019-10-2820-06-10](https://user-images.githubusercontent.com/23132107/67700384-ebff7280-f9be-11e9-9005-6ddadcafec47.gif)
|
||||
@ -13,52 +13,58 @@ Based on idea of https://github.com/humandx/slate-automerge
|
||||
|
||||
Use it as a simple slatejs plugin
|
||||
|
||||
check [example](https://github.com/cudr/slate-collaborative/blob/221d8929915c49cbe30a2f92550c9a604b9a527e/packages/example/src/Client.tsx#L43)
|
||||
```ts
|
||||
import { withIOCollaboration } from '@slate-collaborative/client'
|
||||
|
||||
```
|
||||
import ColaborativeClient from '@slate-collaborative/client'
|
||||
|
||||
const plugins = [ColaborativeClient(options)]
|
||||
const collaborationEditor = withIOCollaboration(editor, options)
|
||||
```
|
||||
|
||||
### options:
|
||||
```
|
||||
Check [detailed example](https://github.com/cudr/slate-collaborative/blob/master/packages/example/src/Client.tsx)
|
||||
|
||||
### Options:
|
||||
```ts
|
||||
{
|
||||
docId?: // document id
|
||||
url?: string // url to open connection
|
||||
connectOpts?: SocketIOClient.ConnectOpts // socket.io-client options
|
||||
cursorAnnotationType?: string // type string for cursor annotations
|
||||
annotationDataMixin?: Data // any data passed to cursor annotation
|
||||
renderPreloader?: () => ReactNode // optional preloader render
|
||||
renderCursor?: (data: Data) => ReactNode | any // custom cursor render
|
||||
onConnect?: (connection: Connection) => void // connect callback
|
||||
onDisconnect?: (connection: Connection) => void // disconnect callback
|
||||
cursorData?: any // any data passed to cursor
|
||||
onConnect?: () => void // connect callback
|
||||
onDisconnect?: () => void // disconnect callback
|
||||
}
|
||||
```
|
||||
|
||||
## Backend
|
||||
```
|
||||
const CollaborativeBackend = require('@slate-collaborative/backend')
|
||||
You need to attach the useCursor decorator to provide custom cursor data in renderLeaf function
|
||||
|
||||
const connection = new CollaborativeBackend(options)
|
||||
```ts
|
||||
import { useCursor } from '@slate-collaborative/client'
|
||||
|
||||
const decorator = useCursor(editor)
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Backend
|
||||
```ts
|
||||
const { SocketIOConnection } = require('@slate-collaborative/backend')
|
||||
|
||||
const connection = new SocketIOConnection(options)
|
||||
```
|
||||
|
||||
### options:
|
||||
```
|
||||
```ts
|
||||
{
|
||||
entry: number | Server // port or Server for listen io connection
|
||||
connectOpts?: SocketIO.ServerOptions
|
||||
defaultValue?: ValueJSON // default value
|
||||
saveTreshold?: number // theshold of onDocumentSave callback execution
|
||||
cursorAnnotationType?: string // type string for cursor annotations
|
||||
onAuthRequest?: ( // auth callback
|
||||
entry: Server // or specify port to start io server
|
||||
defaultValue: Node[] // default value
|
||||
saveFrequency: number // frequency of onDocumentSave callback execution in ms
|
||||
onAuthRequest: ( // auth callback
|
||||
query: Object,
|
||||
socket?: SocketIO.Socket
|
||||
) => Promise<boolean> | boolean
|
||||
onDocumentLoad?: ( // request slatejs document callback
|
||||
onDocumentLoad: ( // request slate document callback
|
||||
pathname: string,
|
||||
query?: Object
|
||||
) => ValueJSON | null | false | undefined
|
||||
onDocumentSave?: (pathname: string, json: ValueJSON) => Promise<void> | void // save document callback
|
||||
) => Node[]
|
||||
onDocumentSave: (pathname: string, doc: Node[]) => Promise<void> | void // save document callback
|
||||
}
|
||||
```
|
||||
|
||||
|
11
lerna.json
11
lerna.json
@ -1,11 +1,6 @@
|
||||
{
|
||||
"packages": ["packages/*"],
|
||||
"lerna": "2.7.1",
|
||||
"version": "0.5.0",
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "independent",
|
||||
"command": {
|
||||
"publish": {
|
||||
"verifyAccess": false
|
||||
}
|
||||
}
|
||||
"useWorkspaces": true
|
||||
}
|
||||
|
29
package.json
29
package.json
@ -1,28 +1,28 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.5.0",
|
||||
"description": "Slate collaborative plugin & microservice",
|
||||
"scripts": {
|
||||
"bootstrap": "lerna bootstrap",
|
||||
"clean": "rimraf ./packages/**/lib/ && rimraf ./packages/**/tsconfig.tsbuildinfo && lerna clean --yes",
|
||||
"release": "yarn prebuild && yarn build && lerna publish from-package",
|
||||
"dev": "concurrently \"yarn watch\" \"lerna run dev --stream\"",
|
||||
"build": "lerna run build --stream",
|
||||
"dev": "lerna run --stream build:js && concurrently \"yarn watch\" \"lerna run dev --stream\"",
|
||||
"build": "lerna run build --stream && lerna run build:example --stream",
|
||||
"watch": "lerna run --parallel watch",
|
||||
"clean:module": "lerna clean --yes",
|
||||
"prebuild": "rm -rf ./packages/**/lib/",
|
||||
"prebuild": "yarn clean",
|
||||
"test": "lerna run test --stream",
|
||||
"format": "prettier --write"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"nohoist": [
|
||||
"**/jest"
|
||||
]
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"author": "cudr",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/cudr/slate-collaborative.git"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
@ -37,8 +37,9 @@
|
||||
"devDependencies": {
|
||||
"concurrently": "^4.1.2",
|
||||
"husky": "^3.0.5",
|
||||
"lerna": "^3.16.4",
|
||||
"lerna": "^3.20.2",
|
||||
"lint-staged": "^9.2.5",
|
||||
"prettier": "^1.18.2"
|
||||
"prettier": "^1.18.2",
|
||||
"rimraf": "^3.0.2"
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/proposal-class-properties",
|
||||
"@babel/proposal-object-rest-spread"
|
||||
"@babel/proposal-object-rest-spread",
|
||||
"@babel/plugin-proposal-optional-chaining"
|
||||
]
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
scripts-prepend-node-path=true
|
@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@slate-collaborative/backend",
|
||||
"version": "0.0.3",
|
||||
"version": "0.5.0",
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/model.d.ts",
|
||||
"types": "lib/index.d.ts",
|
||||
"description": "slate-collaborative bridge",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -18,20 +18,22 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"prepublishOnly": "yarn run build",
|
||||
"type-check": "tsc --noEmit",
|
||||
"build": "yarn run build:types && yarn run build:js",
|
||||
"build:types": "tsc --emitDeclarationOnly",
|
||||
"build:js": "babel src --out-dir lib --extensions \".ts,.tsx\" --source-maps inline",
|
||||
"watch": "yarn build:js -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.9.0",
|
||||
"@babel/runtime": "^7.6.3",
|
||||
"@slate-collaborative/bridge": "^0.0.3",
|
||||
"automerge": "^0.12.1",
|
||||
"@slate-collaborative/bridge": "^0.5.0",
|
||||
"@types/lodash": "^4.14.150",
|
||||
"@types/socket.io": "^2.1.4",
|
||||
"automerge": "^0.14.0",
|
||||
"lodash": "^4.17.15",
|
||||
"slate": "^0.47.8",
|
||||
"socket.io": "^2.2.0",
|
||||
"typescript": "^3.6.3"
|
||||
"slate": "^0.57.2",
|
||||
"socket.io": "^2.3.0",
|
||||
"typescript": "^3.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.6.0",
|
||||
@ -40,8 +42,7 @@
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.5.5",
|
||||
"@babel/plugin-transform-runtime": "^7.6.0",
|
||||
"@babel/preset-env": "^7.6.0",
|
||||
"@babel/preset-typescript": "^7.6.0",
|
||||
"@types/socket.io": "^2.1.2"
|
||||
"@babel/preset-typescript": "^7.6.0"
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib"
|
||||
|
125
packages/backend/src/AutomergeBackend.ts
Normal file
125
packages/backend/src/AutomergeBackend.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import * as Automerge from 'automerge'
|
||||
|
||||
import { Element } from 'slate'
|
||||
|
||||
import {
|
||||
toCollabAction,
|
||||
toSync,
|
||||
SyncDoc,
|
||||
CollabAction
|
||||
} from '@slate-collaborative/bridge'
|
||||
|
||||
export interface Connections {
|
||||
[key: string]: Automerge.Connection<SyncDoc>
|
||||
}
|
||||
|
||||
/**
|
||||
* AutomergeBackend contains collaboration with Automerge
|
||||
*/
|
||||
|
||||
class AutomergeBackend {
|
||||
connections: Connections = {}
|
||||
|
||||
docSet: Automerge.DocSet<SyncDoc> = new Automerge.DocSet()
|
||||
|
||||
/**
|
||||
* Create Autmorge Connection
|
||||
*/
|
||||
|
||||
createConnection = (id: string, send: any) => {
|
||||
if (this.connections[id]) {
|
||||
console.warn(
|
||||
`Already has connection with id: ${id}. It will be terminated before creating new connection`
|
||||
)
|
||||
|
||||
this.closeConnection(id)
|
||||
}
|
||||
|
||||
this.connections[id] = new Automerge.Connection(
|
||||
this.docSet,
|
||||
toCollabAction('operation', send)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Automerge Connection
|
||||
*/
|
||||
|
||||
openConnection = (id: string) => this.connections[id].open()
|
||||
|
||||
/**
|
||||
* Close Automerge Connection and remove it from connections
|
||||
*/
|
||||
|
||||
closeConnection(id: string) {
|
||||
this.connections[id]?.close()
|
||||
|
||||
delete this.connections[id]
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive and apply operation to Automerge Connection
|
||||
*/
|
||||
|
||||
receiveOperation = (id: string, data: CollabAction) => {
|
||||
try {
|
||||
this.connections[id].receiveMsg(data.payload)
|
||||
} catch (e) {
|
||||
console.error('Unexpected error in receiveOperation', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get document from Automerge DocSet
|
||||
*/
|
||||
|
||||
getDocument = (docId: string) => this.docSet.getDoc(docId)
|
||||
|
||||
/**
|
||||
* Append document to Automerge DocSet
|
||||
*/
|
||||
|
||||
appendDocument = (docId: string, data: Element[]) => {
|
||||
try {
|
||||
if (this.getDocument(docId)) {
|
||||
throw new Error(`Already has document with id: ${docId}`)
|
||||
}
|
||||
|
||||
const sync = toSync({ cursors: {}, children: data })
|
||||
|
||||
const doc = Automerge.from<SyncDoc>(sync)
|
||||
|
||||
this.docSet.setDoc(docId, doc)
|
||||
} catch (e) {
|
||||
console.error(e, docId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove document from Automerge DocSet
|
||||
*/
|
||||
|
||||
removeDocument = (docId: string) => this.docSet.removeDoc(docId)
|
||||
|
||||
/**
|
||||
* Remove client cursor data
|
||||
*/
|
||||
|
||||
garbageCursor = (docId: string, id: string) => {
|
||||
try {
|
||||
const doc = this.getDocument(docId)
|
||||
|
||||
if (!doc.cursors) return
|
||||
|
||||
const change = Automerge.change(doc, d => {
|
||||
delete d.cursors[id]
|
||||
})
|
||||
|
||||
this.docSet.setDoc(docId, change)
|
||||
} catch (e) {
|
||||
console.error('Unexpected error in garbageCursor', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AutomergeBackend
|
@ -1,196 +0,0 @@
|
||||
import io from 'socket.io'
|
||||
import { ValueJSON } from 'slate'
|
||||
import * as Automerge from 'automerge'
|
||||
import throttle from 'lodash/throttle'
|
||||
import merge from 'lodash/merge'
|
||||
|
||||
import { toSync, toJS } from '@slate-collaborative/bridge'
|
||||
|
||||
import { getClients, defaultValue, defaultOptions } from './utils'
|
||||
import { ConnectionOptions } from './model'
|
||||
|
||||
export default class Connection {
|
||||
private io: any
|
||||
private docSet: any
|
||||
private connections: { [key: string]: Automerge.Connection<any> }
|
||||
private options: ConnectionOptions
|
||||
|
||||
constructor(options: ConnectionOptions = defaultOptions) {
|
||||
this.io = io(options.entry, options.connectOpts)
|
||||
this.docSet = new Automerge.DocSet()
|
||||
this.connections = {}
|
||||
this.options = merge(defaultOptions, options)
|
||||
|
||||
this.configure()
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
private configure = () =>
|
||||
this.io
|
||||
.of(this.nspMiddleware)
|
||||
.use(this.authMiddleware)
|
||||
.on('connect', this.onConnect)
|
||||
|
||||
private appendDoc = (path: string, value: ValueJSON) => {
|
||||
const sync = toSync(value)
|
||||
|
||||
sync.annotations = {}
|
||||
|
||||
const doc = Automerge.from(sync)
|
||||
|
||||
this.docSet.setDoc(path, doc)
|
||||
}
|
||||
|
||||
private saveDoc = throttle(pathname => {
|
||||
try {
|
||||
if (this.options.onDocumentSave) {
|
||||
const doc = this.docSet.getDoc(pathname)
|
||||
|
||||
if (doc) {
|
||||
const data = toJS(doc)
|
||||
|
||||
delete data.annotations
|
||||
|
||||
this.options.onDocumentSave(pathname, data)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}, (this.options && this.options.saveTreshold) || 2000)
|
||||
|
||||
private nspMiddleware = async (path, query, next) => {
|
||||
const { onDocumentLoad } = this.options
|
||||
|
||||
if (!this.docSet.getDoc(path)) {
|
||||
const valueJson = onDocumentLoad
|
||||
? await onDocumentLoad(path)
|
||||
: this.options.defaultValue || defaultValue
|
||||
|
||||
if (!valueJson) return next(null, false)
|
||||
|
||||
this.appendDoc(path, valueJson)
|
||||
}
|
||||
|
||||
return next(null, true)
|
||||
}
|
||||
|
||||
private authMiddleware = async (socket, next) => {
|
||||
const { query } = socket.handshake
|
||||
const { onAuthRequest } = this.options
|
||||
|
||||
if (onAuthRequest) {
|
||||
const permit = await onAuthRequest(query, socket)
|
||||
|
||||
if (!permit)
|
||||
return next(new Error(`Authentification error: ${socket.id}`))
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
private onConnect = socket => {
|
||||
const { id, conn } = socket
|
||||
const { name } = socket.nsp
|
||||
|
||||
const doc = this.docSet.getDoc(name)
|
||||
|
||||
const data = Automerge.save(doc)
|
||||
|
||||
this.connections[id] = new Automerge.Connection(this.docSet, data => {
|
||||
socket.emit('operation', { id: conn.id, ...data })
|
||||
})
|
||||
|
||||
socket.join(id, () => {
|
||||
this.connections[id].open()
|
||||
|
||||
socket.emit('document', data)
|
||||
})
|
||||
|
||||
socket.on('operation', this.onOperation(id, name))
|
||||
|
||||
socket.on('disconnect', this.onDisconnect(id, socket))
|
||||
|
||||
this.garbageCursors(name)
|
||||
}
|
||||
|
||||
private onOperation = (id, name) => data => {
|
||||
try {
|
||||
this.connections[id].receiveMsg(data)
|
||||
|
||||
this.saveDoc(name)
|
||||
|
||||
this.garbageCursors(name)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
private onDisconnect = (id, socket) => () => {
|
||||
this.connections[id].close()
|
||||
delete this.connections[id]
|
||||
|
||||
socket.leave(id)
|
||||
|
||||
this.garbageCursor(socket.nsp.name, id)
|
||||
this.garbageCursors(socket.nsp.name)
|
||||
|
||||
this.garbageNsp()
|
||||
}
|
||||
|
||||
garbageNsp = () => {
|
||||
Object.keys(this.io.nsps)
|
||||
.filter(n => n !== '/')
|
||||
.forEach(nsp => {
|
||||
getClients(this.io, nsp).then((clientsList: any[]) => {
|
||||
if (!clientsList.length) this.removeDoc(nsp)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
garbageCursor = (nsp, id) => {
|
||||
const doc = this.docSet.getDoc(nsp)
|
||||
|
||||
if (!doc.annotations) return
|
||||
|
||||
const change = Automerge.change(doc, `remove cursor ${id}`, (d: any) => {
|
||||
delete d.annotations[id]
|
||||
})
|
||||
|
||||
this.docSet.setDoc(nsp, change)
|
||||
}
|
||||
|
||||
garbageCursors = nsp => {
|
||||
const doc = this.docSet.getDoc(nsp)
|
||||
|
||||
if (!doc.annotations) return
|
||||
|
||||
const namespace = this.io.of(nsp)
|
||||
|
||||
Object.keys(doc.annotations).forEach(key => {
|
||||
if (
|
||||
!namespace.sockets[key] &&
|
||||
doc.annotations[key].type === this.options.cursorAnnotationType
|
||||
) {
|
||||
this.garbageCursor(nsp, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
removeDoc = async nsp => {
|
||||
const doc = this.docSet.getDoc(nsp)
|
||||
|
||||
if (this.options.onDocumentSave) {
|
||||
await this.options.onDocumentSave(nsp, toJS(doc))
|
||||
}
|
||||
|
||||
this.docSet.removeDoc(nsp)
|
||||
|
||||
delete this.io.nsps[nsp]
|
||||
}
|
||||
|
||||
destroy = async () => {
|
||||
this.io.close()
|
||||
}
|
||||
}
|
237
packages/backend/src/SocketIOConnection.ts
Normal file
237
packages/backend/src/SocketIOConnection.ts
Normal file
@ -0,0 +1,237 @@
|
||||
import io from 'socket.io'
|
||||
import * as Automerge from 'automerge'
|
||||
import { Element } from 'slate'
|
||||
import { Server } from 'http'
|
||||
|
||||
import throttle from 'lodash/throttle'
|
||||
|
||||
import { SyncDoc, CollabAction, toJS } from '@slate-collaborative/bridge'
|
||||
|
||||
import { getClients } from './utils'
|
||||
|
||||
import AutomergeBackend from './AutomergeBackend'
|
||||
|
||||
export interface SocketIOCollaborationOptions {
|
||||
entry: number | Server
|
||||
connectOpts?: SocketIO.ServerOptions
|
||||
defaultValue?: Element[]
|
||||
saveFrequency?: number
|
||||
onAuthRequest?: (
|
||||
query: Object,
|
||||
socket?: SocketIO.Socket
|
||||
) => Promise<boolean> | boolean
|
||||
onDocumentLoad?: (pathname: string, query?: Object) => Element[]
|
||||
onDocumentSave?: (pathname: string, doc: Element[]) => Promise<void> | void
|
||||
}
|
||||
|
||||
export default class SocketIOCollaboration {
|
||||
private io: SocketIO.Server
|
||||
private options: SocketIOCollaborationOptions
|
||||
private backend: AutomergeBackend
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
|
||||
constructor(options: SocketIOCollaborationOptions) {
|
||||
this.io = io(options.entry, options.connectOpts)
|
||||
|
||||
this.backend = new AutomergeBackend()
|
||||
|
||||
this.options = options
|
||||
|
||||
this.configure()
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial IO configuration
|
||||
*/
|
||||
|
||||
private configure = () =>
|
||||
this.io
|
||||
.of(this.nspMiddleware)
|
||||
.use(this.authMiddleware)
|
||||
.on('connect', this.onConnect)
|
||||
|
||||
/**
|
||||
* Namespace SocketIO middleware. Load document value and append it to CollaborationBackend.
|
||||
*/
|
||||
|
||||
private nspMiddleware = async (path: string, query: any, next: any) => {
|
||||
const { onDocumentLoad } = this.options
|
||||
|
||||
if (!this.backend.getDocument(path)) {
|
||||
const doc = onDocumentLoad
|
||||
? await onDocumentLoad(path)
|
||||
: this.options.defaultValue
|
||||
|
||||
if (!doc) return next(null, false)
|
||||
|
||||
this.backend.appendDocument(path, doc)
|
||||
}
|
||||
|
||||
return next(null, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* SocketIO auth middleware. Used for user authentification.
|
||||
*/
|
||||
|
||||
private authMiddleware = async (
|
||||
socket: SocketIO.Socket,
|
||||
next: (e?: any) => void
|
||||
) => {
|
||||
const { query } = socket.handshake
|
||||
const { onAuthRequest } = this.options
|
||||
|
||||
if (onAuthRequest) {
|
||||
const permit = await onAuthRequest(query, socket)
|
||||
|
||||
if (!permit)
|
||||
return next(new Error(`Authentification error: ${socket.id}`))
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
/**
|
||||
* On 'connect' handler.
|
||||
*/
|
||||
|
||||
private onConnect = (socket: SocketIO.Socket) => {
|
||||
const { id, conn } = socket
|
||||
const { name } = socket.nsp
|
||||
|
||||
this.backend.createConnection(id, ({ type, payload }: CollabAction) => {
|
||||
socket.emit('msg', { type, payload: { id: conn.id, ...payload } })
|
||||
})
|
||||
|
||||
socket.on('msg', this.onMessage(id, name))
|
||||
|
||||
socket.on('disconnect', this.onDisconnect(id, socket))
|
||||
|
||||
socket.join(id, () => {
|
||||
const doc = this.backend.getDocument(name)
|
||||
|
||||
socket.emit('msg', {
|
||||
type: 'document',
|
||||
payload: Automerge.save<SyncDoc>(doc)
|
||||
})
|
||||
|
||||
this.backend.openConnection(id)
|
||||
})
|
||||
|
||||
this.garbageCursors(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* On 'message' handler
|
||||
*/
|
||||
|
||||
private onMessage = (id: string, name: string) => (data: any) => {
|
||||
switch (data.type) {
|
||||
case 'operation':
|
||||
try {
|
||||
this.backend.receiveOperation(id, data)
|
||||
|
||||
this.autoSaveDoc(name)
|
||||
|
||||
this.garbageCursors(name)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save document with throttle
|
||||
*/
|
||||
|
||||
private autoSaveDoc = throttle(
|
||||
async (docId: string) =>
|
||||
this.backend.getDocument(docId) && this.saveDocument(docId),
|
||||
this.options?.saveFrequency || 2000
|
||||
)
|
||||
|
||||
/**
|
||||
* Save document
|
||||
*/
|
||||
|
||||
private saveDocument = async (docId: string) => {
|
||||
try {
|
||||
const { onDocumentSave } = this.options
|
||||
|
||||
const doc = this.backend.getDocument(docId)
|
||||
|
||||
if (!doc) {
|
||||
throw new Error(`Can't receive document by id: ${docId}`)
|
||||
}
|
||||
|
||||
onDocumentSave && (await onDocumentSave(docId, toJS(doc.children)))
|
||||
} catch (e) {
|
||||
console.error(e, docId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On 'disconnect' handler
|
||||
*/
|
||||
|
||||
private onDisconnect = (id: string, socket: SocketIO.Socket) => async () => {
|
||||
this.backend.closeConnection(id)
|
||||
|
||||
await this.saveDocument(socket.nsp.name)
|
||||
|
||||
this.garbageCursors(socket.nsp.name)
|
||||
|
||||
socket.leave(id)
|
||||
|
||||
this.garbageNsp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up unused SocketIO namespaces.
|
||||
*/
|
||||
|
||||
garbageNsp = () => {
|
||||
Object.keys(this.io.nsps)
|
||||
.filter(n => n !== '/')
|
||||
.forEach(nsp => {
|
||||
getClients(this.io, nsp).then((clientsList: any) => {
|
||||
if (!clientsList.length) {
|
||||
this.backend.removeDocument(nsp)
|
||||
|
||||
delete this.io.nsps[nsp]
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up unused cursor data.
|
||||
*/
|
||||
|
||||
garbageCursors = (nsp: string) => {
|
||||
const doc = this.backend.getDocument(nsp)
|
||||
|
||||
if (!doc.cursors) return
|
||||
|
||||
const namespace = this.io.of(nsp)
|
||||
|
||||
Object.keys(doc?.cursors)?.forEach(key => {
|
||||
if (!namespace.sockets[key]) {
|
||||
this.backend.garbageCursor(nsp, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy SocketIO connection
|
||||
*/
|
||||
|
||||
destroy = async () => {
|
||||
this.io.close()
|
||||
}
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
import Connection from './Connection'
|
||||
import SocketIOConnection from './SocketIOConnection'
|
||||
|
||||
module.exports = Connection
|
||||
export { SocketIOConnection }
|
||||
|
@ -1,19 +0,0 @@
|
||||
import { ValueJSON } from 'slate'
|
||||
import { Server } from 'http'
|
||||
|
||||
export interface ConnectionOptions {
|
||||
entry: number | Server
|
||||
connectOpts?: SocketIO.ServerOptions
|
||||
defaultValue?: ValueJSON
|
||||
saveTreshold?: number
|
||||
cursorAnnotationType?: string
|
||||
onAuthRequest?: (
|
||||
query: Object,
|
||||
socket?: SocketIO.Socket
|
||||
) => Promise<boolean> | boolean
|
||||
onDocumentLoad?: (
|
||||
pathname: string,
|
||||
query?: Object
|
||||
) => ValueJSON | null | false | undefined
|
||||
onDocumentSave?: (pathname: string, json: ValueJSON) => Promise<void> | void
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import { ValueJSON } from 'slate'
|
||||
|
||||
const json: ValueJSON = {
|
||||
document: {
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'paragraph',
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
marks: [],
|
||||
text: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export default json
|
@ -1,14 +1,4 @@
|
||||
import defaultValue from './defaultValue'
|
||||
|
||||
export const getClients = (io, nsp) =>
|
||||
export const getClients = (io: SocketIO.Server, nsp: string) =>
|
||||
new Promise((r, j) => {
|
||||
io.of(nsp).clients((e, c) => (e ? j(e) : r(c)))
|
||||
io.of(nsp).clients((e: any, c: any) => (e ? j(e) : r(c)))
|
||||
})
|
||||
|
||||
export const defaultOptions = {
|
||||
entry: 9000,
|
||||
saveTreshold: 2000,
|
||||
cursorAnnotationType: 'collaborative_selection'
|
||||
}
|
||||
|
||||
export { defaultValue }
|
||||
|
@ -1,12 +1,16 @@
|
||||
{
|
||||
"include": ["src/**/*"],
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["./src/**/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"baseUrl": "src",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": ["dom", "dom.iterable", "esnext"]
|
||||
}
|
||||
}
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./src",
|
||||
"outDir": "./lib",
|
||||
"composite": true,
|
||||
"paths": {
|
||||
"@slate-collaborative/bridge": ["../../bridge"]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../bridge" }
|
||||
]
|
||||
}
|
@ -1,17 +1,8 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": {
|
||||
"esmodules": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"@babel/typescript"
|
||||
],
|
||||
"presets": ["@babel/env", "@babel/typescript"],
|
||||
"plugins": [
|
||||
"@babel/proposal-class-properties",
|
||||
"@babel/proposal-object-rest-spread"
|
||||
"@babel/proposal-object-rest-spread",
|
||||
"@babel/plugin-proposal-optional-chaining"
|
||||
]
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
scripts-prepend-node-path=true
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@slate-collaborative/bridge",
|
||||
"version": "0.0.3",
|
||||
"version": "0.5.0",
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
@ -25,20 +25,21 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"automerge": "^0.12.1",
|
||||
"slate": "^0.47.8",
|
||||
"typescript": "^3.6.3"
|
||||
"automerge": "^0.14.0",
|
||||
"slate": "^0.57.2",
|
||||
"typescript": "^3.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.6.0",
|
||||
"@babel/core": "^7.6.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.5.5",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.5.5",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.9.0",
|
||||
"@babel/preset-env": "^7.6.0",
|
||||
"@babel/preset-typescript": "^7.6.0",
|
||||
"@types/jest": "^24.0.19",
|
||||
"@types/jest": "^24.9.0",
|
||||
"jest": "^24.9.0",
|
||||
"ts-jest": "^24.1.0"
|
||||
"ts-jest": "^25.4.0"
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib"
|
||||
@ -49,6 +50,12 @@
|
||||
"bridge"
|
||||
],
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"globals": {
|
||||
"ts-jest": {
|
||||
"babelConfig": ".babelrc"
|
||||
}
|
||||
},
|
||||
"roots": [
|
||||
"<rootDir>/src"
|
||||
],
|
||||
|
@ -1,60 +0,0 @@
|
||||
import { SyncDoc } from '../model/index'
|
||||
import { toSync } from '../utils'
|
||||
import {
|
||||
AddAnnotationOperation,
|
||||
RemoveAnnotationOperation,
|
||||
SetAnnotationOperation
|
||||
} from 'slate'
|
||||
|
||||
export const addAnnotation = (
|
||||
doc: SyncDoc,
|
||||
op: AddAnnotationOperation
|
||||
): SyncDoc => {
|
||||
if (!doc.annotations) {
|
||||
doc['annotations'] = {}
|
||||
}
|
||||
|
||||
const annotation = op.annotation.toJSON()
|
||||
|
||||
doc.annotations[annotation.key] = toSync(annotation)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const removeAnnotation = (
|
||||
doc: SyncDoc,
|
||||
op: RemoveAnnotationOperation
|
||||
): SyncDoc => {
|
||||
if (doc.annotations) {
|
||||
delete doc.annotations[op.annotation.key]
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const setAnnotation = (
|
||||
doc: SyncDoc,
|
||||
op: SetAnnotationOperation
|
||||
): SyncDoc => {
|
||||
/**
|
||||
* Looks like set_annotation option is broken, temporary disabled
|
||||
*/
|
||||
|
||||
// const { newProperties }: any = op.toJSON()
|
||||
|
||||
// if (!doc.annotations || !newProperties) return doc
|
||||
|
||||
// if (!doc.annotations[newProperties.key]) {
|
||||
// return addAnnotation(doc, newProperties)
|
||||
// } else {
|
||||
// doc.annotations[newProperties.key] = { ...doc.annotations[newProperties.key], ...newProperties }
|
||||
// }
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export default {
|
||||
add_annotation: addAnnotation,
|
||||
remove_annotation: removeAnnotation,
|
||||
set_annotation: setAnnotation
|
||||
}
|
233
packages/bridge/src/apply/apply.spec.ts
Normal file
233
packages/bridge/src/apply/apply.spec.ts
Normal file
@ -0,0 +1,233 @@
|
||||
import * as Automerge from 'automerge'
|
||||
|
||||
import { createDoc, toJS, createNode, createText } from '../utils'
|
||||
|
||||
import { applySlateOps } from './'
|
||||
|
||||
const transforms = [
|
||||
[
|
||||
'insert_text',
|
||||
[createNode('paragraph', '')],
|
||||
[
|
||||
{
|
||||
marks: [],
|
||||
offset: 0,
|
||||
path: [0, 0],
|
||||
text: 'Hello ',
|
||||
type: 'insert_text'
|
||||
},
|
||||
{
|
||||
marks: [],
|
||||
offset: 6,
|
||||
path: [0, 0],
|
||||
text: 'collaborator',
|
||||
type: 'insert_text'
|
||||
},
|
||||
{
|
||||
marks: [],
|
||||
offset: 18,
|
||||
path: [0, 0],
|
||||
text: '!',
|
||||
type: 'insert_text'
|
||||
}
|
||||
],
|
||||
[createNode('paragraph', 'Hello collaborator!')]
|
||||
],
|
||||
[
|
||||
'remove_text',
|
||||
[createNode('paragraph', 'Hello collaborator!')],
|
||||
[
|
||||
{
|
||||
offset: 11,
|
||||
path: [0, 0],
|
||||
text: 'borator',
|
||||
type: 'remove_text'
|
||||
},
|
||||
{
|
||||
offset: 5,
|
||||
path: [0, 0],
|
||||
text: ' colla',
|
||||
type: 'remove_text'
|
||||
}
|
||||
],
|
||||
[createNode('paragraph', 'Hello!')]
|
||||
],
|
||||
[
|
||||
'insert_node',
|
||||
null,
|
||||
[
|
||||
{
|
||||
type: 'insert_node',
|
||||
path: [1],
|
||||
node: { type: 'paragraph', children: [] }
|
||||
},
|
||||
{
|
||||
type: 'insert_node',
|
||||
path: [1, 0],
|
||||
node: { text: 'Hello collaborator!' }
|
||||
}
|
||||
],
|
||||
[createNode(), createNode('paragraph', 'Hello collaborator!')]
|
||||
],
|
||||
[
|
||||
'merge_node',
|
||||
[
|
||||
createNode('paragraph', 'Hello '),
|
||||
createNode('paragraph', 'collaborator!')
|
||||
],
|
||||
[
|
||||
{
|
||||
path: [1],
|
||||
position: 1,
|
||||
properties: { type: 'paragraph' },
|
||||
target: null,
|
||||
type: 'merge_node'
|
||||
},
|
||||
{
|
||||
path: [0, 1],
|
||||
position: 6,
|
||||
properties: {},
|
||||
target: null,
|
||||
type: 'merge_node'
|
||||
}
|
||||
],
|
||||
[createNode('paragraph', 'Hello collaborator!')]
|
||||
],
|
||||
[
|
||||
'move_node',
|
||||
[
|
||||
createNode('paragraph', 'first'),
|
||||
createNode('paragraph', 'second'),
|
||||
createNode('paragraph', 'third'),
|
||||
createNode('paragraph', 'fourth')
|
||||
],
|
||||
[
|
||||
{
|
||||
newPath: [0],
|
||||
path: [1],
|
||||
type: 'move_node'
|
||||
},
|
||||
{
|
||||
newPath: [3, 0],
|
||||
path: [2, 0],
|
||||
type: 'move_node'
|
||||
}
|
||||
],
|
||||
[
|
||||
createNode('paragraph', 'second'),
|
||||
createNode('paragraph', 'first'),
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [createText('third'), createText('fourth')]
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
'remove_node',
|
||||
[
|
||||
createNode('paragraph', 'first'),
|
||||
createNode('paragraph', 'second'),
|
||||
createNode('paragraph', 'third')
|
||||
],
|
||||
[
|
||||
{
|
||||
path: [1, 0],
|
||||
type: 'remove_node'
|
||||
},
|
||||
{
|
||||
path: [0],
|
||||
type: 'remove_node'
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: []
|
||||
},
|
||||
createNode('paragraph', 'third')
|
||||
]
|
||||
],
|
||||
[
|
||||
'set_node',
|
||||
[
|
||||
createNode('paragraph', 'first', { test: '1234' }),
|
||||
createNode('paragraph', 'second')
|
||||
],
|
||||
[
|
||||
{
|
||||
path: [0],
|
||||
type: 'set_node',
|
||||
properties: {
|
||||
test: '1234'
|
||||
},
|
||||
newProperties: {
|
||||
test: '4567'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: [1, 0],
|
||||
type: 'set_node',
|
||||
newProperties: {
|
||||
data: '4567'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
createNode('paragraph', 'first', { test: '4567' }),
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
data: '4567',
|
||||
text: 'second'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
'split_node',
|
||||
[createNode('paragraph', 'Hello collaborator!')],
|
||||
[
|
||||
{
|
||||
path: [0, 0],
|
||||
position: 6,
|
||||
target: null,
|
||||
type: 'split_node'
|
||||
},
|
||||
{
|
||||
path: [0],
|
||||
position: 1,
|
||||
properties: {
|
||||
type: 'paragraph'
|
||||
},
|
||||
target: 6,
|
||||
type: 'split_node'
|
||||
}
|
||||
],
|
||||
[
|
||||
createNode('paragraph', 'Hello '),
|
||||
createNode('paragraph', 'collaborator!')
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
describe('apply slate operations to Automerge document', () => {
|
||||
transforms.forEach(([op, input, operations, output]) => {
|
||||
it(`apply ${op} operations`, () => {
|
||||
const doc = createDoc(input)
|
||||
|
||||
const updated = Automerge.change(doc, (d: any) => {
|
||||
applySlateOps(d.children, operations as any)
|
||||
})
|
||||
|
||||
const expected = createDoc(output)
|
||||
|
||||
expect(toJS(expected)).toStrictEqual(toJS(updated))
|
||||
})
|
||||
})
|
||||
})
|
@ -1,20 +1,17 @@
|
||||
import { Operation, Operations, SyncDoc } from '../model'
|
||||
import { Operation } from 'slate'
|
||||
|
||||
import node from './node'
|
||||
import mark from './mark'
|
||||
import text from './text'
|
||||
import annotation from './annotation'
|
||||
|
||||
const setSelection = doc => doc
|
||||
const setValue = doc => doc
|
||||
import { SyncDoc } from '../model'
|
||||
import { toJS } from '../utils'
|
||||
|
||||
const setSelection = (doc: any) => doc
|
||||
|
||||
const opType = {
|
||||
...text,
|
||||
...annotation,
|
||||
...node,
|
||||
...mark,
|
||||
set_selection: setSelection
|
||||
// set_value: setValue
|
||||
}
|
||||
|
||||
const applyOperation = (doc: SyncDoc, op: Operation): SyncDoc => {
|
||||
@ -22,19 +19,19 @@ const applyOperation = (doc: SyncDoc, op: Operation): SyncDoc => {
|
||||
const applyOp = opType[op.type]
|
||||
|
||||
if (!applyOp) {
|
||||
console.log('operation', op.toJS())
|
||||
throw new TypeError(`Unsupported operation type: ${op.type}!`)
|
||||
}
|
||||
|
||||
return applyOp(doc, op)
|
||||
return applyOp(doc, op as any)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
console.error(e, op, toJS(doc))
|
||||
|
||||
return doc
|
||||
}
|
||||
}
|
||||
|
||||
const applySlateOps = (doc: SyncDoc, operations: Operations) =>
|
||||
operations.reduce(applyOperation, doc)
|
||||
const applySlateOps = (doc: SyncDoc, operations: Operation[]): SyncDoc => {
|
||||
return operations.reduce(applyOperation, doc)
|
||||
}
|
||||
|
||||
export { applyOperation, applySlateOps }
|
||||
|
@ -1,72 +0,0 @@
|
||||
import { getTarget } from '../path'
|
||||
import { toSync } from '../utils'
|
||||
import { SyncDoc } from '../model'
|
||||
|
||||
import { AddMarkOperation, RemoveMarkOperation, SetMarkOperation } from 'slate'
|
||||
|
||||
const findIndex = (node, mark) =>
|
||||
node.marks.findIndex(m => m.type === mark.type)
|
||||
|
||||
export const addMark = (doc: SyncDoc, op: AddMarkOperation) => {
|
||||
const node = getTarget(doc, op.path)
|
||||
|
||||
if (node.object !== 'text') {
|
||||
throw new TypeError('cannot set marks on non-text node')
|
||||
}
|
||||
|
||||
if (findIndex(node, op.mark) < 0) node.marks.push(toSync(op.mark.toJS()))
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const removeMark = (doc: SyncDoc, op: RemoveMarkOperation) => {
|
||||
const node = getTarget(doc, op.path)
|
||||
|
||||
if (node.object !== 'text') {
|
||||
throw new TypeError('cannot set marks on non-text node')
|
||||
}
|
||||
|
||||
const index = findIndex(node, op.mark)
|
||||
|
||||
if (index >= 0) node.marks.splice(index, 1)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const setMark = (doc: SyncDoc, op: SetMarkOperation) => {
|
||||
const node = getTarget(doc, op.path)
|
||||
|
||||
if (node.object !== 'text') {
|
||||
throw new TypeError('cannot set marks on non-text node')
|
||||
}
|
||||
|
||||
const index = findIndex(node, op.properties)
|
||||
|
||||
if (index === -1) {
|
||||
console.warn('did not find old mark with properties', op.properties)
|
||||
|
||||
if (!op.newProperties.type) {
|
||||
throw new TypeError('no old mark, and new mark missing type')
|
||||
}
|
||||
|
||||
node.marks.push({
|
||||
object: 'mark',
|
||||
type: op.newProperties.type,
|
||||
...op.newProperties
|
||||
})
|
||||
} else {
|
||||
node.marks[index] = {
|
||||
object: 'mark',
|
||||
...node.marks[index],
|
||||
...op.newProperties
|
||||
}
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export default {
|
||||
add_mark: addMark,
|
||||
remove_mark: removeMark,
|
||||
set_mark: setMark
|
||||
}
|
@ -1,107 +0,0 @@
|
||||
import { SyncDoc } from '../model'
|
||||
|
||||
import {
|
||||
SplitNodeOperation,
|
||||
InsertNodeOperation,
|
||||
MoveNodeOperation,
|
||||
RemoveNodeOperation,
|
||||
MergeNodeOperation,
|
||||
SetNodeOperation
|
||||
} from 'slate'
|
||||
|
||||
import { getTarget, getParent } from '../path'
|
||||
import { toJS, cloneNode, toSync } from '../utils'
|
||||
|
||||
export const insertNode = (doc: SyncDoc, op: InsertNodeOperation): SyncDoc => {
|
||||
const [parent, index] = getParent(doc, op.path)
|
||||
|
||||
if (parent.object === 'text') {
|
||||
throw new TypeError('cannot insert node into text node')
|
||||
}
|
||||
|
||||
parent.nodes.splice(index, 0, toSync(op.node.toJS()))
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const moveNode = (doc: SyncDoc, op: MoveNodeOperation): SyncDoc => {
|
||||
const [from, fromIndex] = getParent(doc, op.path)
|
||||
const [to, toIndex] = getParent(doc, op.newPath)
|
||||
|
||||
if (from.object === 'text' || to.object === 'text') {
|
||||
throw new TypeError('cannot move node as child of a text node')
|
||||
}
|
||||
|
||||
to.nodes.splice(toIndex, 0, ...from.nodes.splice(fromIndex, 1))
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const removeNode = (doc: SyncDoc, op: RemoveNodeOperation): SyncDoc => {
|
||||
const [parent, index] = getParent(doc, op.path)
|
||||
|
||||
if (parent.object === 'text') {
|
||||
throw new TypeError('cannot remove node from text node')
|
||||
}
|
||||
|
||||
parent.nodes.splice(index, 1)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const splitNode = (doc: SyncDoc, op: SplitNodeOperation): SyncDoc => {
|
||||
const [parent, index]: [any, number] = getParent(doc, op.path)
|
||||
|
||||
const target = parent.nodes[index]
|
||||
const inject = cloneNode(target)
|
||||
|
||||
if (target.object === 'text') {
|
||||
target.text.length > op.position &&
|
||||
target.text.deleteAt(op.position, target.text.length - op.position)
|
||||
op.position && inject.text.deleteAt(0, op.position)
|
||||
} else {
|
||||
target.nodes.splice(op.position, target.nodes.length - op.position)
|
||||
op.position && inject.nodes.splice(0, op.position)
|
||||
}
|
||||
|
||||
parent.nodes.insertAt(index + 1, inject)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const mergeNode = (doc: SyncDoc, op: MergeNodeOperation) => {
|
||||
const [parent, index]: [any, number] = getParent(doc, op.path)
|
||||
|
||||
const prev = parent.nodes[index - 1]
|
||||
const next = parent.nodes[index]
|
||||
|
||||
if (prev.object === 'text') {
|
||||
prev.text.insertAt(prev.text.length, ...toJS(next.text).split(''))
|
||||
} else {
|
||||
next.nodes.forEach(n => prev.nodes.push(cloneNode(n)))
|
||||
}
|
||||
|
||||
parent.nodes.deleteAt(index, 1)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const setNode = (doc: SyncDoc, op: SetNodeOperation) => {
|
||||
const node = getTarget(doc, op.path)
|
||||
|
||||
const { type, data }: any = op.newProperties
|
||||
|
||||
if (type) node.type = type
|
||||
if (node.object !== 'text' && data) node.data = data.toJSON()
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export default {
|
||||
insert_node: insertNode,
|
||||
move_node: moveNode,
|
||||
remove_node: removeNode,
|
||||
split_node: splitNode,
|
||||
merge_node: mergeNode,
|
||||
set_node: setNode
|
||||
}
|
15
packages/bridge/src/apply/node/index.ts
Normal file
15
packages/bridge/src/apply/node/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import insertNode from './insertNode'
|
||||
import mergeNode from './mergeNode'
|
||||
import moveNode from './moveNode'
|
||||
import removeNode from './removeNode'
|
||||
import setNode from './setNode'
|
||||
import splitNode from './splitNode'
|
||||
|
||||
export default {
|
||||
insert_node: insertNode,
|
||||
merge_node: mergeNode,
|
||||
move_node: moveNode,
|
||||
remove_node: removeNode,
|
||||
set_node: setNode,
|
||||
split_node: splitNode
|
||||
}
|
19
packages/bridge/src/apply/node/insertNode.ts
Normal file
19
packages/bridge/src/apply/node/insertNode.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { InsertNodeOperation } from 'slate'
|
||||
|
||||
import { SyncDoc } from '../../model'
|
||||
import { getParent, getChildren } from '../../path'
|
||||
import { toSync } from '../../utils'
|
||||
|
||||
const insertNode = (doc: SyncDoc, op: InsertNodeOperation): SyncDoc => {
|
||||
const [parent, index] = getParent(doc, op.path)
|
||||
|
||||
if (parent.text) {
|
||||
throw new TypeError("Can't insert node into text node")
|
||||
}
|
||||
|
||||
getChildren(parent).splice(index, 0, toSync(op.node))
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export default insertNode
|
24
packages/bridge/src/apply/node/mergeNode.ts
Normal file
24
packages/bridge/src/apply/node/mergeNode.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { MergeNodeOperation, Node } from 'slate'
|
||||
|
||||
import { SyncDoc } from '../../model'
|
||||
import { getParent, getChildren } from '../../path'
|
||||
import { toJS, cloneNode } from '../../utils'
|
||||
|
||||
const mergeNode = (doc: SyncDoc, op: MergeNodeOperation): SyncDoc => {
|
||||
const [parent, index]: [any, number] = getParent(doc, op.path)
|
||||
|
||||
const prev = parent[index - 1] || parent.children[index - 1]
|
||||
const next = parent[index] || parent.children[index]
|
||||
|
||||
if (prev.text) {
|
||||
prev.text.insertAt(prev.text.length, ...toJS(next.text).split(''))
|
||||
} else {
|
||||
getChildren(next).forEach((n: Node) => getChildren(prev).push(cloneNode(n)))
|
||||
}
|
||||
|
||||
getChildren(parent).deleteAt(index, 1)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export default mergeNode
|
26
packages/bridge/src/apply/node/moveNode.ts
Normal file
26
packages/bridge/src/apply/node/moveNode.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { MoveNodeOperation } from 'slate'
|
||||
|
||||
import { cloneNode } from '../../utils'
|
||||
import { SyncDoc } from '../../model'
|
||||
import { getParent, getChildren } from '../../path'
|
||||
|
||||
const moveNode = (doc: SyncDoc, op: MoveNodeOperation): SyncDoc => {
|
||||
const [from, fromIndex] = getParent(doc, op.path)
|
||||
const [to, toIndex] = getParent(doc, op.newPath)
|
||||
|
||||
if (from.text || to.text) {
|
||||
throw new TypeError("Can't move node as child of a text node")
|
||||
}
|
||||
|
||||
getChildren(to).splice(
|
||||
toIndex,
|
||||
0,
|
||||
...getChildren(from)
|
||||
.splice(fromIndex, 1)
|
||||
.map(cloneNode)
|
||||
)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export default moveNode
|
18
packages/bridge/src/apply/node/removeNode.ts
Normal file
18
packages/bridge/src/apply/node/removeNode.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { RemoveNodeOperation } from 'slate'
|
||||
|
||||
import { SyncDoc } from '../../model'
|
||||
import { getParent, getChildren } from '../../path'
|
||||
|
||||
export const removeNode = (doc: SyncDoc, op: RemoveNodeOperation): SyncDoc => {
|
||||
const [parent, index] = getParent(doc, op.path)
|
||||
|
||||
if (parent.text) {
|
||||
throw new TypeError("Can't remove node from text node")
|
||||
}
|
||||
|
||||
getChildren(parent).splice(index, 1)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export default removeNode
|
18
packages/bridge/src/apply/node/setNode.ts
Normal file
18
packages/bridge/src/apply/node/setNode.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { SetNodeOperation } from 'slate'
|
||||
|
||||
import { SyncDoc } from '../../model'
|
||||
import { getTarget } from '../../path'
|
||||
|
||||
const setNode = (doc: SyncDoc, op: SetNodeOperation): SyncDoc => {
|
||||
const node = getTarget(doc, op.path)
|
||||
|
||||
const { newProperties } = op
|
||||
|
||||
for (let key in newProperties) {
|
||||
node[key] = newProperties[key]
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export default setNode
|
27
packages/bridge/src/apply/node/splitNode.ts
Normal file
27
packages/bridge/src/apply/node/splitNode.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { SplitNodeOperation } from 'slate'
|
||||
|
||||
import { SyncDoc } from '../../model'
|
||||
import { getParent, getChildren } from '../../path'
|
||||
import { cloneNode } from '../../utils'
|
||||
|
||||
const splitNode = (doc: SyncDoc, op: SplitNodeOperation): SyncDoc => {
|
||||
const [parent, index]: [any, number] = getParent(doc, op.path)
|
||||
|
||||
const target = getChildren(parent)[index]
|
||||
const inject = cloneNode(target)
|
||||
|
||||
if (target.text) {
|
||||
target.text.length > op.position &&
|
||||
target.text.deleteAt(op.position, target.text.length - op.position)
|
||||
op.position && inject.text.deleteAt(0, op.position)
|
||||
} else {
|
||||
target.children.splice(op.position, target.children.length - op.position)
|
||||
op.position && inject.children.splice(0, op.position)
|
||||
}
|
||||
|
||||
getChildren(parent).insertAt(index + 1, inject)
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export default splitNode
|
@ -1,11 +1,12 @@
|
||||
import { SyncDoc } from '../model'
|
||||
import { InsertTextOperation, RemoveTextOperation } from 'slate'
|
||||
|
||||
import { getTarget } from '../path'
|
||||
import { SyncDoc } from '../model'
|
||||
|
||||
export const insertText = (doc: SyncDoc, op: InsertTextOperation): SyncDoc => {
|
||||
const node = getTarget(doc, op.path)
|
||||
|
||||
node.text.insertAt(op.offset, op.text)
|
||||
node.text.insertAt(op.offset, ...op.text.split(''))
|
||||
|
||||
return doc
|
||||
}
|
||||
|
@ -1,15 +1,15 @@
|
||||
import * as Automerge from 'automerge'
|
||||
import { toSlateOp } from './index'
|
||||
import { createDoc, cloneDoc, createBlockJSON } from '../utils'
|
||||
import { createDoc, cloneDoc, createNode } from '../utils'
|
||||
|
||||
describe('convert operations to slatejs model', () => {
|
||||
it('convert insert operations', () => {
|
||||
const doc1 = createDoc()
|
||||
const doc2 = cloneDoc(doc1)
|
||||
|
||||
const change = Automerge.change(doc1, 'change', d => {
|
||||
d.document.nodes.push(createBlockJSON('paragraph', 'hello!'))
|
||||
d.document.nodes[1].nodes[0].text = 'hello!'
|
||||
const change = Automerge.change(doc1, d => {
|
||||
d.children.push(createNode('paragraph', 'hello!'))
|
||||
d.children[1].children[0].text = 'hello!'
|
||||
})
|
||||
|
||||
const operations = Automerge.diff(doc2, change)
|
||||
@ -20,12 +20,12 @@ describe('convert operations to slatejs model', () => {
|
||||
{
|
||||
type: 'insert_node',
|
||||
path: [1],
|
||||
node: { object: 'block', type: 'paragraph', nodes: [] }
|
||||
node: { type: 'paragraph', children: [] }
|
||||
},
|
||||
{
|
||||
type: 'insert_node',
|
||||
path: [1, 0],
|
||||
node: { object: 'text', marks: [], text: 'hello!' }
|
||||
node: { text: 'hello!' }
|
||||
}
|
||||
]
|
||||
|
||||
@ -33,17 +33,17 @@ describe('convert operations to slatejs model', () => {
|
||||
})
|
||||
|
||||
it('convert remove operations', () => {
|
||||
const doc1 = Automerge.change(createDoc(), 'change', d => {
|
||||
d.document.nodes.push(createBlockJSON('paragraph', 'hello!'))
|
||||
d.document.nodes.push(createBlockJSON('paragraph', 'hello twice!'))
|
||||
d.document.nodes[1].nodes[0].text = 'hello!'
|
||||
const doc1 = Automerge.change(createDoc(), d => {
|
||||
d.children.push(createNode('paragraph', 'hello!'))
|
||||
d.children.push(createNode('paragraph', 'hello twice!'))
|
||||
d.children[1].children[0].text = 'hello!'
|
||||
})
|
||||
|
||||
const doc2 = cloneDoc(doc1)
|
||||
|
||||
const change = Automerge.change(doc1, 'change', d => {
|
||||
delete d.document.nodes[1]
|
||||
delete d.document.nodes[0].nodes[0]
|
||||
const change = Automerge.change(doc1, d => {
|
||||
delete d.children[1]
|
||||
delete d.children[0].children[0]
|
||||
})
|
||||
|
||||
const operations = Automerge.diff(doc2, change)
|
||||
@ -55,14 +55,14 @@ describe('convert operations to slatejs model', () => {
|
||||
type: 'remove_node',
|
||||
path: [1],
|
||||
node: {
|
||||
object: 'text'
|
||||
text: '*'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'remove_node',
|
||||
path: [0, 0],
|
||||
node: {
|
||||
object: 'text'
|
||||
text: '*'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -1,8 +1,9 @@
|
||||
import * as Automerge from 'automerge'
|
||||
|
||||
const createByType = type => (type === 'map' ? {} : type === 'list' ? [] : '')
|
||||
const createByType = (type: any) =>
|
||||
type === 'map' ? {} : type === 'list' ? [] : ''
|
||||
|
||||
const opCreate = ({ obj, type }: Automerge.Diff, [map, ops]) => {
|
||||
const opCreate = ({ obj, type }: Automerge.Diff, [map, ops]: any) => {
|
||||
map[obj] = createByType(type)
|
||||
|
||||
return [map, ops]
|
||||
|
@ -1,4 +1,5 @@
|
||||
import * as Automerge from 'automerge'
|
||||
import { Node } from 'slate'
|
||||
|
||||
import opInsert from './insert'
|
||||
import opRemove from './remove'
|
||||
@ -14,11 +15,11 @@ const byAction = {
|
||||
|
||||
const rootKey = '00000000-0000-0000-0000-000000000000'
|
||||
|
||||
const toSlateOp = (ops: Automerge.Diff[], doc) => {
|
||||
const iterate = (acc, op) => {
|
||||
const toSlateOp = (ops: Automerge.Diff[], doc: Automerge.Doc<Node>) => {
|
||||
const iterate = (acc: [any, any[]], op: Automerge.Diff): any => {
|
||||
const action = byAction[op.action]
|
||||
|
||||
const result = action ? action(op, acc) : acc
|
||||
const result = action ? action(op, acc, doc) : acc
|
||||
|
||||
return result
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import * as Automerge from 'automerge'
|
||||
import { toSlatePath, toJS } from '../utils/index'
|
||||
|
||||
import { toSlatePath, toJS } from '../utils'
|
||||
|
||||
import { SyncDoc } from '../model'
|
||||
|
||||
const insertTextOp = ({ index, path, value }: Automerge.Diff) => () => ({
|
||||
type: 'insert_text',
|
||||
@ -9,32 +12,31 @@ const insertTextOp = ({ index, path, value }: Automerge.Diff) => () => ({
|
||||
marks: []
|
||||
})
|
||||
|
||||
const insertNodeOp = ({ value, obj, index, path }: Automerge.Diff) => map => {
|
||||
const ops = []
|
||||
const insertNodeOp = (
|
||||
{ value, obj, index, path }: Automerge.Diff,
|
||||
doc: any
|
||||
) => (map: any) => {
|
||||
const ops: any = []
|
||||
|
||||
const iterate = ({ nodes, ...json }, path) => {
|
||||
const node = nodes ? { ...json, nodes: [] } : json
|
||||
const iterate = ({ children, ...json }: any, path: any) => {
|
||||
const node = children ? { ...json, children: [] } : json
|
||||
|
||||
if (node.object) {
|
||||
if (node.object === 'mark') {
|
||||
ops.push({
|
||||
type: 'add_mark',
|
||||
path: path.slice(0, -1),
|
||||
mark: node
|
||||
})
|
||||
} else {
|
||||
ops.push({
|
||||
type: 'insert_node',
|
||||
path,
|
||||
node
|
||||
})
|
||||
}
|
||||
}
|
||||
ops.push({
|
||||
type: 'insert_node',
|
||||
path,
|
||||
node
|
||||
})
|
||||
|
||||
nodes && nodes.forEach((n, i) => iterate(n, [...path, i]))
|
||||
children &&
|
||||
children.forEach((n: any, i: any) => {
|
||||
const node = map[n] || Automerge.getObjectById(doc, n)
|
||||
|
||||
iterate((node && toJS(node)) || n, [...path, i])
|
||||
})
|
||||
}
|
||||
|
||||
const source = map[value] || (map[obj] && toJS(map[obj]))
|
||||
const source =
|
||||
map[value] || toJS(map[obj] || Automerge.getObjectById(doc, value))
|
||||
|
||||
source && iterate(source, [...toSlatePath(path), index])
|
||||
|
||||
@ -46,11 +48,11 @@ const insertByType = {
|
||||
list: insertNodeOp
|
||||
}
|
||||
|
||||
const opInsert = (op: Automerge.Diff, [map, ops]) => {
|
||||
const opInsert = (op: Automerge.Diff, [map, ops]: any, doc: SyncDoc) => {
|
||||
try {
|
||||
const { link, obj, path, index, type, value } = op
|
||||
|
||||
if (link && map[obj]) {
|
||||
if (link && map.hasOwnProperty(obj)) {
|
||||
map[obj].splice(index, 0, map[value] || value)
|
||||
} else if ((type === 'text' || type === 'list') && !path) {
|
||||
map[obj] = map[obj]
|
||||
@ -62,7 +64,7 @@ const opInsert = (op: Automerge.Diff, [map, ops]) => {
|
||||
} else {
|
||||
const insert = insertByType[type]
|
||||
|
||||
const operation = insert && insert(op, map)
|
||||
const operation = insert && insert(op, doc)
|
||||
|
||||
ops.push(operation)
|
||||
}
|
||||
|
@ -1,29 +1,20 @@
|
||||
import * as Automerge from 'automerge'
|
||||
import { toSlatePath, toJS } from '../utils/index'
|
||||
|
||||
import { toSlatePath, toJS } from '../utils'
|
||||
import { getTarget } from '../path'
|
||||
|
||||
const removeTextOp = ({ index, path }: Automerge.Diff) => () => ({
|
||||
type: 'remove_text',
|
||||
path: toSlatePath(path).slice(0, path.length),
|
||||
path: toSlatePath(path).slice(0, path?.length),
|
||||
offset: index,
|
||||
text: '*',
|
||||
marks: []
|
||||
})
|
||||
|
||||
const removeMarkOp = ({ path, index }: Automerge.Diff) => (map, doc) => {
|
||||
const slatePath = toSlatePath(path)
|
||||
const target = getTarget(doc, slatePath)
|
||||
|
||||
return {
|
||||
type: 'remove_mark',
|
||||
path: slatePath,
|
||||
mark: {
|
||||
type: target.marks[index].type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeNodesOp = ({ index, obj, path }: Automerge.Diff) => (map, doc) => {
|
||||
const removeNodeOp = ({ index, obj, path }: Automerge.Diff) => (
|
||||
map: any,
|
||||
doc: any
|
||||
) => {
|
||||
const slatePath = toSlatePath(path)
|
||||
if (!map.hasOwnProperty(obj)) {
|
||||
const target = getTarget(doc, [...slatePath, index] as any)
|
||||
@ -35,34 +26,20 @@ const removeNodesOp = ({ index, obj, path }: Automerge.Diff) => (map, doc) => {
|
||||
type: 'remove_node',
|
||||
path: slatePath.length ? slatePath.concat(index) : [index],
|
||||
node: {
|
||||
object: 'text'
|
||||
text: '*'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeAnnotationOp = ({ key }: Automerge.Diff) => (map, doc) => {
|
||||
const annotation = toJS(doc.annotations[key])
|
||||
|
||||
if (annotation) {
|
||||
return {
|
||||
type: 'remove_annotation',
|
||||
annotation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeByType = {
|
||||
text: removeTextOp,
|
||||
nodes: removeNodesOp,
|
||||
marks: removeMarkOp,
|
||||
annotations: removeAnnotationOp
|
||||
}
|
||||
|
||||
const opRemove = (op: Automerge.Diff, [map, ops]) => {
|
||||
const opRemove = (op: Automerge.Diff, [map, ops]: any) => {
|
||||
try {
|
||||
const { index, path, obj } = op
|
||||
const { index, path, obj, type } = op
|
||||
|
||||
if (map.hasOwnProperty(obj) && op.type !== 'text') {
|
||||
if (
|
||||
map.hasOwnProperty(obj) &&
|
||||
typeof map[obj] !== 'string' &&
|
||||
type !== 'text'
|
||||
) {
|
||||
map[obj].splice(index, 1)
|
||||
|
||||
return [map, ops]
|
||||
@ -70,7 +47,11 @@ const opRemove = (op: Automerge.Diff, [map, ops]) => {
|
||||
|
||||
if (!path) return [map, ops]
|
||||
|
||||
const fn = removeByType[path[path.length - 1]]
|
||||
const key = path[path.length - 1]
|
||||
|
||||
if (key === 'cursors') return [map, ops]
|
||||
|
||||
const fn = key === 'text' ? removeTextOp : removeNodeOp
|
||||
|
||||
return [map, [...ops, fn(op)]]
|
||||
} catch (e) {
|
||||
|
@ -1,62 +1,31 @@
|
||||
import * as Automerge from 'automerge'
|
||||
import { toSlatePath, toJS } from '../utils/index'
|
||||
|
||||
const setDataOp = ({ path, value }: Automerge.Diff) => map => ({
|
||||
type: 'set_node',
|
||||
path: toSlatePath(path),
|
||||
properties: {},
|
||||
newProperties: {
|
||||
data: map[value]
|
||||
}
|
||||
})
|
||||
import { toSlatePath, toJS } from '../utils'
|
||||
|
||||
const AnnotationSetOp = ({ key, value }: Automerge.Diff) => (map, doc) => {
|
||||
if (!doc.annotations) {
|
||||
doc.annotations = {}
|
||||
}
|
||||
|
||||
let op
|
||||
|
||||
/**
|
||||
* Looks like set_annotation option is broken, temporary disabled
|
||||
*/
|
||||
|
||||
// if (!doc.annotations[key]) {
|
||||
op = {
|
||||
type: 'add_annotation',
|
||||
annotation: map[value]
|
||||
}
|
||||
// } else {
|
||||
// op = {
|
||||
// type: 'set_annotation',
|
||||
// properties: toJS(doc.annotations[key]),
|
||||
// newProperties: map[value]
|
||||
// }
|
||||
// }
|
||||
|
||||
return op
|
||||
}
|
||||
|
||||
const setByType = {
|
||||
data: setDataOp
|
||||
}
|
||||
|
||||
const opSet = (op: Automerge.Diff, [map, ops]) => {
|
||||
const { link, value, path, obj, key } = op
|
||||
try {
|
||||
const set = setByType[key]
|
||||
|
||||
if (set && path) {
|
||||
ops.push(set(op))
|
||||
} else if (map[obj]) {
|
||||
map[obj][key] = link ? map[value] : value
|
||||
const setDataOp = (
|
||||
{ key = '', obj, path, value }: Automerge.Diff,
|
||||
doc: any
|
||||
) => (map: any) => {
|
||||
return {
|
||||
type: 'set_node',
|
||||
path: toSlatePath(path),
|
||||
properties: {
|
||||
[key]: Automerge.getObjectById(doc, obj)?.[key]
|
||||
},
|
||||
newProperties: {
|
||||
[key]: value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotation
|
||||
*/
|
||||
if (path && path.length === 1 && path[0] === 'annotations') {
|
||||
ops.push(AnnotationSetOp(op))
|
||||
const opSet = (op: Automerge.Diff, [map, ops]: any, doc: any) => {
|
||||
const { link, value, path, obj, key } = op
|
||||
|
||||
try {
|
||||
if (path && path[0] !== 'cursors') {
|
||||
ops.push(setDataOp(op, doc))
|
||||
} else if (map[obj]) {
|
||||
map[obj][key as any] = link ? map[value] : value
|
||||
}
|
||||
|
||||
return [map, ops]
|
||||
|
@ -1,68 +1,35 @@
|
||||
import { Selection } from 'slate'
|
||||
import merge from 'lodash/merge'
|
||||
import { Operation, Range } from 'slate'
|
||||
|
||||
import { toJS } from '../utils'
|
||||
import { SyncDoc, CursorKey } from '../model'
|
||||
import { CursorData } from '../model'
|
||||
|
||||
export const setCursor = (
|
||||
doc: SyncDoc,
|
||||
key: CursorKey,
|
||||
selection: Selection,
|
||||
type,
|
||||
data
|
||||
id: string,
|
||||
selection: Range | null,
|
||||
doc: any,
|
||||
operations: Operation[],
|
||||
cursorData: CursorData
|
||||
) => {
|
||||
if (!doc) return
|
||||
const cursorOps = operations.filter(op => op.type === 'set_selection')
|
||||
|
||||
if (!doc.annotations) {
|
||||
doc.annotations = {}
|
||||
}
|
||||
if (!doc.cursors) doc.cursors = {}
|
||||
|
||||
if (!doc.annotations[key]) {
|
||||
doc.annotations[key] = {
|
||||
key,
|
||||
type,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
const newCursor = cursorOps[cursorOps.length - 1]?.newProperties || {}
|
||||
|
||||
const annotation = toJS(doc.annotations[key])
|
||||
|
||||
annotation.focus = selection.end.toJSON()
|
||||
annotation.anchor = selection.start.toJSON()
|
||||
|
||||
annotation.data = merge(annotation.data, data, {
|
||||
isBackward: selection.isBackward,
|
||||
targetPath: selection.isBackward
|
||||
? annotation.anchor.path
|
||||
: annotation.focus.path
|
||||
})
|
||||
|
||||
doc.annotations[key] = annotation
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const removeCursor = (doc: SyncDoc, key: CursorKey) => {
|
||||
if (doc.annotations && doc.annotations[key]) {
|
||||
delete doc.annotations[key]
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
export const cursorOpFilter = (ops, type: string) =>
|
||||
ops.filter(op => {
|
||||
if (op.type === 'set_annotation') {
|
||||
return !(
|
||||
(op.properties && op.properties.type === type) ||
|
||||
(op.newProperties && op.newProperties.type === type)
|
||||
if (selection) {
|
||||
doc.cursors[id] = JSON.stringify(
|
||||
Object.assign(
|
||||
(doc.cursors[id] && JSON.parse(doc.cursors[id])) || {},
|
||||
newCursor,
|
||||
selection,
|
||||
{
|
||||
...cursorData,
|
||||
isForward: Boolean(newCursor.focus)
|
||||
}
|
||||
)
|
||||
} else if (
|
||||
op.type === 'add_annotation' ||
|
||||
op.type === 'remove_annotation'
|
||||
) {
|
||||
return op.annotation.type !== type
|
||||
}
|
||||
)
|
||||
} else {
|
||||
delete doc.cursors[id]
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
return doc
|
||||
}
|
||||
|
@ -1,5 +0,0 @@
|
||||
import { ValueJSON } from 'slate'
|
||||
|
||||
export type CursorKey = string
|
||||
|
||||
export interface SyncDoc extends ValueJSON {}
|
@ -1,2 +1,23 @@
|
||||
export * from './automerge'
|
||||
export * from './slate'
|
||||
import Automerge from 'automerge'
|
||||
import { Node, Range } from 'slate'
|
||||
|
||||
export type SyncDoc = Automerge.Doc<Node & Cursors>
|
||||
|
||||
export type CollabActionType = 'operation' | 'document'
|
||||
|
||||
export interface CollabAction {
|
||||
type: CollabActionType
|
||||
payload: any
|
||||
}
|
||||
|
||||
export interface CursorData {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface Cursor extends Range, CursorData {
|
||||
isForward: boolean
|
||||
}
|
||||
|
||||
export interface Cursors {
|
||||
[key: string]: Cursor
|
||||
}
|
||||
|
@ -1,8 +1,5 @@
|
||||
import { Operation, NodeJSON } from 'slate'
|
||||
import { List } from 'immutable'
|
||||
import { Operation, Path, NodeEntry } from 'slate'
|
||||
|
||||
export type Operations = List<Operation>
|
||||
export type SyncNode = NodeJSON
|
||||
export type Path = List<number>
|
||||
export type SyncNode = NodeEntry
|
||||
|
||||
export { Operation }
|
||||
export { Operation, Path }
|
||||
|
@ -1,38 +1,42 @@
|
||||
import { SyncDoc, Path } from '../model'
|
||||
import { NodeJSON } from 'slate'
|
||||
import { Node, Path } from 'slate'
|
||||
|
||||
export const isTree = (node: NodeJSON): any => node && node.object !== 'text'
|
||||
import { SyncDoc } from '../model'
|
||||
|
||||
export const isTree = (node: Node): boolean => Boolean(node?.children)
|
||||
|
||||
export const getTarget = (doc: SyncDoc, path: Path) => {
|
||||
const iterate = (current: any, idx: number) => {
|
||||
if (!isTree(current) || !current.nodes) {
|
||||
if (!(isTree(current) || current[idx])) {
|
||||
throw new TypeError(
|
||||
`path ${path.toString()} does not match tree ${JSON.stringify(current)}`
|
||||
)
|
||||
}
|
||||
|
||||
return current.nodes[idx]
|
||||
return current[idx] || current?.children[idx]
|
||||
}
|
||||
|
||||
return path.reduce(iterate, doc.document)
|
||||
return path.reduce(iterate, doc)
|
||||
}
|
||||
|
||||
export const getParentPath = (
|
||||
path: Path,
|
||||
level: number = 1
|
||||
): [number, Path] => {
|
||||
if (level > path.size) {
|
||||
if (level > path.length) {
|
||||
throw new TypeError('requested ancestor is higher than root')
|
||||
}
|
||||
|
||||
return [path.get(path.size - level), path.slice(0, path.size - level) as Path]
|
||||
return [path[path.length - level], path.slice(0, path.length - level)]
|
||||
}
|
||||
|
||||
export const getParent = (
|
||||
doc: SyncDoc,
|
||||
path: Path,
|
||||
level = 1
|
||||
): [NodeJSON, number] => {
|
||||
): [any, number] => {
|
||||
const [idx, parentPath] = getParentPath(path, level)
|
||||
|
||||
return [getTarget(doc, parentPath), idx]
|
||||
}
|
||||
|
||||
export const getChildren = (node: Node) => node.children || node
|
||||
|
@ -1,9 +1,11 @@
|
||||
import toSync from './toSync'
|
||||
import hexGen from './hexGen'
|
||||
|
||||
import { CollabAction } from '../model'
|
||||
|
||||
export * from './testUtils'
|
||||
|
||||
const toJS = node => {
|
||||
const toJS = (node: any) => {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(node))
|
||||
} catch (e) {
|
||||
@ -12,8 +14,13 @@ const toJS = node => {
|
||||
}
|
||||
}
|
||||
|
||||
const cloneNode = node => toSync(toJS(node))
|
||||
const cloneNode = (node: any) => toSync(toJS(node))
|
||||
|
||||
const toSlatePath = path => (path ? path.filter(d => Number.isInteger(d)) : [])
|
||||
const toSlatePath = (path: any) =>
|
||||
path ? path.filter((d: any) => Number.isInteger(d)) : []
|
||||
|
||||
export { toSync, toJS, toSlatePath, hexGen, cloneNode }
|
||||
const toCollabAction = (type: any, fn: (action: CollabAction) => void) => (
|
||||
payload: any
|
||||
) => fn({ type, payload })
|
||||
|
||||
export { toSync, toJS, toSlatePath, hexGen, cloneNode, toCollabAction }
|
||||
|
@ -1,27 +1,28 @@
|
||||
import * as Automerge from 'automerge'
|
||||
import { TextJSON } from 'slate'
|
||||
|
||||
export const createTextJSON = (text: string = ''): TextJSON => ({
|
||||
object: 'text',
|
||||
marks: [],
|
||||
import { toSync } from '../'
|
||||
|
||||
import { Node } from 'slate'
|
||||
|
||||
export const createText = (text: string = '') => ({
|
||||
text
|
||||
})
|
||||
|
||||
export const createBlockJSON = (
|
||||
export const createNode = (
|
||||
type: string = 'paragraph',
|
||||
text: string = ''
|
||||
text: string = '',
|
||||
data?: { [key: string]: any }
|
||||
) => ({
|
||||
object: 'block',
|
||||
type,
|
||||
nodes: [createTextJSON(text)]
|
||||
children: [createText(text)],
|
||||
...data
|
||||
})
|
||||
|
||||
export const createValueJSON = () => ({
|
||||
document: {
|
||||
nodes: [createBlockJSON()]
|
||||
}
|
||||
export const createValue = (children?: any): { children: Node[] } => ({
|
||||
children: children || [createNode()]
|
||||
})
|
||||
|
||||
export const createDoc = () => Automerge.from(createValueJSON())
|
||||
export const createDoc = (children?: any) =>
|
||||
Automerge.from(toSync(createValue(children)))
|
||||
|
||||
export const cloneDoc = doc => Automerge.change(doc, '', d => d)
|
||||
export const cloneDoc = (doc: any) => Automerge.change(doc, '', d => d)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as Automerge from 'automerge'
|
||||
|
||||
const toSync = node => {
|
||||
const toSync = (node: any) => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
@ -10,20 +10,10 @@ const toSync = node => {
|
||||
...node,
|
||||
text: new Automerge.Text(node.text)
|
||||
}
|
||||
} else if (node.nodes) {
|
||||
} else if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
nodes: node.nodes.map(toSync)
|
||||
}
|
||||
} else if (node.leaves) {
|
||||
return {
|
||||
...node,
|
||||
leaves: node.leaves.map(toSync)
|
||||
}
|
||||
} else if (node.document) {
|
||||
return {
|
||||
...node,
|
||||
document: toSync(node.document)
|
||||
children: node.children.map(toSync)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,9 @@
|
||||
{
|
||||
"include": ["src/**/*"],
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["./src/**/*"],
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"baseUrl": "src",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib",
|
||||
"composite": true
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
"presets": ["@babel/env", "@babel/react", "@babel/typescript"],
|
||||
"plugins": [
|
||||
"@babel/proposal-class-properties",
|
||||
"@babel/proposal-object-rest-spread"
|
||||
"@babel/proposal-object-rest-spread",
|
||||
"@babel/plugin-proposal-optional-chaining"
|
||||
]
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
scripts-prepend-node-path=true
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@slate-collaborative/client",
|
||||
"version": "0.0.3",
|
||||
"version": "0.5.0",
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
@ -24,14 +24,13 @@
|
||||
"watch": "yarn build:js -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.9.0",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@slate-collaborative/bridge": "^0.0.3",
|
||||
"automerge": "^0.12.1",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"react": "^16.9.0",
|
||||
"slate": "^0.47.8",
|
||||
"socket.io-client": "^2.2.0",
|
||||
"typescript": "^3.6.3"
|
||||
"@slate-collaborative/bridge": "^0.5.0",
|
||||
"automerge": "^0.14.0",
|
||||
"slate": "^0.57.2",
|
||||
"socket.io-client": "^2.3.0",
|
||||
"typescript": "^3.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.6.0",
|
||||
@ -40,8 +39,7 @@
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.5.5",
|
||||
"@babel/preset-env": "^7.6.0",
|
||||
"@babel/preset-typescript": "^7.6.0",
|
||||
"@types/react": "^16.9.2",
|
||||
"@types/slate": "^0.47.1",
|
||||
"@types/react": "^16.9.34",
|
||||
"@types/socket.io-client": "^1.4.32"
|
||||
},
|
||||
"directories": {
|
||||
|
@ -1,206 +0,0 @@
|
||||
import Automerge from 'automerge'
|
||||
import Immutable from 'immutable'
|
||||
import io from 'socket.io-client'
|
||||
|
||||
import { Value, Operation } from 'slate'
|
||||
import { ConnectionModel, ExtendedEditor } from './model'
|
||||
|
||||
import {
|
||||
setCursor,
|
||||
removeCursor,
|
||||
cursorOpFilter,
|
||||
applySlateOps,
|
||||
toSlateOp,
|
||||
toJS
|
||||
} from '@slate-collaborative/bridge'
|
||||
|
||||
class Connection {
|
||||
url: string
|
||||
docId: string
|
||||
docSet: Automerge.DocSet<any>
|
||||
connection: Automerge.Connection<any>
|
||||
socket: SocketIOClient.Socket
|
||||
editor: ExtendedEditor
|
||||
connectOpts: any
|
||||
annotationDataMixin: any
|
||||
cursorAnnotationType: string
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
|
||||
constructor({
|
||||
editor,
|
||||
url,
|
||||
connectOpts,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
cursorAnnotationType,
|
||||
annotationDataMixin
|
||||
}: ConnectionModel) {
|
||||
this.url = url
|
||||
this.editor = editor
|
||||
this.connectOpts = connectOpts
|
||||
this.cursorAnnotationType = cursorAnnotationType
|
||||
this.annotationDataMixin = annotationDataMixin
|
||||
|
||||
this.onConnect = onConnect
|
||||
this.onDisconnect = onDisconnect
|
||||
|
||||
this.docId = connectOpts.path || new URL(url).pathname
|
||||
|
||||
this.docSet = new Automerge.DocSet()
|
||||
|
||||
this.connect()
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
sendData = (data: any) => {
|
||||
this.socket.emit('operation', data)
|
||||
}
|
||||
|
||||
recieveData = async (data: any) => {
|
||||
if (this.docId !== data.docId || !this.connection) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const currentDoc = this.docSet.getDoc(this.docId)
|
||||
const docNew = this.connection.receiveMsg(data)
|
||||
|
||||
if (!docNew) {
|
||||
return
|
||||
}
|
||||
|
||||
const operations = Automerge.diff(currentDoc, docNew)
|
||||
|
||||
if (operations.length !== 0) {
|
||||
const slateOps = toSlateOp(operations, currentDoc)
|
||||
|
||||
this.editor.remote = true
|
||||
|
||||
this.editor.withoutSaving(() => {
|
||||
slateOps.forEach(o => {
|
||||
this.editor.applyOperation(o)
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
this.editor.remote = false
|
||||
|
||||
this.garbageCursors()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
garbageCursors = async () => {
|
||||
const doc = this.docSet.getDoc(this.docId)
|
||||
const { value } = this.editor
|
||||
|
||||
if (value.annotations.size === Object.keys(doc.annotations).length) {
|
||||
return
|
||||
}
|
||||
|
||||
const garbage = []
|
||||
|
||||
value.annotations.forEach(annotation => {
|
||||
if (
|
||||
annotation.type === this.cursorAnnotationType &&
|
||||
!doc.annotations[annotation.key]
|
||||
) {
|
||||
garbage.push(annotation)
|
||||
}
|
||||
})
|
||||
|
||||
if (garbage.length) {
|
||||
this.editor.withoutSaving(() => {
|
||||
garbage.forEach(annotation => {
|
||||
this.editor.removeAnnotation(annotation)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
receiveSlateOps = (operations: Immutable.List<Operation>) => {
|
||||
const doc = this.docSet.getDoc(this.docId)
|
||||
const message = `change from ${this.socket.id}`
|
||||
|
||||
if (!doc) return
|
||||
|
||||
const {
|
||||
value: { selection }
|
||||
} = this.editor
|
||||
|
||||
const withCursor = selection.isFocused ? setCursor : removeCursor
|
||||
|
||||
const changed = Automerge.change(doc, message, (d: any) =>
|
||||
withCursor(
|
||||
applySlateOps(d, cursorOpFilter(operations, this.cursorAnnotationType)),
|
||||
this.socket.id,
|
||||
selection,
|
||||
this.cursorAnnotationType,
|
||||
this.annotationDataMixin
|
||||
)
|
||||
)
|
||||
|
||||
this.docSet.setDoc(this.docId, changed)
|
||||
}
|
||||
|
||||
recieveDocument = data => {
|
||||
const currentDoc = this.docSet.getDoc(this.docId)
|
||||
|
||||
if (!currentDoc) {
|
||||
const doc = Automerge.load(data)
|
||||
|
||||
this.docSet.removeDoc(this.docId)
|
||||
|
||||
this.docSet.setDoc(this.docId, doc)
|
||||
|
||||
this.editor.controller.setValue(Value.fromJSON(toJS(doc)))
|
||||
}
|
||||
|
||||
this.editor.setFocus()
|
||||
|
||||
this.connection.open()
|
||||
|
||||
this.onConnect && setTimeout(this.onConnect, 0)
|
||||
}
|
||||
|
||||
connect = () => {
|
||||
this.socket = io(this.url, { ...this.connectOpts })
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this.connection = new Automerge.Connection(this.docSet, this.sendData)
|
||||
|
||||
this.socket.on('document', this.recieveDocument)
|
||||
|
||||
this.socket.on('operation', this.recieveData)
|
||||
|
||||
this.socket.on('disconnect', this.disconnect)
|
||||
})
|
||||
}
|
||||
|
||||
disconnect = () => {
|
||||
this.onDisconnect()
|
||||
|
||||
console.log('disconnect', this.socket)
|
||||
|
||||
this.connection && this.connection.close()
|
||||
|
||||
delete this.connection
|
||||
|
||||
this.socket.removeListener('document')
|
||||
this.socket.removeListener('operation')
|
||||
}
|
||||
|
||||
close = () => {
|
||||
this.onDisconnect()
|
||||
|
||||
this.socket.close()
|
||||
// this.socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export default Connection
|
@ -1,76 +0,0 @@
|
||||
import React, { Component } from 'react'
|
||||
import { KeyUtils } from 'slate'
|
||||
|
||||
import { hexGen } from '@slate-collaborative/bridge'
|
||||
|
||||
import Connection from './Connection'
|
||||
import { ControllerProps } from './model'
|
||||
|
||||
class Controller extends Component<ControllerProps> {
|
||||
connection?: Connection
|
||||
|
||||
state = {
|
||||
preloading: true
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
editor,
|
||||
url,
|
||||
cursorAnnotationType,
|
||||
annotationDataMixin,
|
||||
connectOpts
|
||||
} = this.props
|
||||
|
||||
KeyUtils.setGenerator(() => hexGen())
|
||||
|
||||
editor.connection = new Connection({
|
||||
editor,
|
||||
url,
|
||||
connectOpts,
|
||||
cursorAnnotationType,
|
||||
annotationDataMixin,
|
||||
onConnect: this.onConnect,
|
||||
onDisconnect: this.onDisconnect
|
||||
})
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { editor } = this.props
|
||||
|
||||
if (editor.connection) editor.connection.close()
|
||||
|
||||
delete editor.connection
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, renderPreloader } = this.props
|
||||
const { preloading } = this.state
|
||||
|
||||
if (renderPreloader && preloading) return renderPreloader()
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
onConnect = () => {
|
||||
const { onConnect, editor } = this.props
|
||||
|
||||
this.setState({
|
||||
preloading: false
|
||||
})
|
||||
|
||||
onConnect && onConnect(editor.connection)
|
||||
}
|
||||
|
||||
onDisconnect = () => {
|
||||
const { onDisconnect, editor } = this.props
|
||||
|
||||
this.setState({
|
||||
preloading: true
|
||||
})
|
||||
|
||||
onDisconnect && onDisconnect(editor.connection)
|
||||
}
|
||||
}
|
||||
|
||||
export default Controller
|
156
packages/client/src/automerge-editor.ts
Normal file
156
packages/client/src/automerge-editor.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import Automerge from 'automerge'
|
||||
|
||||
import { Editor, Operation } from 'slate'
|
||||
|
||||
import {
|
||||
toJS,
|
||||
SyncDoc,
|
||||
CollabAction,
|
||||
toCollabAction,
|
||||
applyOperation,
|
||||
setCursor,
|
||||
toSlateOp,
|
||||
CursorData
|
||||
} from '@slate-collaborative/bridge'
|
||||
|
||||
export interface AutomergeEditor extends Editor {
|
||||
clientId: string
|
||||
|
||||
isRemote: boolean
|
||||
|
||||
docSet: Automerge.DocSet<SyncDoc>
|
||||
connection: Automerge.Connection<SyncDoc>
|
||||
|
||||
onConnectionMsg: (msg: Automerge.Message) => void
|
||||
|
||||
openConnection: () => void
|
||||
closeConnection: () => void
|
||||
|
||||
receiveDocument: (data: string) => void
|
||||
receiveOperation: (data: Automerge.Message) => void
|
||||
|
||||
gabageCursor: () => void
|
||||
|
||||
onCursor: (data: any) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* `AutomergeEditor` contains methods for collaboration-enabled editors.
|
||||
*/
|
||||
|
||||
export const AutomergeEditor = {
|
||||
/**
|
||||
* Create Automerge connection
|
||||
*/
|
||||
|
||||
createConnection: (e: AutomergeEditor, emit: (data: CollabAction) => void) =>
|
||||
new Automerge.Connection(e.docSet, toCollabAction('operation', emit)),
|
||||
|
||||
/**
|
||||
* Apply Slate operations to Automerge
|
||||
*/
|
||||
|
||||
applySlateOps: async (
|
||||
e: AutomergeEditor,
|
||||
docId: string,
|
||||
operations: Operation[],
|
||||
cursorData?: CursorData
|
||||
) => {
|
||||
try {
|
||||
const doc = e.docSet.getDoc(docId)
|
||||
|
||||
if (!doc) {
|
||||
throw new TypeError(`Unknown docId: ${docId}!`)
|
||||
}
|
||||
|
||||
let changed
|
||||
|
||||
for await (let op of operations) {
|
||||
changed = Automerge.change(changed || doc, d =>
|
||||
applyOperation(d.children, op)
|
||||
)
|
||||
}
|
||||
|
||||
changed = Automerge.change(changed || doc, d => {
|
||||
setCursor(e.clientId, e.selection, d, operations, cursorData || {})
|
||||
})
|
||||
|
||||
e.docSet.setDoc(docId, changed as any)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Receive and apply document to Automerge docSet
|
||||
*/
|
||||
|
||||
receiveDocument: (e: AutomergeEditor, docId: string, data: string) => {
|
||||
const currentDoc = e.docSet.getDoc(docId)
|
||||
|
||||
const externalDoc = Automerge.load<SyncDoc>(data)
|
||||
|
||||
const mergedDoc = Automerge.merge<SyncDoc>(
|
||||
externalDoc,
|
||||
currentDoc || Automerge.init()
|
||||
)
|
||||
|
||||
e.docSet.setDoc(docId, mergedDoc)
|
||||
|
||||
Editor.withoutNormalizing(e, () => {
|
||||
e.children = toJS(mergedDoc).children
|
||||
|
||||
e.onChange()
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate automerge diff, convert and apply operations to Editor
|
||||
*/
|
||||
|
||||
applyOperation: (
|
||||
e: AutomergeEditor,
|
||||
docId: string,
|
||||
data: Automerge.Message
|
||||
) => {
|
||||
try {
|
||||
const current: any = e.docSet.getDoc(docId)
|
||||
|
||||
const updated = e.connection.receiveMsg(data)
|
||||
|
||||
const operations = Automerge.diff(current, updated)
|
||||
|
||||
if (operations.length) {
|
||||
const slateOps = toSlateOp(operations, current)
|
||||
|
||||
e.isRemote = true
|
||||
|
||||
Editor.withoutNormalizing(e, () => {
|
||||
slateOps.forEach((o: Operation) => {
|
||||
e.apply(o)
|
||||
})
|
||||
})
|
||||
|
||||
e.onCursor && e.onCursor(updated.cursors)
|
||||
|
||||
Promise.resolve().then(_ => (e.isRemote = false))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
|
||||
garbageCursor: (e: AutomergeEditor, docId: string) => {
|
||||
const doc = e.docSet.getDoc(docId)
|
||||
|
||||
const changed = Automerge.change<SyncDoc>(doc, d => {
|
||||
delete d.cusors
|
||||
})
|
||||
|
||||
e.onCursor && e.onCursor(null)
|
||||
|
||||
e.docSet.setDoc(docId, changed)
|
||||
|
||||
e.onChange()
|
||||
}
|
||||
}
|
6
packages/client/src/index.ts
Normal file
6
packages/client/src/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import useCursor from './useCursor'
|
||||
import withAutomerge from './withAutomerge'
|
||||
import withSocketIO from './withSocketIO'
|
||||
import withIOCollaboration from './withIOCollaboration'
|
||||
|
||||
export { withAutomerge, withSocketIO, withIOCollaboration, useCursor }
|
@ -1,30 +0,0 @@
|
||||
import onChange from './onChange'
|
||||
import renderEditor from './renderEditor'
|
||||
import renderAnnotation from './renderAnnotation'
|
||||
|
||||
import renderCursor from './renderCursor'
|
||||
|
||||
import { PluginOptions } from './model'
|
||||
|
||||
export const defaultOpts = {
|
||||
url: 'http://localhost:9000',
|
||||
cursorAnnotationType: 'collaborative_selection',
|
||||
renderCursor,
|
||||
annotationDataMixin: {
|
||||
name: 'an collaborator name',
|
||||
color: 'palevioletred',
|
||||
alphaColor: 'rgba(233, 30, 99, 0.2)'
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = (opts: PluginOptions = defaultOpts) => {
|
||||
const options = { ...defaultOpts, ...opts }
|
||||
|
||||
return {
|
||||
onChange: onChange(options),
|
||||
renderEditor: renderEditor(options),
|
||||
renderAnnotation: renderAnnotation(options)
|
||||
}
|
||||
}
|
||||
|
||||
export default plugin
|
@ -1,43 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Editor, Controller, Value } from 'slate'
|
||||
|
||||
import Connection from './Connection'
|
||||
|
||||
type Data = {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface FixedController extends Controller {
|
||||
setValue: (value: Value) => void
|
||||
}
|
||||
|
||||
export interface ExtendedEditor extends Editor {
|
||||
remote?: boolean
|
||||
connection?: Connection
|
||||
controller: FixedController
|
||||
setFocus: () => void
|
||||
}
|
||||
|
||||
export interface ConnectionModel extends PluginOptions {
|
||||
editor: ExtendedEditor
|
||||
cursorAnnotationType: string
|
||||
onConnect: () => void
|
||||
onDisconnect: () => void
|
||||
}
|
||||
|
||||
export interface ControllerProps extends PluginOptions {
|
||||
editor: ExtendedEditor
|
||||
url?: string
|
||||
connectOpts?: SocketIOClient.ConnectOpts
|
||||
}
|
||||
|
||||
export interface PluginOptions {
|
||||
url?: string
|
||||
connectOpts?: SocketIOClient.ConnectOpts
|
||||
cursorAnnotationType?: string
|
||||
annotationDataMixin?: Data
|
||||
renderPreloader?: () => ReactNode
|
||||
renderCursor?: (data: Data) => ReactNode | any
|
||||
onConnect?: (connection: Connection) => void
|
||||
onDisconnect?: (connection: Connection) => void
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import { ExtendedEditor } from './model'
|
||||
|
||||
const onChange = opts => (editor: ExtendedEditor, next: () => void) => {
|
||||
if (editor.connection && !editor.remote) {
|
||||
const operations: any = editor.operations
|
||||
|
||||
editor.connection.receiveSlateOps(operations)
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
export default onChange
|
@ -1,31 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
const renderAnnotation = ({ cursorAnnotationType, renderCursor }) => (
|
||||
props,
|
||||
editor,
|
||||
next
|
||||
) => {
|
||||
const { children, annotation, attributes, node } = props
|
||||
|
||||
if (annotation.type !== cursorAnnotationType) return next()
|
||||
|
||||
const data = annotation.data.toJS()
|
||||
|
||||
const { targetPath, alphaColor } = data
|
||||
const { document } = editor.value
|
||||
|
||||
const targetNode = document.getNode(targetPath)
|
||||
const showCursor = targetNode && targetNode.key === node.key
|
||||
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
style={{ position: 'relative', background: alphaColor }}
|
||||
>
|
||||
{showCursor ? renderCursor(data) : null}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default renderAnnotation
|
@ -1,7 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import Cursor from './Cursor'
|
||||
|
||||
const renderCursor = data => <Cursor {...data} />
|
||||
|
||||
export default renderCursor
|
@ -1,20 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { PluginOptions } from './model'
|
||||
import Controller from './Controller'
|
||||
|
||||
const renderEditor = (opts: PluginOptions) => (
|
||||
props: any,
|
||||
editor: any,
|
||||
next: any
|
||||
) => {
|
||||
const children = next()
|
||||
|
||||
return (
|
||||
<Controller {...opts} editor={editor}>
|
||||
{children}
|
||||
</Controller>
|
||||
)
|
||||
}
|
||||
|
||||
export default renderEditor
|
68
packages/client/src/useCursor.ts
Normal file
68
packages/client/src/useCursor.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
import { Text, Range, Path, NodeEntry } from 'slate'
|
||||
|
||||
import { toJS, Cursor, Cursors } from '@slate-collaborative/bridge'
|
||||
|
||||
import { AutomergeEditor } from './automerge-editor'
|
||||
|
||||
const useCursor = (
|
||||
e: AutomergeEditor
|
||||
): { decorate: (entry: NodeEntry) => Range[]; cursors: Cursor[] } => {
|
||||
const [cursorData, setSursorData] = useState<Cursor[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
e.onCursor = (data: Cursors) => {
|
||||
const ranges: Cursor[] = []
|
||||
|
||||
const cursors = toJS(data)
|
||||
|
||||
for (let cursor in cursors) {
|
||||
if (cursor !== e.clientId && cursors[cursor]) {
|
||||
ranges.push(JSON.parse(cursors[cursor]))
|
||||
}
|
||||
}
|
||||
|
||||
setSursorData(ranges)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cursors = useMemo<Cursor[]>(() => cursorData, [cursorData])
|
||||
|
||||
const decorate = useCallback(
|
||||
([node, path]: NodeEntry) => {
|
||||
const ranges: Range[] = []
|
||||
|
||||
if (Text.isText(node) && cursors?.length) {
|
||||
cursors.forEach(cursor => {
|
||||
if (Range.includes(cursor, path)) {
|
||||
const { focus, anchor, isForward } = cursor
|
||||
|
||||
ranges.push({
|
||||
...cursor,
|
||||
isCaret: isForward
|
||||
? Path.equals(focus.path, path)
|
||||
: Path.equals(anchor.path, path),
|
||||
anchor: Path.isBefore(anchor.path, path)
|
||||
? { ...anchor, offset: 0 }
|
||||
: anchor,
|
||||
focus: Path.isAfter(focus.path, path)
|
||||
? { ...focus, offset: node.text.length }
|
||||
: focus
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
},
|
||||
[cursors]
|
||||
)
|
||||
|
||||
return {
|
||||
cursors,
|
||||
decorate
|
||||
}
|
||||
}
|
||||
|
||||
export default useCursor
|
105
packages/client/src/withAutomerge.ts
Normal file
105
packages/client/src/withAutomerge.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import Automerge from 'automerge'
|
||||
|
||||
import { Editor } from 'slate'
|
||||
|
||||
import { AutomergeEditor } from './automerge-editor'
|
||||
|
||||
import { CursorData, CollabAction } from '@slate-collaborative/bridge'
|
||||
|
||||
export interface AutomergeOptions {
|
||||
docId: string
|
||||
cursorData?: CursorData
|
||||
}
|
||||
|
||||
/**
|
||||
* The `withAutomerge` plugin contains core collaboration logic.
|
||||
*/
|
||||
|
||||
const withAutomerge = <T extends Editor>(
|
||||
editor: T,
|
||||
options: AutomergeOptions
|
||||
) => {
|
||||
const e = editor as T & AutomergeEditor
|
||||
|
||||
const { onChange } = e
|
||||
|
||||
const { docId, cursorData } = options || {}
|
||||
|
||||
e.docSet = new Automerge.DocSet()
|
||||
|
||||
const createConnection = () => {
|
||||
if (e.connection) e.connection.close()
|
||||
|
||||
e.connection = AutomergeEditor.createConnection(e, (data: CollabAction) =>
|
||||
e.send(data)
|
||||
)
|
||||
|
||||
e.connection.open()
|
||||
}
|
||||
|
||||
createConnection()
|
||||
|
||||
/**
|
||||
* Open Automerge Connection
|
||||
*/
|
||||
|
||||
e.openConnection = () => {
|
||||
e.connection.open()
|
||||
}
|
||||
|
||||
/**
|
||||
* Close Automerge Connection
|
||||
*/
|
||||
|
||||
e.closeConnection = () => {
|
||||
e.connection.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cursor data
|
||||
*/
|
||||
|
||||
e.gabageCursor = () => {
|
||||
AutomergeEditor.garbageCursor(e, docId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor onChange
|
||||
*/
|
||||
|
||||
e.onChange = () => {
|
||||
const operations: any = e.operations
|
||||
|
||||
if (!e.isRemote) {
|
||||
AutomergeEditor.applySlateOps(e, docId, operations, cursorData)
|
||||
}
|
||||
|
||||
onChange()
|
||||
|
||||
// console.log('e', e.children)
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive document value
|
||||
*/
|
||||
|
||||
e.receiveDocument = data => {
|
||||
AutomergeEditor.receiveDocument(e, docId, data)
|
||||
|
||||
createConnection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive Automerge sync operations
|
||||
*/
|
||||
|
||||
e.receiveOperation = data => {
|
||||
if (docId !== data.docId) return
|
||||
|
||||
AutomergeEditor.applyOperation(e, docId, data)
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
export default withAutomerge
|
20
packages/client/src/withIOCollaboration.ts
Normal file
20
packages/client/src/withIOCollaboration.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Editor } from 'slate'
|
||||
import { AutomergeEditor } from './automerge-editor'
|
||||
|
||||
import withAutomerge, { AutomergeOptions } from './withAutomerge'
|
||||
import withSocketIO, {
|
||||
WithSocketIOEditor,
|
||||
SocketIOPluginOptions
|
||||
} from './withSocketIO'
|
||||
|
||||
/**
|
||||
* The `withIOCollaboration` plugin contains collaboration with SocketIO.
|
||||
*/
|
||||
|
||||
const withIOCollaboration = <T extends Editor>(
|
||||
editor: T,
|
||||
options: AutomergeOptions & SocketIOPluginOptions
|
||||
): T & WithSocketIOEditor & AutomergeEditor =>
|
||||
withSocketIO(withAutomerge(editor, options), options)
|
||||
|
||||
export default withIOCollaboration
|
122
packages/client/src/withSocketIO.ts
Normal file
122
packages/client/src/withSocketIO.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import io from 'socket.io-client'
|
||||
|
||||
import { AutomergeEditor } from './automerge-editor'
|
||||
|
||||
import { CollabAction } from '@slate-collaborative/bridge'
|
||||
|
||||
export interface SocketIOPluginOptions {
|
||||
url: string
|
||||
connectOpts: SocketIOClient.ConnectOpts
|
||||
autoConnect?: boolean
|
||||
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
}
|
||||
|
||||
export interface WithSocketIOEditor {
|
||||
socket: SocketIOClient.Socket
|
||||
|
||||
connect: () => void
|
||||
disconnect: () => void
|
||||
|
||||
send: (op: CollabAction) => void
|
||||
receive: (op: CollabAction) => void
|
||||
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The `withSocketIO` plugin contains SocketIO layer logic.
|
||||
*/
|
||||
|
||||
const withSocketIO = <T extends AutomergeEditor>(
|
||||
editor: T,
|
||||
options: SocketIOPluginOptions
|
||||
) => {
|
||||
const e = editor as T & WithSocketIOEditor
|
||||
|
||||
const { onConnect, onDisconnect, connectOpts, url, autoConnect } = options
|
||||
|
||||
/**
|
||||
* Connect to Socket.
|
||||
*/
|
||||
|
||||
e.connect = () => {
|
||||
if (!e.socket) {
|
||||
e.socket = io(url, { ...connectOpts })
|
||||
|
||||
e.socket.on('connect', () => {
|
||||
e.clientId = e.socket.id
|
||||
|
||||
e.openConnection()
|
||||
|
||||
onConnect && onConnect()
|
||||
})
|
||||
}
|
||||
|
||||
e.socket.on('msg', (data: CollabAction) => {
|
||||
e.receive(data)
|
||||
})
|
||||
|
||||
e.socket.on('disconnect', () => {
|
||||
e.gabageCursor()
|
||||
|
||||
onDisconnect && onDisconnect()
|
||||
})
|
||||
|
||||
e.socket.connect()
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Socket.
|
||||
*/
|
||||
|
||||
e.disconnect = () => {
|
||||
e.socket.removeListener('msg')
|
||||
|
||||
e.socket.close()
|
||||
|
||||
e.closeConnection()
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive transport msg.
|
||||
*/
|
||||
|
||||
e.receive = (msg: CollabAction) => {
|
||||
switch (msg.type) {
|
||||
case 'operation':
|
||||
return e.receiveOperation(msg.payload)
|
||||
case 'document':
|
||||
return e.receiveDocument(msg.payload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to socket.
|
||||
*/
|
||||
|
||||
e.send = (msg: CollabAction) => {
|
||||
e.socket.emit('msg', msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close socket and connection.
|
||||
*/
|
||||
|
||||
e.destroy = () => {
|
||||
e.socket.close()
|
||||
|
||||
e.closeConnection()
|
||||
}
|
||||
|
||||
autoConnect && e.connect()
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
export default withSocketIO
|
@ -1,12 +1,16 @@
|
||||
{
|
||||
"include": ["src/**/*"],
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["./src/**/*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"rootDir": "src",
|
||||
"baseUrl": "src",
|
||||
"jsx": "react",
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"esModuleInterop": true
|
||||
}
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./src",
|
||||
"outDir": "./lib",
|
||||
"composite": true,
|
||||
"paths": {
|
||||
"@slate-collaborative/bridge": ["../../bridge"]
|
||||
}
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../bridge" }
|
||||
]
|
||||
}
|
||||
|
23
packages/example/.gitignore
vendored
23
packages/example/.gitignore
vendored
@ -1,23 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
10
packages/example/extend.tsconfig.json
Normal file
10
packages/example/extend.tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@slate-collaborative/bridge": ["../../bridge"],
|
||||
"@slate-collaborative/client": ["../../client"]
|
||||
}
|
||||
}
|
||||
}
|
@ -1,18 +1,17 @@
|
||||
{
|
||||
"name": "@slate-collaborative/example",
|
||||
"version": "0.0.1",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/core": "^10.0.17",
|
||||
"@emotion/styled": "^10.0.17",
|
||||
"@slate-collaborative/backend": "^0.0.3",
|
||||
"@slate-collaborative/client": "^0.0.3",
|
||||
"@slate-collaborative/backend": "^0.5.0",
|
||||
"@slate-collaborative/client": "^0.5.0",
|
||||
"@types/faker": "^4.1.5",
|
||||
"@types/jest": "24.0.18",
|
||||
"@types/node": "12.7.5",
|
||||
"@types/react": "16.9.2",
|
||||
"@types/react-dom": "16.9.0",
|
||||
"@types/slate-react": "^0.22.5",
|
||||
"@types/randomcolor": "^0.5.4",
|
||||
"@types/react-dom": "^16.9.6",
|
||||
"concurrently": "^4.1.2",
|
||||
"cross-env": "^6.0.3",
|
||||
"express": "^4.17.1",
|
||||
@ -23,14 +22,15 @@
|
||||
"react": "^16.9.0",
|
||||
"react-dom": "^16.9.0",
|
||||
"react-scripts": "3.1.2",
|
||||
"slate": "^0.47.8",
|
||||
"slate-react": "^0.22.8",
|
||||
"typescript": "3.6.3"
|
||||
"slate": "^0.57.2",
|
||||
"slate-history": "^0.57.2",
|
||||
"slate-react": "^0.57.2",
|
||||
"typescript": "^3.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"start:cra": "react-scripts start",
|
||||
"build": "cross-env NODE_ENV=production && react-scripts build",
|
||||
"build:example": "cross-env NODE_ENV=production && react-scripts build",
|
||||
"dev": "concurrently \"yarn start:cra\" \"yarn serve\"",
|
||||
"serve": "nodemon --watch ../backend/lib --inspect server.js"
|
||||
},
|
||||
|
@ -10,7 +10,13 @@
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="logo192.png" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||
/>
|
||||
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
|
||||
<title>Slate collaborative</title>
|
||||
</head>
|
||||
<body>
|
||||
|
@ -1,7 +1,17 @@
|
||||
const Connection = require('@slate-collaborative/backend')
|
||||
const defaultValue = require('./src/defaultValue')
|
||||
const { SocketIOConnection } = require('@slate-collaborative/backend')
|
||||
const express = require('express')
|
||||
|
||||
const defaultValue = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
text: 'Hello collaborator!'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const PORT = process.env.PORT || 9000
|
||||
|
||||
const server = express()
|
||||
@ -11,18 +21,19 @@ const server = express()
|
||||
const config = {
|
||||
entry: server, // or specify port to start io server
|
||||
defaultValue,
|
||||
saveTreshold: 2000,
|
||||
saveFrequency: 2000,
|
||||
onAuthRequest: async (query, socket) => {
|
||||
// some query validation
|
||||
return true
|
||||
},
|
||||
onDocumentLoad: async pathname => {
|
||||
// return initial document ValueJSON by pathnme
|
||||
// request initial document ValueJSON by pathnme
|
||||
return defaultValue
|
||||
},
|
||||
onDocumentSave: async (pathname, document) => {
|
||||
onDocumentSave: async (pathname, doc) => {
|
||||
// save document
|
||||
// console.log('onDocumentSave', pathname, doc)
|
||||
}
|
||||
}
|
||||
|
||||
const connection = new Connection(config)
|
||||
const connection = new SocketIOConnection(config)
|
||||
|
@ -1,53 +1,38 @@
|
||||
import React, { Component } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import faker from 'faker'
|
||||
|
||||
import styled from '@emotion/styled'
|
||||
|
||||
import Room from './Room'
|
||||
|
||||
class App extends Component<{}, { rooms: string[] }> {
|
||||
state = {
|
||||
rooms: []
|
||||
}
|
||||
const App = () => {
|
||||
const [rooms, setRooms] = useState<string[]>([])
|
||||
|
||||
componentDidMount() {
|
||||
this.addRoom()
|
||||
}
|
||||
const addRoom = () => setRooms(rooms.concat(faker.lorem.slug(4)))
|
||||
|
||||
render() {
|
||||
const { rooms } = this.state
|
||||
const removeRoom = (room: string) => () =>
|
||||
setRooms(rooms.filter(r => r !== room))
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Panel>
|
||||
<AddButton type="button" onClick={this.addRoom}>
|
||||
Add Room
|
||||
</AddButton>
|
||||
</Panel>
|
||||
{rooms.map(room => (
|
||||
<Room key={room} slug={room} removeRoom={this.removeRoom(room)} />
|
||||
))}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
useEffect(() => {
|
||||
addRoom()
|
||||
}, [])
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Panel>
|
||||
<AddButton type="button" onClick={addRoom}>
|
||||
Add Room
|
||||
</AddButton>
|
||||
</Panel>
|
||||
{rooms.map(room => (
|
||||
<Room key={room} slug={room} removeRoom={removeRoom(room)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
const Container = styled.div``
|
||||
|
||||
const Panel = styled.div`
|
||||
display: flex;
|
||||
`
|
||||
|
@ -1,4 +1,34 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
interface Caret {
|
||||
color: string
|
||||
isForward: boolean
|
||||
name: string
|
||||
}
|
||||
|
||||
const Caret: React.FC<Caret> = ({ color, isForward, name }) => {
|
||||
const cursorStyles = {
|
||||
...cursorStyleBase,
|
||||
background: color,
|
||||
left: isForward ? '100%' : '0%'
|
||||
}
|
||||
const caretStyles = {
|
||||
...caretStyleBase,
|
||||
background: color,
|
||||
left: isForward ? '100%' : '0%'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span contentEditable={false} style={cursorStyles}>
|
||||
{name}
|
||||
</span>
|
||||
<span contentEditable={false} style={caretStyles} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Caret
|
||||
|
||||
const cursorStyleBase = {
|
||||
position: 'absolute',
|
||||
@ -17,31 +47,7 @@ const caretStyleBase = {
|
||||
top: 0,
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
height: '100%',
|
||||
height: '1.2em',
|
||||
width: 2,
|
||||
background: 'palevioletred'
|
||||
} as any
|
||||
|
||||
const Cursor = ({ color, isBackward, name }) => {
|
||||
const cursorStyles = {
|
||||
...cursorStyleBase,
|
||||
background: color,
|
||||
left: isBackward ? '0%' : '100%'
|
||||
}
|
||||
const caretStyles = {
|
||||
...caretStyleBase,
|
||||
background: color,
|
||||
left: isBackward ? '0%' : '100%'
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<span contentEditable={false} style={cursorStyles}>
|
||||
{name}
|
||||
</span>
|
||||
<span contentEditable={false} style={caretStyles} />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default Cursor
|
@ -1,39 +1,53 @@
|
||||
import React, { Component } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
|
||||
import { createEditor, Node } from 'slate'
|
||||
import { withHistory } from 'slate-history'
|
||||
import { withReact } from 'slate-react'
|
||||
|
||||
import { Value, ValueJSON } from 'slate'
|
||||
import { Editor } from 'slate-react'
|
||||
import randomColor from 'randomcolor'
|
||||
|
||||
import styled from '@emotion/styled'
|
||||
|
||||
import ClientPlugin from '@slate-collaborative/client'
|
||||
import { withIOCollaboration, useCursor } from '@slate-collaborative/client'
|
||||
|
||||
import defaultValue from './defaultValue'
|
||||
import { Instance, Title, H4, Button } from './Elements'
|
||||
|
||||
import { Instance, ClientFrame, Title, H4, Button } from './elements'
|
||||
import EditorFrame from './EditorFrame'
|
||||
|
||||
interface ClienProps {
|
||||
const defaultValue: Node[] = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
children: [
|
||||
{
|
||||
text: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
interface ClientProps {
|
||||
name: string
|
||||
id: string
|
||||
slug: string
|
||||
removeUser: (id: any) => void
|
||||
}
|
||||
|
||||
class Client extends Component<ClienProps> {
|
||||
editor: any
|
||||
const Client: React.FC<ClientProps> = ({ id, name, slug, removeUser }) => {
|
||||
const [value, setValue] = useState<Node[]>(defaultValue)
|
||||
const [isOnline, setOnlineState] = useState<boolean>(false)
|
||||
|
||||
state = {
|
||||
value: Value.fromJSON(defaultValue as ValueJSON),
|
||||
isOnline: false,
|
||||
plugins: []
|
||||
}
|
||||
const color = useMemo(
|
||||
() =>
|
||||
randomColor({
|
||||
luminosity: 'dark',
|
||||
format: 'rgba',
|
||||
alpha: 1
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
componentDidMount() {
|
||||
const color = randomColor({
|
||||
luminosity: 'dark',
|
||||
format: 'rgba',
|
||||
alpha: 1
|
||||
})
|
||||
const editor = useMemo(() => {
|
||||
const slateEditor = withReact(withHistory(createEditor()))
|
||||
|
||||
const origin =
|
||||
process.env.NODE_ENV === 'production'
|
||||
@ -41,74 +55,60 @@ class Client extends Component<ClienProps> {
|
||||
: 'http://localhost:9000'
|
||||
|
||||
const options = {
|
||||
url: `${origin}/${this.props.slug}`,
|
||||
connectOpts: {
|
||||
query: {
|
||||
name: this.props.name,
|
||||
token: this.props.id,
|
||||
slug: this.props.slug
|
||||
}
|
||||
},
|
||||
annotationDataMixin: {
|
||||
name: this.props.name,
|
||||
docId: '/' + slug,
|
||||
cursorData: {
|
||||
name,
|
||||
color,
|
||||
alphaColor: color.slice(0, -2) + '0.2)'
|
||||
},
|
||||
// renderPreloader: () => <div>PRELOADER!!!!!!</div>,
|
||||
onConnect: this.onConnect,
|
||||
onDisconnect: this.onDisconnect
|
||||
url: `${origin}/${slug}`,
|
||||
connectOpts: {
|
||||
query: {
|
||||
name,
|
||||
token: id,
|
||||
slug
|
||||
}
|
||||
},
|
||||
onConnect: () => setOnlineState(true),
|
||||
onDisconnect: () => setOnlineState(false)
|
||||
}
|
||||
|
||||
const plugin = ClientPlugin(options)
|
||||
return withIOCollaboration(slateEditor, options)
|
||||
}, [])
|
||||
|
||||
this.setState({
|
||||
plugins: [plugin]
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
editor.connect()
|
||||
|
||||
render() {
|
||||
const { plugins, isOnline, value } = this.state
|
||||
const { id, name } = this.props
|
||||
return editor.destroy
|
||||
}, [])
|
||||
|
||||
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
|
||||
const { decorate } = useCursor(editor)
|
||||
|
||||
const toggleOnline = () => {
|
||||
const { connect, disconnect } = editor
|
||||
isOnline ? disconnect() : connect()
|
||||
}
|
||||
|
||||
return (
|
||||
<Instance online={isOnline}>
|
||||
<Title>
|
||||
<Head>Editor: {name}</Head>
|
||||
<Button type="button" onClick={toggleOnline}>
|
||||
Go {isOnline ? 'offline' : 'online'}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => removeUser(id)}>
|
||||
Remove
|
||||
</Button>
|
||||
</Title>
|
||||
|
||||
<EditorFrame
|
||||
editor={editor}
|
||||
value={value}
|
||||
decorate={decorate}
|
||||
onChange={(value: Node[]) => setValue(value)}
|
||||
/>
|
||||
</Instance>
|
||||
)
|
||||
}
|
||||
|
||||
export default Client
|
||||
|
193
packages/example/src/EditorFrame.tsx
Normal file
193
packages/example/src/EditorFrame.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React, { useCallback } from 'react'
|
||||
|
||||
import { Transforms, Editor, Node } from 'slate'
|
||||
import {
|
||||
Slate,
|
||||
ReactEditor,
|
||||
Editable,
|
||||
RenderLeafProps,
|
||||
useSlate
|
||||
} from 'slate-react'
|
||||
|
||||
import { ClientFrame, IconButton, Icon } from './Elements'
|
||||
|
||||
import Caret from './Caret'
|
||||
|
||||
const LIST_TYPES = ['numbered-list', 'bulleted-list']
|
||||
|
||||
export interface EditorFrame {
|
||||
editor: ReactEditor
|
||||
value: Node[]
|
||||
decorate: any
|
||||
onChange: (value: Node[]) => void
|
||||
}
|
||||
|
||||
const renderElement = (props: any) => <Element {...props} />
|
||||
|
||||
const EditorFrame: React.FC<EditorFrame> = ({
|
||||
editor,
|
||||
value,
|
||||
decorate,
|
||||
onChange
|
||||
}) => {
|
||||
const renderLeaf = useCallback((props: any) => <Leaf {...props} />, [
|
||||
decorate
|
||||
])
|
||||
|
||||
return (
|
||||
<ClientFrame>
|
||||
<Slate editor={editor} value={value} onChange={onChange}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
backgroundColor: 'white',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<MarkButton format="bold" icon="format_bold" />
|
||||
<MarkButton format="italic" icon="format_italic" />
|
||||
<MarkButton format="underline" icon="format_underlined" />
|
||||
<MarkButton format="code" icon="code" />
|
||||
<BlockButton format="heading-one" icon="looks_one" />
|
||||
<BlockButton format="heading-two" icon="looks_two" />
|
||||
<BlockButton format="block-quote" icon="format_quote" />
|
||||
<BlockButton format="numbered-list" icon="format_list_numbered" />
|
||||
<BlockButton format="bulleted-list" icon="format_list_bulleted" />
|
||||
</div>
|
||||
|
||||
<Editable
|
||||
renderElement={renderElement}
|
||||
renderLeaf={renderLeaf}
|
||||
decorate={decorate}
|
||||
/>
|
||||
</Slate>
|
||||
</ClientFrame>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorFrame
|
||||
|
||||
const toggleBlock = (editor: any, format: any) => {
|
||||
const isActive = isBlockActive(editor, format)
|
||||
const isList = LIST_TYPES.includes(format)
|
||||
|
||||
Transforms.unwrapNodes(editor, {
|
||||
match: n => LIST_TYPES.includes(n.type),
|
||||
split: true
|
||||
})
|
||||
|
||||
Transforms.setNodes(editor, {
|
||||
type: isActive ? 'paragraph' : isList ? 'list-item' : format
|
||||
})
|
||||
|
||||
if (!isActive && isList) {
|
||||
const block = { type: format, children: [] }
|
||||
Transforms.wrapNodes(editor, block)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMark = (editor: any, format: any) => {
|
||||
const isActive = isMarkActive(editor, format)
|
||||
|
||||
if (isActive) {
|
||||
Editor.removeMark(editor, format)
|
||||
} else {
|
||||
Editor.addMark(editor, format, true)
|
||||
}
|
||||
}
|
||||
|
||||
const isBlockActive = (editor: any, format: any) => {
|
||||
const [match] = Editor.nodes(editor, {
|
||||
match: n => n.type === format
|
||||
})
|
||||
|
||||
return !!match
|
||||
}
|
||||
|
||||
const isMarkActive = (editor: any, format: any) => {
|
||||
const marks = Editor.marks(editor)
|
||||
return marks ? marks[format] === true : false
|
||||
}
|
||||
|
||||
const Element: React.FC<any> = ({ attributes, children, element }) => {
|
||||
switch (element.type) {
|
||||
case 'block-quote':
|
||||
return <blockquote {...attributes}>{children}</blockquote>
|
||||
case 'bulleted-list':
|
||||
return <ul {...attributes}>{children}</ul>
|
||||
case 'heading-one':
|
||||
return <h1 {...attributes}>{children}</h1>
|
||||
case 'heading-two':
|
||||
return <h2 {...attributes}>{children}</h2>
|
||||
case 'list-item':
|
||||
return <li {...attributes}>{children}</li>
|
||||
case 'numbered-list':
|
||||
return <ol {...attributes}>{children}</ol>
|
||||
default:
|
||||
return <p {...attributes}>{children}</p>
|
||||
}
|
||||
}
|
||||
|
||||
const Leaf: React.FC<RenderLeafProps> = ({ attributes, children, leaf }) => {
|
||||
if (leaf.bold) {
|
||||
children = <strong>{children}</strong>
|
||||
}
|
||||
|
||||
if (leaf.code) {
|
||||
children = <code>{children}</code>
|
||||
}
|
||||
|
||||
if (leaf.italic) {
|
||||
children = <em>{children}</em>
|
||||
}
|
||||
|
||||
if (leaf.underline) {
|
||||
children = <u>{children}</u>
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
style={{
|
||||
position: 'relative',
|
||||
backgroundColor: leaf.alphaColor
|
||||
}}
|
||||
>
|
||||
{leaf.isCaret ? <Caret {...(leaf as any)} /> : null}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const BlockButton: React.FC<any> = ({ format, icon }) => {
|
||||
const editor = useSlate()
|
||||
return (
|
||||
<IconButton
|
||||
active={isBlockActive(editor, format)}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
toggleBlock(editor, format)
|
||||
}}
|
||||
>
|
||||
<Icon className="material-icons">{icon}</Icon>
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
const MarkButton: React.FC<any> = ({ format, icon }) => {
|
||||
const editor = useSlate()
|
||||
return (
|
||||
<IconButton
|
||||
active={isMarkActive(editor, format)}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
toggleMark(editor, format)
|
||||
}}
|
||||
>
|
||||
<Icon className="material-icons">{icon}</Icon>
|
||||
</IconButton>
|
||||
)
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
import React, { Component, ChangeEvent } from 'react'
|
||||
import React, { useState, ChangeEvent } from 'react'
|
||||
|
||||
import faker from 'faker'
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
import { RoomWrapper, H4, Title, Button, Grid, Input } from './elements'
|
||||
import { RoomWrapper, H4, Title, Button, Grid, Input } from './Elements'
|
||||
|
||||
import Client from './Client'
|
||||
|
||||
@ -16,78 +17,57 @@ interface RoomProps {
|
||||
removeRoom: () => void
|
||||
}
|
||||
|
||||
interface RoomState {
|
||||
users: User[]
|
||||
slug: string
|
||||
rebuild: boolean
|
||||
}
|
||||
const createUser = (): User => ({
|
||||
id: faker.random.uuid(),
|
||||
name: `${faker.name.firstName()} ${faker.name.lastName()}`
|
||||
})
|
||||
|
||||
class Room extends Component<RoomProps, RoomState> {
|
||||
state = {
|
||||
users: [],
|
||||
slug: this.props.slug,
|
||||
rebuild: false
|
||||
}
|
||||
const Room: React.FC<RoomProps> = ({ slug, removeRoom }) => {
|
||||
const [users, setUsers] = useState<User[]>([createUser(), createUser()])
|
||||
const [roomSlug, setRoomSlug] = useState<string>(slug)
|
||||
const [isRemounted, setRemountState] = useState(false)
|
||||
|
||||
componentDidMount() {
|
||||
this.addUser()
|
||||
setTimeout(this.addUser, 10)
|
||||
}
|
||||
|
||||
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 }))
|
||||
const remount = debounce(() => {
|
||||
setRemountState(true)
|
||||
setTimeout(setRemountState, 50, false)
|
||||
}, 300)
|
||||
|
||||
const changeSlug = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setRoomSlug(e.target.value)
|
||||
remount()
|
||||
}
|
||||
|
||||
const addUser = () => setUsers(users => users.concat(createUser()))
|
||||
|
||||
const removeUser = (userId: string) =>
|
||||
setUsers(users => users.filter((u: User) => u.id !== userId))
|
||||
|
||||
return (
|
||||
<RoomWrapper>
|
||||
<Title>
|
||||
<H4>Document slug:</H4>
|
||||
<Input type="text" value={roomSlug} onChange={changeSlug} />
|
||||
<Button type="button" onClick={addUser}>
|
||||
Add random user
|
||||
</Button>
|
||||
<Button type="button" onClick={removeRoom}>
|
||||
Remove Room
|
||||
</Button>
|
||||
</Title>
|
||||
<Grid>
|
||||
{users.map((user: User) =>
|
||||
isRemounted ? null : (
|
||||
<Client
|
||||
{...user}
|
||||
slug={roomSlug}
|
||||
key={user.id}
|
||||
removeUser={removeUser}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Grid>
|
||||
</RoomWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default Room
|
||||
|
@ -1,17 +0,0 @@
|
||||
module.exports = {
|
||||
document: {
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'paragraph',
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
marks: [],
|
||||
text: 'Hello collaborator!'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -36,6 +36,14 @@ export const Button = styled.button`
|
||||
}
|
||||
`
|
||||
|
||||
export const IconButton = styled(Button)((props: any) => ({
|
||||
color: props.active ? 'mediumvioletred' : 'lightpink',
|
||||
border: 'none',
|
||||
padding: 0
|
||||
}))
|
||||
|
||||
export const Icon = styled.div``
|
||||
|
||||
export const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-gap: 2vw;
|
||||
@ -61,4 +69,12 @@ export const ClientFrame = styled.div`
|
||||
margin-left: -10px;
|
||||
margin-right: -10px;
|
||||
background: white;
|
||||
blockquote {
|
||||
border-left: 2px solid #ddd;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
padding-left: 10px;
|
||||
color: #aaa;
|
||||
font-style: italic;
|
||||
}
|
||||
`
|
||||
|
@ -2,32 +2,25 @@
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"extends": "./extend.tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"baseUrl": "src",
|
||||
"jsx": "react",
|
||||
"rootDir": "../",
|
||||
"baseUrl": "./src",
|
||||
"allowJs": true,
|
||||
"declaration": false,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noImplicitAny": false,
|
||||
"removeComments": true,
|
||||
"noLib": false,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"noEmit": true
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../client"
|
||||
},
|
||||
{
|
||||
"path": "../backend"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -1,16 +1,21 @@
|
||||
{
|
||||
"include": ["packages/*/src"],
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"rootDir": ".",
|
||||
"baseUrl": "./packages",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": true,
|
||||
"noImplicitAny": false,
|
||||
"removeComments": true,
|
||||
"noLib": false,
|
||||
"skipLibCheck": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"target": "es5",
|
||||
"sourceMap": true,
|
||||
"lib": ["es6", "es5"]
|
||||
}
|
||||
"strict": true,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"target": "esnext"
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"include": ["**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user