feat: cursors should works with annotations

This commit is contained in:
cudr 2019-10-13 19:37:56 +03:00
parent 242a836ce8
commit 0ceb38bbfd
19 changed files with 214 additions and 186 deletions

View File

@ -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,7 +33,7 @@ class Connection {
private appendDoc = (path: string, value: ValueJSON) => { private appendDoc = (path: string, value: ValueJSON) => {
const sync = toSync(value) const sync = toSync(value)
sync.cursors = {} sync.annotations = {}
const doc = Automerge.from(sync) const doc = Automerge.from(sync)
@ -44,12 +45,14 @@ class Connection {
if (this.options.onDocumentSave) { if (this.options.onDocumentSave) {
const doc = this.docSet.getDoc(pathname) const doc = this.docSet.getDoc(pathname)
if (doc) {
const data = toJS(doc) const data = toJS(doc)
delete data.cursors delete data.annotations
this.options.onDocumentSave(pathname, data) this.options.onDocumentSave(pathname, data)
} }
}
} catch (e) { } catch (e) {
console.log(e) console.log(e)
} }
@ -106,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 => {
@ -125,6 +130,8 @@ class Connection {
socket.leave(id) socket.leave(id)
this.garbageCursor(socket.nsp.name, id) this.garbageCursor(socket.nsp.name, id)
this.garbageCursors(socket.nsp.name)
this.garbageNsp() this.garbageNsp()
} }
@ -141,17 +148,32 @@ class Connection {
garbageCursor = (nsp, id) => { garbageCursor = (nsp, id) => {
const doc = this.docSet.getDoc(nsp) const doc = this.docSet.getDoc(nsp)
if (!doc.cursors) return if (!doc.annotations) return
const change = Automerge.change( const change = Automerge.change(doc, `remove cursor ${id}`, (d: any) => {
doc, delete d.annotations[id]
`remove cursor ${id}`, })
(d: any) => delete d.cursors[id]
)
this.docSet.setDoc(nsp, change) 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)

View File

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

View File

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

View File

@ -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,9 +30,7 @@ const toSlateOp = (ops: Automerge.Diff[], currentTree) => {
[] []
]) ])
const res = defer.flatMap(op => op(tempTree, currentTree)) const res = defer.flatMap(op => op(tempTree, doc)).filter(op => op)
console.log('toSlate@@@', ops, res)
return res return res
} }

View File

@ -41,9 +41,13 @@ const removeNodesOp = ({ index, obj, path }: Automerge.Diff) => (map, doc) => {
} }
const removeAnnotationOp = ({ key }: Automerge.Diff) => (map, doc) => { const removeAnnotationOp = ({ key }: Automerge.Diff) => (map, doc) => {
const annotation = toJS(doc.annotations[key])
if (annotation) {
return { return {
type: 'remove_annotation', type: 'remove_annotation',
annotation: toJS(doc.annotations[key]) annotation
}
} }
} }

View File

@ -33,8 +33,6 @@ const AnnotationSetOp = ({ key, value }: Automerge.Diff) => (map, doc) => {
// } // }
// } // }
console.log('opSET!!', key, map[value], op)
return op return op
} }

View File

