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

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

@@ -1,47 +0,0 @@
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

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