feat: update to slate 0.5x (#10)

Update Slate-Collaboration to be compatible with Slate 0.5x versions.
This commit is contained in:
George 2020-05-10 16:50:12 +03:00 committed by GitHub
parent fee0098c3d
commit 0fd9390a99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 2017 additions and 1596 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
lib/
build/
node_modules/
.DS_Store
lerna-debug.log

9
License.md Normal file
View File

@ -0,0 +1,9 @@
The MIT License
Copyright © 20192020, [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.

View File

@ -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
}
```

View File

@ -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
}

View File

@ -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"
}
}

View File

@ -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"
]
}

View File

@ -1 +0,0 @@
scripts-prepend-node-path=true

View File

@ -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"

View 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

View File

@ -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()
}
}

View 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()
}
}

View File

@ -1,3 +1,3 @@
import Connection from './Connection'
import SocketIOConnection from './SocketIOConnection'
module.exports = Connection
export { SocketIOConnection }

View File

@ -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
}

View File

@ -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

View File

@ -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 }

View File

@ -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" }
]
}

View File

@ -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"
]
}

View File

@ -1 +0,0 @@
scripts-prepend-node-path=true

View File

@ -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"
],

View File

@ -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
}

View 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))
})
})
})

View File

@ -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 }

View File

@ -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
}

View File

@ -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
}

View 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
}

View 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

View 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

View 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

View 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

View 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

View 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

View File

@ -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
}

View File

@ -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: '*'
}
}
]

View File

@ -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]

View File

@ -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
}

View File

@ -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)
}

View File

@ -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) {

View File

@ -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]

View File

@ -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
}

View File

@ -1,5 +0,0 @@
import { ValueJSON } from 'slate'
export type CursorKey = string
export interface SyncDoc extends ValueJSON {}

View File

@ -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
}

View File

@ -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 }

View File

@ -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

View File

@ -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 }

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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"
]
}

View File

@ -1 +0,0 @@
scripts-prepend-node-path=true

View File

@ -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": {

View File

@ -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

View File

@ -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

View 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()
}
}

View 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 }

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -1,7 +0,0 @@
import React from 'react'
import Cursor from './Cursor'
const renderCursor = data => <Cursor {...data} />
export default renderCursor

View File

@ -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

View 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

View 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

View 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

View 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

View File

@ -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" }
]
}

View File

@ -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*

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@slate-collaborative/bridge": ["../../bridge"],
"@slate-collaborative/client": ["../../client"]
}
}
}

View File

@ -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"
},

View File

@ -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>

View File

@ -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)

View File

@ -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;
`

View File

@ -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

View File

@ -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

View 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>
)
}

View File

@ -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

View File

@ -1,17 +0,0 @@
module.exports = {
document: {
nodes: [
{
object: 'block',
type: 'paragraph',
nodes: [
{
object: 'text',
marks: [],
text: 'Hello collaborator!'
}
]
}
]
}
}

View File

@ -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;
}
`

View File

@ -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"
}
]
}

View File

@ -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"]
}