@ -1,75 +1,68 @@
import { toJS } from '../utils' import { Operation, Selection } from 'slate'
import { Operation } from 'slate'
import { List } from 'immutable' import { List } 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
export const setCursor = (doc, { id, selection, annotationType }) => {
if (!doc.annotations) { if (!doc.annotations) {
doc.annotations = {} doc.annotations = {}
} }
if (!doc.annotations[id]) { if (!doc.annotations[key]) {
doc.annotations[id] = { doc.annotations[key] = {
key: id, key,
type: annotationType, type,
data: {} data: {}
} }
} }
const annotation = toJS(doc.annotations[id]) const annotation = toJS(doc.annotations[key])
// if (selectionOps.size) { annotation.focus = selection.end.toJSON()
// selectionOps.forEach(op => { annotation.anchor = selection.start.toJSON()
// const { newProperties } = op.toJSON()
// if (newProperties.focus) annotation.focus = newProperties.focus annotation.data = merge(annotation.data, data, {
// if (newProperties.anchor) annotation.anchor = newProperties.anchor isBackward: selection.isBackward,
// if (newProperties.data) annotation.data = newProperties.data targetPath: selection.isBackward
// })
// }
// console.log('cursor!!', cursorStart, cursorEnd)
// console.log(
// 'selection!!',
// selection.toJSON(),
// selection.start.offset,
// selection.end.offset
// )
annotation.focus = selection.end.toJSON() || {}
annotation.anchor = selection.start.toJSON() || {}
annotation.data.isBackward = selection.isBackward
annotation.data.targetPath = selection.isBackward
? annotation.anchor.path ? annotation.anchor.path
: annotation.focus.path : annotation.focus.path
})
doc.annotations[id] = annotation doc.annotations[key] = annotation
return doc return doc
} }
export const removeCursor = (doc, { id }) => { export const removeCursor = (doc: SyncDoc, key: CursorKey) => {
// console.log('!!!removeCursor', doc, id) if (doc.annotations && doc.annotations[key]) {
if (doc.annotations && doc.annotations[id]) { delete doc.annotations[key]
delete doc.annotations[id]
} }
return doc return doc
} }
export const cursorOpFilter = (ops: List<Operation>, annotationType) => export const cursorOpFilter = (ops: List<Operation>, type: string) =>
ops.filter(op => { ops.filter(op => {
if (op.type === 'set_annotation') { if (op.type === 'set_annotation') {
return !( return !(
(op.properties && op.properties.type === annotationType) || (op.properties && op.properties.type === type) ||
(op.newProperties && op.newProperties.type === annotationType) (op.newProperties && op.newProperties.type === type)
) )
} else if ( } else if (
op.type === 'add_annotation' || op.type === 'add_annotation' ||
op.type === 'remove_annotation' op.type === 'remove_annotation'
) { ) {
return op.annotation.type !== annotationType return op.annotation.type !== type
} }
return true return true

View File

@ -2,3 +2,4 @@ export * from './apply'
export * from './convert' export * from './convert'
export * from './utils' export * from './utils'
export * from './cursor' export * from './cursor'
export * from './model'

View File

@ -1,3 +1,3 @@
import { Doc } from 'automerge' export type CursorKey = string
export type SyncDoc = Doc<any> export type SyncDoc = any

View File

@ -5,7 +5,7 @@ export const toJS = node => {
try { try {
return JSON.parse(JSON.stringify(node)) return JSON.parse(JSON.stringify(node))
} catch (e) { } catch (e) {
console.error(e) console.error('Convert to js failed!!! Return null')
return null return null
} }
} }

View File

View File

@ -22,7 +22,8 @@ class Connection {
socket: SocketIOClient.Socket socket: SocketIOClient.Socket
editor: ExtendedEditor editor: ExtendedEditor
connectOpts: any connectOpts: any
selection: any annotationDataMixin: any
cursorAnnotationType: string
onConnect?: () => void onConnect?: () => void
onDisconnect?: () => void onDisconnect?: () => void
@ -31,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
@ -58,9 +64,6 @@ class Connection {
const currentDoc = this.docSet.getDoc(this.docId) const currentDoc = this.docSet.getDoc(this.docId)
const docNew = this.connection.receiveMsg(data) const docNew = this.connection.receiveMsg(data)
console.log('current doc before updates', toJS(currentDoc))
console.log('new doc with remote updates!!', toJS(docNew))
if (!docNew) { if (!docNew) {
return return
} }
@ -82,44 +85,12 @@ class Connection {
await Promise.resolve() await Promise.resolve()
this.editor.remote = false this.editor.remote = false
this.setCursors(docNew.cursors)
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
} }
setCursors = cursors => {
if (!cursors) return
// const {
// value: { annotations }
// } = this.editor
// const keyMap = {}
// console.log('cursors', cursors)
// this.editor.withoutSaving(() => {
// annotations.forEach(anno => {
// this.editor.removeAnnotation(anno)
// })
// Object.keys(cursors).forEach(key => {
// if (key !== this.socket.id && !keyMap[key]) {
// this.editor.addAnnotation(toJS(cursors[key]))
// }
// })
// })
console.log(
'!!!!VAL',
this.connectOpts.query.name,
this.editor.value.toJSON({ preserveAnnotations: true })
)
}
receiveSlateOps = (operations: Immutable.List<Operation>) => { receiveSlateOps = (operations: Immutable.List<Operation>) => {
const doc = this.docSet.getDoc(this.docId) const doc = this.docSet.getDoc(this.docId)
const message = `change from ${this.socket.id}` const message = `change from ${this.socket.id}`
@ -130,26 +101,18 @@ class Connection {
value: { selection } value: { selection }
} = this.editor } = this.editor
const annotationType = 'collaborative_selection'
const cursorData = {
id: this.socket.id,
selection,
// selectionOps: operations.filter(op => op.type === 'set_selection'),
annotationType
}
const withCursor = selection.isFocused ? setCursor : removeCursor const withCursor = selection.isFocused ? setCursor : removeCursor
const changed = Automerge.change(doc, message, (d: any) => const changed = Automerge.change(doc, message, (d: any) =>
withCursor( withCursor(
applySlateOps(d, cursorOpFilter(operations, annotationType)), applySlateOps(d, cursorOpFilter(operations, this.cursorAnnotationType)),
cursorData this.socket.id,
selection,
this.cursorAnnotationType,
this.annotationDataMixin
) )
) )
console.log('doc with annotations!!', toJS(changed))
this.docSet.setDoc(this.docId, changed) this.docSet.setDoc(this.docId, changed)
} }
@ -174,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)
@ -190,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
@ -202,6 +167,7 @@ class Connection {
this.onDisconnect() this.onDisconnect()
this.socket.close() this.socket.close()
// this.socket.destroy()
} }
} }

