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
79 changed files with 2017 additions and 1596 deletions

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