mirror of
https://github.com/cudr/slate-collaborative.git
synced 2024-10-27 20:34:06 +00:00
commit
31599caa71
@ -2,6 +2,7 @@ import io from 'socket.io'
|
|||||||
import { ValueJSON } from 'slate'
|
import { ValueJSON } from 'slate'
|
||||||
import * as Automerge from 'automerge'
|
import * as Automerge from 'automerge'
|
||||||
import throttle from 'lodash/throttle'
|
import throttle from 'lodash/throttle'
|
||||||
|
import merge from 'lodash/merge'
|
||||||
|
|
||||||
import { toSync, toJS } from '@slate-collaborative/bridge'
|
import { toSync, toJS } from '@slate-collaborative/bridge'
|
||||||
|
|
||||||
@ -18,7 +19,7 @@ class Connection {
|
|||||||
this.io = io(options.port, options.connectOpts)
|
this.io = io(options.port, options.connectOpts)
|
||||||
this.docSet = new Automerge.DocSet()
|
this.docSet = new Automerge.DocSet()
|
||||||
this.connections = {}
|
this.connections = {}
|
||||||
this.options = options
|
this.options = merge(defaultOptions, options)
|
||||||
|
|
||||||
this.configure()
|
this.configure()
|
||||||
}
|
}
|
||||||
@ -32,16 +33,28 @@ class Connection {
|
|||||||
private appendDoc = (path: string, value: ValueJSON) => {
|
private appendDoc = (path: string, value: ValueJSON) => {
|
||||||
const sync = toSync(value)
|
const sync = toSync(value)
|
||||||
|
|
||||||
|
sync.annotations = {}
|
||||||
|
|
||||||
const doc = Automerge.from(sync)
|
const doc = Automerge.from(sync)
|
||||||
|
|
||||||
this.docSet.setDoc(path, doc)
|
this.docSet.setDoc(path, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveDoc = throttle(pathname => {
|
private saveDoc = throttle(pathname => {
|
||||||
if (this.options.onDocumentSave) {
|
try {
|
||||||
const doc = this.docSet.getDoc(pathname)
|
if (this.options.onDocumentSave) {
|
||||||
|
const doc = this.docSet.getDoc(pathname)
|
||||||
|
|
||||||
this.options.onDocumentSave(pathname, toJS(doc))
|
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)
|
}, (this.options && this.options.saveTreshold) || 2000)
|
||||||
|
|
||||||
@ -96,6 +109,8 @@ class Connection {
|
|||||||
socket.on('operation', this.onOperation(id, name))
|
socket.on('operation', this.onOperation(id, name))
|
||||||
|
|
||||||
socket.on('disconnect', this.onDisconnect(id, socket))
|
socket.on('disconnect', this.onDisconnect(id, socket))
|
||||||
|
|
||||||
|
this.garbageCursors(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
private onOperation = (id, name) => data => {
|
private onOperation = (id, name) => data => {
|
||||||
@ -103,6 +118,8 @@ class Connection {
|
|||||||
this.connections[id].receiveMsg(data)
|
this.connections[id].receiveMsg(data)
|
||||||
|
|
||||||
this.saveDoc(name)
|
this.saveDoc(name)
|
||||||
|
|
||||||
|
this.garbageCursors(name)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
@ -114,6 +131,9 @@ class Connection {
|
|||||||
|
|
||||||
socket.leave(id)
|
socket.leave(id)
|
||||||
|
|
||||||
|
this.garbageCursor(socket.nsp.name, id)
|
||||||
|
this.garbageCursors(socket.nsp.name)
|
||||||
|
|
||||||
this.garbageNsp()
|
this.garbageNsp()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,6 +147,35 @@ class Connection {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 => {
|
removeDoc = async nsp => {
|
||||||
const doc = this.docSet.getDoc(nsp)
|
const doc = this.docSet.getDoc(nsp)
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ export interface ConnectionOptions {
|
|||||||
connectOpts?: SocketIO.ServerOptions
|
connectOpts?: SocketIO.ServerOptions
|
||||||
defaultValue?: ValueJSON
|
defaultValue?: ValueJSON
|
||||||
saveTreshold?: number
|
saveTreshold?: number
|
||||||
|
cursorAnnotationType?: string
|
||||||
onAuthRequest?: (
|
onAuthRequest?: (
|
||||||
query: Object,
|
query: Object,
|
||||||
socket?: SocketIO.Socket
|
socket?: SocketIO.Socket
|
||||||
|
@ -7,7 +7,8 @@ export const getClients = (io, nsp) =>
|
|||||||
|
|
||||||
export const defaultOptions = {
|
export const defaultOptions = {
|
||||||
port: 9000,
|
port: 9000,
|
||||||
saveTreshold: 2000
|
saveTreshold: 2000,
|
||||||
|
cursorAnnotationType: 'collaborative_selection'
|
||||||
}
|
}
|
||||||
|
|
||||||
export { defaultValue }
|
export { defaultValue }
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
import { Operation, SyncDoc } from '../model'
|
import { Operation, SyncDoc } from '../model/index'
|
||||||
|
|
||||||
export const addAnnotation = (doc: SyncDoc, op: Operation): SyncDoc => {
|
export const addAnnotation = (doc: SyncDoc, op: Operation): SyncDoc => {
|
||||||
|
console.log('addAnnotation!!!', op.toJS())
|
||||||
return doc
|
return doc
|
||||||
}
|
}
|
||||||
|
|
||||||
export const removeAnnotation = (doc: SyncDoc, op: Operation): SyncDoc => {
|
export const removeAnnotation = (doc: SyncDoc, op: Operation): SyncDoc => {
|
||||||
|
console.log('removeAnnotation!!!', op.toJS())
|
||||||
return doc
|
return doc
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setAnnotation = (doc: SyncDoc, op: Operation): SyncDoc => {
|
export const setAnnotation = (doc: SyncDoc, op: Operation): SyncDoc => {
|
||||||
|
console.log('setAnnotation!!!', op.toJS())
|
||||||
return doc
|
return doc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,23 +6,25 @@ import text from './text'
|
|||||||
import annotation from './annotation'
|
import annotation from './annotation'
|
||||||
|
|
||||||
const setSelection = doc => doc
|
const setSelection = doc => doc
|
||||||
const setValue = (doc, op) => doc
|
const setValue = doc => doc
|
||||||
|
|
||||||
const opType: any = {
|
const opType: any = {
|
||||||
...text,
|
...text,
|
||||||
...annotation,
|
...annotation,
|
||||||
...node,
|
...node,
|
||||||
...mark,
|
...mark,
|
||||||
|
set_selection: setSelection
|
||||||
set_selection: setSelection,
|
// set_value: setValue
|
||||||
set_value: setValue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const applyOperation = (doc: SyncDoc, op: Operation): SyncDoc => {
|
export const applyOperation = (doc: SyncDoc, op: Operation): SyncDoc => {
|
||||||
try {
|
try {
|
||||||
const applyOp = opType[op.type]
|
const applyOp = opType[op.type]
|
||||||
|
|
||||||
if (!applyOp) throw new TypeError('Invalid operation type!')
|
if (!applyOp) {
|
||||||
|
console.log('operation', op.toJS())
|
||||||
|
throw new TypeError(`Unsupported operation type: ${op.type}!`)
|
||||||
|
}
|
||||||
|
|
||||||
return applyOp(doc, op)
|
return applyOp(doc, op)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -14,7 +14,7 @@ const byAction = {
|
|||||||
|
|
||||||
const rootKey = '00000000-0000-0000-0000-000000000000'
|
const rootKey = '00000000-0000-0000-0000-000000000000'
|
||||||
|
|
||||||
const toSlateOp = (ops: Automerge.Diff[], currentTree) => {
|
const toSlateOp = (ops: Automerge.Diff[], doc) => {
|
||||||
const iterate = (acc, op) => {
|
const iterate = (acc, op) => {
|
||||||
const action = byAction[op.action]
|
const action = byAction[op.action]
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ const toSlateOp = (ops: Automerge.Diff[], currentTree) => {
|
|||||||
[]
|
[]
|
||||||
])
|
])
|
||||||
|
|
||||||
return defer.flatMap(op => op(tempTree, currentTree))
|
return defer.flatMap(op => op(tempTree, doc)).filter(op => op)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { toSlateOp }
|
export { toSlateOp }
|
||||||
|
@ -15,18 +15,20 @@ const insertNodeOp = ({ value, obj, index, path }: Automerge.Diff) => map => {
|
|||||||
const inserate = ({ nodes, ...json }: any, path) => {
|
const inserate = ({ nodes, ...json }: any, path) => {
|
||||||
const node = nodes ? { ...json, nodes: [] } : json
|
const node = nodes ? { ...json, nodes: [] } : json
|
||||||
|
|
||||||
if (node.object === 'mark') {
|
if (node.object) {
|
||||||
ops.push({
|
if (node.object === 'mark') {
|
||||||
type: 'add_mark',
|
ops.push({
|
||||||
path: path.slice(0, -1),
|
type: 'add_mark',
|
||||||
mark: node
|
path: path.slice(0, -1),
|
||||||
})
|
mark: node
|
||||||
} else {
|
})
|
||||||
ops.push({
|
} else {
|
||||||
type: 'insert_node',
|
ops.push({
|
||||||
path,
|
type: 'insert_node',
|
||||||
node
|
path,
|
||||||
})
|
node
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes && nodes.forEach((n, i) => inserate(n, [...path, i]))
|
nodes && nodes.forEach((n, i) => inserate(n, [...path, i]))
|
||||||
@ -50,7 +52,7 @@ const opInsert = (op: Automerge.Diff, [map, ops]) => {
|
|||||||
|
|
||||||
if (link && map[obj]) {
|
if (link && map[obj]) {
|
||||||
map[obj].splice(index, 0, map[value] || value)
|
map[obj].splice(index, 0, map[value] || value)
|
||||||
} else if (type === 'text' && !path) {
|
} else if ((type === 'text' || type === 'list') && !path) {
|
||||||
map[obj] = map[obj]
|
map[obj] = map[obj]
|
||||||
? map[obj]
|
? map[obj]
|
||||||
.slice(0, index)
|
.slice(0, index)
|
||||||
|
@ -40,10 +40,22 @@ const removeNodesOp = ({ index, obj, path }: Automerge.Diff) => (map, doc) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeAnnotationOp = ({ key }: Automerge.Diff) => (map, doc) => {
|
||||||
|
const annotation = toJS(doc.annotations[key])
|
||||||
|
|
||||||
|
if (annotation) {
|
||||||
|
return {
|
||||||
|
type: 'remove_annotation',
|
||||||
|
annotation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const removeByType = {
|
const removeByType = {
|
||||||
text: removeTextOp,
|
text: removeTextOp,
|
||||||
nodes: removeNodesOp,
|
nodes: removeNodesOp,
|
||||||
marks: removeMarkOp
|
marks: removeMarkOp,
|
||||||
|
annotations: removeAnnotationOp
|
||||||
}
|
}
|
||||||
|
|
||||||
const opRemove = (op: Automerge.Diff, [map, ops]) => {
|
const opRemove = (op: Automerge.Diff, [map, ops]) => {
|
||||||
|
@ -10,6 +10,32 @@ const setDataOp = ({ path, value }: Automerge.Diff) => map => ({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 = {
|
const setByType = {
|
||||||
data: setDataOp
|
data: setDataOp
|
||||||
}
|
}
|
||||||
@ -21,10 +47,17 @@ const opSet = (op: Automerge.Diff, [map, ops]) => {
|
|||||||
|
|
||||||
if (set && path) {
|
if (set && path) {
|
||||||
ops.push(set(op))
|
ops.push(set(op))
|
||||||
} else {
|
} else if (map[obj]) {
|
||||||
map[obj][key] = link ? map[value] : value
|
map[obj][key] = link ? map[value] : value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotation
|
||||||
|
*/
|
||||||
|
if (path && path.length === 1 && path[0] === 'annotations') {
|
||||||
|
ops.push(AnnotationSetOp(op))
|
||||||
|
}
|
||||||
|
|
||||||
return [map, ops]
|
return [map, ops]
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e, op, toJS(map))
|
console.error(e, op, toJS(map))
|
||||||
|
69
packages/bridge/src/cursor/index.ts
Normal file
69
packages/bridge/src/cursor/index.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Operation, Selection } from 'slate'
|
||||||
|
import * as Immutable from 'immutable'
|
||||||
|
import merge from 'lodash/merge'
|
||||||
|
|
||||||
|
import { toJS } from '../utils'
|
||||||
|
import { SyncDoc, CursorKey } from '../model'
|
||||||
|
|
||||||
|
export const setCursor = (
|
||||||
|
doc: SyncDoc,
|
||||||
|
key: CursorKey,
|
||||||
|
selection: Selection,
|
||||||
|
type,
|
||||||
|
data
|
||||||
|
) => {
|
||||||
|
if (!doc) return
|
||||||
|
|
||||||
|
if (!doc.annotations) {
|
||||||
|
doc.annotations = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc.annotations[key]) {
|
||||||
|
doc.annotations[key] = {
|
||||||
|
key,
|
||||||
|
type,
|
||||||
|
data: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: Immutable.List<Operation>, type: string) =>
|
||||||
|
ops.filter(op => {
|
||||||
|
if (op.type === 'set_annotation') {
|
||||||
|
return !(
|
||||||
|
(op.properties && op.properties.type === type) ||
|
||||||
|
(op.newProperties && op.newProperties.type === type)
|
||||||
|
)
|
||||||
|
} else if (
|
||||||
|
op.type === 'add_annotation' ||
|
||||||
|
op.type === 'remove_annotation'
|
||||||
|
) {
|
||||||
|
return op.annotation.type !== type
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
@ -1,3 +1,5 @@
|
|||||||
export * from './apply'
|
export * from './apply'
|
||||||
export * from './convert'
|
export * from './convert'
|
||||||
export * from './utils'
|
export * from './utils'
|
||||||
|
export * from './cursor'
|
||||||
|
export * from './model'
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
import { Doc } from 'automerge'
|
export type CursorKey = string
|
||||||
|
|
||||||
export type SyncDoc = Doc<any>
|
export type SyncDoc = any
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
import toSync from './toSync'
|
import toSync from './toSync'
|
||||||
import hexGen from './hexGen'
|
import hexGen from './hexGen'
|
||||||
|
|
||||||
export const toJS = node => JSON.parse(JSON.stringify(node))
|
export const toJS = node => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(node))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Convert to js failed!!! Return null')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const cloneNode = node => toSync(toJS(node))
|
export const cloneNode = node => toSync(toJS(node))
|
||||||
|
|
||||||
|
@ -3,9 +3,16 @@ import Immutable from 'immutable'
|
|||||||
import io from 'socket.io-client'
|
import io from 'socket.io-client'
|
||||||
|
|
||||||
import { Value, Operation } from 'slate'
|
import { Value, Operation } from 'slate'
|
||||||
import { ConnectionModel } from './model'
|
import { ConnectionModel, ExtendedEditor } from './model'
|
||||||
|
|
||||||
import { applySlateOps, toSlateOp, toJS } from '@slate-collaborative/bridge'
|
import {
|
||||||
|
setCursor,
|
||||||
|
removeCursor,
|
||||||
|
cursorOpFilter,
|
||||||
|
applySlateOps,
|
||||||
|
toSlateOp,
|
||||||
|
toJS
|
||||||
|
} from '@slate-collaborative/bridge'
|
||||||
|
|
||||||
class Connection {
|
class Connection {
|
||||||
url: string
|
url: string
|
||||||
@ -13,8 +20,10 @@ class Connection {
|
|||||||
docSet: Automerge.DocSet<any>
|
docSet: Automerge.DocSet<any>
|
||||||
connection: Automerge.Connection<any>
|
connection: Automerge.Connection<any>
|
||||||
socket: SocketIOClient.Socket
|
socket: SocketIOClient.Socket
|
||||||
editor: any
|
editor: ExtendedEditor
|
||||||
connectOpts: any
|
connectOpts: any
|
||||||
|
annotationDataMixin: any
|
||||||
|
cursorAnnotationType: string
|
||||||
onConnect?: () => void
|
onConnect?: () => void
|
||||||
onDisconnect?: () => void
|
onDisconnect?: () => void
|
||||||
|
|
||||||
@ -23,11 +32,16 @@ class Connection {
|
|||||||
url,
|
url,
|
||||||
connectOpts,
|
connectOpts,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect
|
onDisconnect,
|
||||||
|
cursorAnnotationType,
|
||||||
|
annotationDataMixin
|
||||||
}: ConnectionModel) {
|
}: ConnectionModel) {
|
||||||
this.url = url
|
this.url = url
|
||||||
this.editor = editor
|
this.editor = editor
|
||||||
this.connectOpts = connectOpts
|
this.connectOpts = connectOpts
|
||||||
|
this.cursorAnnotationType = cursorAnnotationType
|
||||||
|
this.annotationDataMixin = annotationDataMixin
|
||||||
|
|
||||||
this.onConnect = onConnect
|
this.onConnect = onConnect
|
||||||
this.onDisconnect = onDisconnect
|
this.onDisconnect = onDisconnect
|
||||||
|
|
||||||
@ -68,7 +82,9 @@ class Connection {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
setTimeout(() => (this.editor.remote = false), 5)
|
await Promise.resolve()
|
||||||
|
|
||||||
|
this.editor.remote = false
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
@ -81,8 +97,20 @@ class Connection {
|
|||||||
|
|
||||||
if (!doc) return
|
if (!doc) return
|
||||||
|
|
||||||
|
const {
|
||||||
|
value: { selection }
|
||||||
|
} = this.editor
|
||||||
|
|
||||||
|
const withCursor = selection.isFocused ? setCursor : removeCursor
|
||||||
|
|
||||||
const changed = Automerge.change(doc, message, (d: any) =>
|
const changed = Automerge.change(doc, message, (d: any) =>
|
||||||
applySlateOps(d, operations)
|
withCursor(
|
||||||
|
applySlateOps(d, cursorOpFilter(operations, this.cursorAnnotationType)),
|
||||||
|
this.socket.id,
|
||||||
|
selection,
|
||||||
|
this.cursorAnnotationType,
|
||||||
|
this.annotationDataMixin
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
this.docSet.setDoc(this.docId, changed)
|
this.docSet.setDoc(this.docId, changed)
|
||||||
@ -109,7 +137,7 @@ class Connection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connect = () => {
|
connect = () => {
|
||||||
this.socket = io(this.url, this.connectOpts)
|
this.socket = io(this.url, { ...this.connectOpts })
|
||||||
|
|
||||||
this.socket.on('connect', () => {
|
this.socket.on('connect', () => {
|
||||||
this.connection = new Automerge.Connection(this.docSet, this.sendData)
|
this.connection = new Automerge.Connection(this.docSet, this.sendData)
|
||||||
@ -125,6 +153,8 @@ class Connection {
|
|||||||
disconnect = () => {
|
disconnect = () => {
|
||||||
this.onDisconnect()
|
this.onDisconnect()
|
||||||
|
|
||||||
|
console.log('disconnect', this.socket)
|
||||||
|
|
||||||
this.connection && this.connection.close()
|
this.connection && this.connection.close()
|
||||||
|
|
||||||
delete this.connection
|
delete this.connection
|
||||||
@ -137,6 +167,7 @@ class Connection {
|
|||||||
this.onDisconnect()
|
this.onDisconnect()
|
||||||
|
|
||||||
this.socket.close()
|
this.socket.close()
|
||||||
|
// this.socket.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@ import { KeyUtils } from 'slate'
|
|||||||
import { hexGen } from '@slate-collaborative/bridge'
|
import { hexGen } from '@slate-collaborative/bridge'
|
||||||
|
|
||||||
import Connection from './Connection'
|
import Connection from './Connection'
|
||||||
|
|
||||||
import { ControllerProps } from './model'
|
import { ControllerProps } from './model'
|
||||||
|
|
||||||
class Controller extends Component<ControllerProps> {
|
class Controller extends Component<ControllerProps> {
|
||||||
@ -15,7 +14,13 @@ class Controller extends Component<ControllerProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { editor, url, connectOpts } = this.props
|
const {
|
||||||
|
editor,
|
||||||
|
url,
|
||||||
|
cursorAnnotationType,
|
||||||
|
annotationDataMixin,
|
||||||
|
connectOpts
|
||||||
|
} = this.props
|
||||||
|
|
||||||
KeyUtils.setGenerator(() => hexGen())
|
KeyUtils.setGenerator(() => hexGen())
|
||||||
|
|
||||||
@ -23,6 +28,8 @@ class Controller extends Component<ControllerProps> {
|
|||||||
editor,
|
editor,
|
||||||
url,
|
url,
|
||||||
connectOpts,
|
connectOpts,
|
||||||
|
cursorAnnotationType,
|
||||||
|
annotationDataMixin,
|
||||||
onConnect: this.onConnect,
|
onConnect: this.onConnect,
|
||||||
onDisconnect: this.onDisconnect
|
onDisconnect: this.onDisconnect
|
||||||
})
|
})
|
||||||
@ -37,10 +44,10 @@ class Controller extends Component<ControllerProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { children, preloader } = this.props
|
const { children, renderPreloader } = this.props
|
||||||
const { preloading } = this.state
|
const { preloading } = this.state
|
||||||
|
|
||||||
if (preloader && preloading) return preloader()
|
if (renderPreloader && preloading) return renderPreloader()
|
||||||
|
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
47
packages/client/src/Cursor.tsx
Normal file
47
packages/client/src/Cursor.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React, { Fragment } from 'react'
|
||||||
|
|
||||||
|
const cursorStyleBase = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -2,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
transform: 'translateY(-100%)',
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'white',
|
||||||
|
background: 'palevioletred',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const caretStyleBase = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
height: '100%',
|
||||||
|
width: 2,
|
||||||
|
background: 'palevioletred'
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const Cursor = ({ color, isBackward, name }) => {
|
||||||
|
const cursorStyles = {
|
||||||
|
...cursorStyleBase,
|
||||||
|
background: color,
|
||||||
|
left: isBackward ? '0%' : '100%'
|
||||||
|
}
|
||||||
|
const caretStyles = {
|
||||||
|
...caretStyleBase,
|
||||||
|
background: color,
|
||||||
|
left: isBackward ? '0%' : '100%'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<span contentEditable={false} style={cursorStyles}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span contentEditable={false} style={caretStyles} />
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Cursor
|
@ -1,29 +0,0 @@
|
|||||||
import { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import onChange from './onChange'
|
|
||||||
import renderEditor from './renderEditor'
|
|
||||||
|
|
||||||
import Connection from './Connection'
|
|
||||||
|
|
||||||
export interface PluginOptions {
|
|
||||||
url?: string
|
|
||||||
connectOpts?: SocketIOClient.ConnectOpts
|
|
||||||
preloader?: () => ReactNode
|
|
||||||
onConnect?: (connection: Connection) => void
|
|
||||||
onDisconnect?: (connection: Connection) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultOpts = {
|
|
||||||
url: 'http://localhost:9000'
|
|
||||||
}
|
|
||||||
|
|
||||||
const plugin = (opts: PluginOptions = {}) => {
|
|
||||||
const options = { ...defaultOpts, ...opts }
|
|
||||||
|
|
||||||
return {
|
|
||||||
onChange: onChange(options),
|
|
||||||
renderEditor: renderEditor(options)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default plugin
|
|
30
packages/client/src/index.tsx
Normal file
30
packages/client/src/index.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import onChange from './onChange'
|
||||||
|
import renderEditor from './renderEditor'
|
||||||
|
import renderAnnotation from './renderAnnotation'
|
||||||
|
|
||||||
|
import renderCursor from './renderCursor'
|
||||||
|
|
||||||
|
import { PluginOptions } from './model'
|
||||||
|
|
||||||
|
export const defaultOpts = {
|
||||||
|
url: 'http://localhost:9000',
|
||||||
|
cursorAnnotationType: 'collaborative_selection',
|
||||||
|
renderCursor,
|
||||||
|
annotationDataMixin: {
|
||||||
|
name: 'an collaborator name',
|
||||||
|
color: 'palevioletred',
|
||||||
|
alphaColor: 'rgba(233, 30, 99, 0.2)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = (opts: PluginOptions = defaultOpts) => {
|
||||||
|
const options = { ...defaultOpts, ...opts }
|
||||||
|
|
||||||
|
return {
|
||||||
|
onChange: onChange(options),
|
||||||
|
renderEditor: renderEditor(options),
|
||||||
|
renderAnnotation: renderAnnotation(options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default plugin
|
@ -1,16 +1,28 @@
|
|||||||
import { Editor } from 'slate'
|
import { ReactNode } from 'react'
|
||||||
import { PluginOptions } from './index'
|
import { Editor, Controller, Value } from 'slate'
|
||||||
|
|
||||||
import Connection from './Connection'
|
import Connection from './Connection'
|
||||||
|
|
||||||
export interface ConnectionModel extends PluginOptions {
|
type Data = {
|
||||||
editor: Editor
|
[key: string]: any
|
||||||
onConnect: () => void
|
}
|
||||||
onDisconnect: () => void
|
|
||||||
|
interface FixedController extends Controller {
|
||||||
|
setValue: (value: Value) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtendedEditor extends Editor {
|
export interface ExtendedEditor extends Editor {
|
||||||
remote: boolean
|
remote?: boolean
|
||||||
connection: Connection
|
connection?: Connection
|
||||||
|
controller: FixedController
|
||||||
|
setFocus: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionModel extends PluginOptions {
|
||||||
|
editor: ExtendedEditor
|
||||||
|
cursorAnnotationType: string
|
||||||
|
onConnect: () => void
|
||||||
|
onDisconnect: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControllerProps extends PluginOptions {
|
export interface ControllerProps extends PluginOptions {
|
||||||
@ -18,3 +30,14 @@ export interface ControllerProps extends PluginOptions {
|
|||||||
url?: string
|
url?: string
|
||||||
connectOpts?: SocketIOClient.ConnectOpts
|
connectOpts?: SocketIOClient.ConnectOpts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginOptions {
|
||||||
|
url?: string
|
||||||
|
connectOpts?: SocketIOClient.ConnectOpts
|
||||||
|
cursorAnnotationType?: string
|
||||||
|
annotationDataMixin?: Data
|
||||||
|
renderPreloader?: () => ReactNode
|
||||||
|
renderCursor?: (data: Data) => ReactNode | any
|
||||||
|
onConnect?: (connection: Connection) => void
|
||||||
|
onDisconnect?: (connection: Connection) => void
|
||||||
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ExtendedEditor } from './model'
|
import { ExtendedEditor } from './model'
|
||||||
|
|
||||||
const onChange = opts => (editor: ExtendedEditor, next: () => void) => {
|
const onChange = opts => (editor: ExtendedEditor, next: () => void) => {
|
||||||
if (!editor.remote) {
|
if (editor.connection && !editor.remote) {
|
||||||
const operations: any = editor.operations
|
const operations: any = editor.operations
|
||||||
|
|
||||||
editor.connection.receiveSlateOps(operations)
|
editor.connection.receiveSlateOps(operations)
|
||||||
|
31
packages/client/src/renderAnnotation.tsx
Normal file
31
packages/client/src/renderAnnotation.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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
|
7
packages/client/src/renderCursor.tsx
Normal file
7
packages/client/src/renderCursor.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import Cursor from './Cursor'
|
||||||
|
|
||||||
|
const renderCursor = data => <Cursor {...data} />
|
||||||
|
|
||||||
|
export default renderCursor
|
@ -1,7 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { PluginOptions } from './index'
|
import { PluginOptions } from './model'
|
||||||
|
|
||||||
import Controller from './Controller'
|
import Controller from './Controller'
|
||||||
|
|
||||||
const renderEditor = (opts: PluginOptions) => (
|
const renderEditor = (opts: PluginOptions) => (
|
||||||
|
@ -16,6 +16,8 @@
|
|||||||
"concurrently": "^4.1.2",
|
"concurrently": "^4.1.2",
|
||||||
"faker": "^4.1.0",
|
"faker": "^4.1.0",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.15",
|
||||||
|
"nodemon": "^1.19.2",
|
||||||
|
"randomcolor": "^0.5.4",
|
||||||
"react": "^16.9.0",
|
"react": "^16.9.0",
|
||||||
"react-dom": "^16.9.0",
|
"react-dom": "^16.9.0",
|
||||||
"react-scripts": "3.1.1",
|
"react-scripts": "3.1.1",
|
||||||
@ -25,10 +27,9 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
"dev": "concurrently \"yarn start\" \"yarn serve\"",
|
"dev": "concurrently \"yarn start\" \"yarn serve\"",
|
||||||
"serve": "nodemon --watch ../backend/lib --inspect server.js",
|
"serve": "nodemon --watch ../backend/lib --inspect server.js"
|
||||||
"test": "react-scripts test",
|
|
||||||
"eject": "react-scripts eject"
|
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": "react-app"
|
"extends": "react-app"
|
||||||
@ -44,8 +45,5 @@
|
|||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"nodemon": "^1.19.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,9 +19,11 @@ class App extends Component<{}, { rooms: string[] }> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<AddButton type="button" onClick={this.addRoom}>
|
<Panel>
|
||||||
Add Room
|
<AddButton type="button" onClick={this.addRoom}>
|
||||||
</AddButton>
|
Add Room
|
||||||
|
</AddButton>
|
||||||
|
</Panel>
|
||||||
{rooms.map(room => (
|
{rooms.map(room => (
|
||||||
<Room key={room} slug={room} removeRoom={this.removeRoom(room)} />
|
<Room key={room} slug={room} removeRoom={this.removeRoom(room)} />
|
||||||
))}
|
))}
|
||||||
@ -46,6 +48,10 @@ export default App
|
|||||||
|
|
||||||
const Container = styled.div``
|
const Container = styled.div``
|
||||||
|
|
||||||
|
const Panel = styled.div`
|
||||||
|
display: flex;
|
||||||
|
`
|
||||||
|
|
||||||
const Button = styled.button`
|
const Button = styled.button`
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -2,6 +2,7 @@ import React, { Component } from 'react'
|
|||||||
|
|
||||||
import { Value, ValueJSON } from 'slate'
|
import { Value, ValueJSON } from 'slate'
|
||||||
import { Editor } from 'slate-react'
|
import { Editor } from 'slate-react'
|
||||||
|
import randomColor from 'randomcolor'
|
||||||
|
|
||||||
import styled from '@emotion/styled'
|
import styled from '@emotion/styled'
|
||||||
|
|
||||||
@ -23,12 +24,18 @@ class Client extends Component<ClienProps> {
|
|||||||
|
|
||||||
state = {
|
state = {
|
||||||
value: Value.fromJSON(defaultValue as ValueJSON),
|
value: Value.fromJSON(defaultValue as ValueJSON),
|
||||||
isOnline: true,
|
isOnline: false,
|
||||||
plugins: []
|
plugins: []
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const plugin = ClientPlugin({
|
const color = randomColor({
|
||||||
|
luminosity: 'dark',
|
||||||
|
format: 'rgba',
|
||||||
|
alpha: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = {
|
||||||
url: `http://localhost:9000/${this.props.slug}`,
|
url: `http://localhost:9000/${this.props.slug}`,
|
||||||
connectOpts: {
|
connectOpts: {
|
||||||
query: {
|
query: {
|
||||||
@ -37,10 +44,17 @@ class Client extends Component<ClienProps> {
|
|||||||
slug: this.props.slug
|
slug: this.props.slug
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// preloader: () => <div>PRELOADER!!!!!!</div>,
|
annotationDataMixin: {
|
||||||
|
name: this.props.name,
|
||||||
|
color,
|
||||||
|
alphaColor: color.slice(0, -2) + '0.2)'
|
||||||
|
},
|
||||||
|
// renderPreloader: () => <div>PRELOADER!!!!!!</div>,
|
||||||
onConnect: this.onConnect,
|
onConnect: this.onConnect,
|
||||||
onDisconnect: this.onDisconnect
|
onDisconnect: this.onDisconnect
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const plugin = ClientPlugin(options)
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
plugins: [plugin]
|
plugins: [plugin]
|
||||||
|
Loading…
Reference in New Issue
Block a user