View File

@ -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,19 +28,11 @@ 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
}) })
//@ts-ignore
if (!window.counter) {
//@ts-ignore
window.counter = 1
}
//@ts-ignore
window[`Editor_${counter}`] = editor
//@ts-ignore
window.counter += 1
} }
componentWillUnmount() { componentWillUnmount() {
@ -47,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
} }

View File

@ -1,30 +1,34 @@
import { ReactNode } from 'react'
import onChange from './onChange' import onChange from './onChange'
import renderEditor from './renderEditor' import renderEditor from './renderEditor'
import renderAnnotation from './renderAnnotation' import renderAnnotation from './renderAnnotation'
import Connection from './Connection' import { PluginOptions } from './model'
export interface PluginOptions { export const defaultOpts = {
url?: string url: 'http://localhost:9000',
connectOpts?: SocketIOClient.ConnectOpts cursorAnnotationType: 'collaborative_selection',
preloader?: () => ReactNode annotationDataMixin: {
onConnect?: (connection: Connection) => void name: 'an collaborator'
onDisconnect?: (connection: Connection) => void },
renderCursor: data => data.name,
cursorStyle: {
background: 'palevioletred'
},
caretStyle: {
background: 'palevioletred'
},
selectionStyle: {
background: 'rgba(233, 30, 99, 0.2)'
}
} }
const defaultOpts = { const plugin = (opts: PluginOptions = defaultOpts) => {
url: 'http://localhost:9000'
}
const plugin = (opts: PluginOptions = {}) => {
const options = { ...defaultOpts, ...opts } const options = { ...defaultOpts, ...opts }
return { return {
onChange: onChange(options), onChange: onChange(options),
renderEditor: renderEditor(options), renderEditor: renderEditor(options),
renderAnnotation renderAnnotation: renderAnnotation(options)
} }
} }

View File

@ -1,5 +1,7 @@
import { ReactNode } from 'react'
import CSS from 'csstype'
import { Editor, Controller, Value } from 'slate' import { Editor, Controller, Value } from 'slate'
import { PluginOptions } from './index'
import Connection from './Connection' import Connection from './Connection'
interface FixedController extends Controller { interface FixedController extends Controller {
@ -15,6 +17,7 @@ export interface ExtendedEditor extends Editor {
export interface ConnectionModel extends PluginOptions { export interface ConnectionModel extends PluginOptions {
editor: ExtendedEditor editor: ExtendedEditor
cursorAnnotationType: string
onConnect: () => void onConnect: () => void
onDisconnect: () => void onDisconnect: () => void
} }
@ -24,3 +27,18 @@ 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
caretStyle?: CSS.Properties
cursorStyle?: CSS.Properties
renderPreloader?: () => ReactNode
annotationDataMixin?: {
[key: string]: any
}
renderCursor?: (data: any) => ReactNode | string | any
onConnect?: (connection: Connection) => void
onDisconnect?: (connection: Connection) => void
}

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
const wrapStyles = { const wrapStyles = {
backgroundColor: 'rgba(233, 30, 99, 0.2)', background: 'rgba(233, 30, 99, 0.2)',
position: 'relative' position: 'relative'
} }
@ -24,40 +24,46 @@ const caretStyleBase = {
userSelect: 'none', userSelect: 'none',
height: '100%', height: '100%',
width: 2, width: 2,
background: '#bf1b52' background: 'palevioletred'
} } as any
const renderAnnotation = (props, editor, next) => { const renderAnnotation = ({
cursorAnnotationType,
renderCursor,
cursorStyle = {},
caretStyle = {},
selectionStyle = {}
}) => (props, editor, next) => {
const { children, annotation, attributes, node } = props const { children, annotation, attributes, node } = props
if (annotation.type !== cursorAnnotationType) return next()
const isBackward = annotation.data.get('isBackward') const isBackward = annotation.data.get('isBackward')
const targetPath = annotation.data.get('targetPath') const targetPath = annotation.data.get('targetPath')
const cursorText = renderCursor(annotation.data)
console.log( const cursorStyles = {
'renderAnnotation', ...cursorStyleBase,
annotation.toJS(), ...cursorStyle,
props, left: isBackward ? '0%' : '100%'
isBackward, }
targetPath const caretStyles = {
) ...caretStyleBase,
...caretStyle,
const badgeStyles = { ...cursorStyleBase, left: isBackward ? '0%' : '100%' } left: isBackward ? '0%' : '100%'
const caretStyles = { ...caretStyleBase, left: isBackward ? '0%' : '100%' } }
const { document } = editor.value const { document } = editor.value
const targetNode = document.getNode(targetPath) const targetNode = document.getNode(targetPath)
const isShowCursor = targetNode && targetNode.key === node.key const isShowCursor = targetNode && targetNode.key === node.key
switch (annotation.type) {
case 'collaborative_selection':
return ( return (
<span {...attributes} style={wrapStyles}> <span {...attributes} style={{ ...wrapStyles, ...selectionStyle }}>
{isShowCursor ? ( {isShowCursor ? (
<Fragment> <Fragment>
<span contentEditable={false} style={badgeStyles}> <span contentEditable={false} style={cursorStyles}>
{annotation.key} {cursorText}
</span> </span>
<span contentEditable={false} style={caretStyles} /> <span contentEditable={false} style={caretStyles} />
</Fragment> </Fragment>
@ -65,9 +71,6 @@ const renderAnnotation = (props, editor, next) => {
{children} {children}
</span> </span>
) )
default:
return next()
}
} }
export default renderAnnotation export default renderAnnotation

View File

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

View File

@ -16,6 +16,7 @@
"concurrently": "^4.1.2", "concurrently": "^4.1.2",
"faker": "^4.1.0", "faker": "^4.1.0",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"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",

View File

@ -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,25 @@ class Client extends Component<ClienProps> {
slug: this.props.slug slug: this.props.slug
} }
}, },
// preloader: () => <div>PRELOADER!!!!!!</div>, annotationDataMixin: {
name: this.props.name
},
cursorStyle: {
background: color
},
caretStyle: {
background: color
},
selectionStyle: {
background: color.slice(0, -2) + '0.2)'
},
renderCursor: data => data.get('name'),
// 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]