feat: update to slate 0.5x (#10)
Update Slate-Collaboration to be compatible with Slate 0.5x versions.pull/12/head
parent
fee0098c3d
commit
0fd9390a99
@ -1,11 +1,6 @@
|
|||||||
{
|
{
|
||||||
"packages": ["packages/*"],
|
"lerna": "2.7.1",
|
||||||
|
"version": "0.5.0",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"useWorkspaces": true,
|
"useWorkspaces": true
|
||||||
"version": "independent",
|
|
||||||
"command": {
|
|
||||||
"publish": {
|
|
||||||
"verifyAccess": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
scripts-prepend-node-path=true
|
|
@ -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
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,3 @@
|
|||||||
import Connection from './Connection'
|
import SocketIOConnection from './SocketIOConnection'
|
||||||
|
|
||||||
module.exports = Connection
|
export { SocketIOConnection }
|
||||||
|
@ -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
|
|
||||||
}
|
|
@ -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
|
|
@ -1,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"include": ["src/**/*"],
|
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["./src/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "lib",
|
"rootDir": "./src",
|
||||||
"rootDir": "src",
|
"baseUrl": "./src",
|
||||||
"baseUrl": "src",
|
"outDir": "./lib",
|
||||||
"esModuleInterop": true,
|
"composite": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"paths": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"]
|
"@slate-collaborative/bridge": ["../../bridge"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"references": [
|
||||||
|
{ "path": "../bridge" }
|
||||||
|
]
|
||||||
|
}
|
@ -1,17 +1,8 @@
|
|||||||
{
|
{
|
||||||
"presets": [
|
"presets": ["@babel/env", "@babel/typescript"],
|
||||||
[
|
|
||||||
"@babel/preset-env",
|
|
||||||
{
|
|
||||||
"targets": {
|
|
||||||
"esmodules": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@babel/typescript"
|
|
||||||
],
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@babel/proposal-class-properties",
|
"@babel/proposal-class-properties",
|
||||||
"@babel/proposal-object-rest-spread"
|
"@babel/proposal-object-rest-spread",
|
||||||
|
"@babel/plugin-proposal-optional-chaining"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
scripts-prepend-node-path=true
|
|
@ -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
|
|
||||||
}
|
|
@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -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
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -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
|
||||||
|
}
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -1,5 +0,0 @@
|
|||||||
import { ValueJSON } from 'slate'
|
|
||||||
|
|
||||||
export type CursorKey = string
|
|
||||||
|
|
||||||
export interface SyncDoc extends ValueJSON {}
|
|
@ -1,2 +1,23 @@
|
|||||||
export * from './automerge'
|
import Automerge from 'automerge'
|
||||||
export * from './slate'
|
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
|
||||||
|
}
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
import { Operation, NodeJSON } from 'slate'
|
import { Operation, Path, NodeEntry } from 'slate'
|
||||||
import { List } from 'immutable'
|
|
||||||
|
|
||||||
export type Operations = List<Operation>
|
export type SyncNode = NodeEntry
|
||||||
export type SyncNode = NodeJSON
|
|
||||||
export type Path = List<number>
|
|
||||||
|
|
||||||
export { Operation }
|
export { Operation, Path }
|
||||||
|
@ -1,38 +1,42 @@
|
|||||||
import { SyncDoc, Path } from '../model'
|
import { Node, Path } from 'slate'
|
||||||
import { NodeJSON } 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) => {
|
export const getTarget = (doc: SyncDoc, path: Path) => {
|
||||||
const iterate = (current: any, idx: number) => {
|
const iterate = (current: any, idx: number) => {
|
||||||
if (!isTree(current) || !current.nodes) {
|
if (!(isTree(current) || current[idx])) {
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
`path ${path.toString()} does not match tree ${JSON.stringify(current)}`
|
`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 = (
|
export const getParentPath = (
|
||||||
path: Path,
|
path: Path,
|
||||||
level: number = 1
|
level: number = 1
|
||||||
): [number, Path] => {
|
): [number, Path] => {
|
||||||
if (level > path.size) {
|
if (level > path.length) {
|
||||||
throw new TypeError('requested ancestor is higher than root')
|
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 = (
|
export const getParent = (
|
||||||
doc: SyncDoc,
|
doc: SyncDoc,
|
||||||
path: Path,
|
path: Path,
|
||||||
level = 1
|
level = 1
|
||||||
): [NodeJSON, number] => {
|
): [any, number] => {
|
||||||
const [idx, parentPath] = getParentPath(path, level)
|
const [idx, parentPath] = getParentPath(path, level)
|
||||||
|
|
||||||
return [getTarget(doc, parentPath), idx]
|
return [getTarget(doc, parentPath), idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getChildren = (node: Node) => node.children || node
|
||||||
|
@ -1,27 +1,28 @@
|
|||||||
import * as Automerge from 'automerge'
|
import * as Automerge from 'automerge'
|
||||||
import { TextJSON } from 'slate'
|
|
||||||
|
|
||||||
export const createTextJSON = (text: string = ''): TextJSON => ({
|
import { toSync } from '../'
|
||||||
object: 'text',
|
|
||||||
marks: [],
|
import { Node } from 'slate'
|
||||||
|
|
||||||
|
export const createText = (text: string = '') => ({
|
||||||
text
|
text
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createBlockJSON = (
|
export const createNode = (
|
||||||
type: string = 'paragraph',
|
type: string = 'paragraph',
|
||||||
text: string = ''
|
text: string = '',
|
||||||
|
data?: { [key: string]: any }
|
||||||
) => ({
|
) => ({
|
||||||
object: 'block',
|
|
||||||
type,
|
type,
|
||||||
nodes: [createTextJSON(text)]
|
children: [createText(text)],
|
||||||
|
...data
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createValueJSON = () => ({
|
export const createValue = (children?: any): { children: Node[] } => ({
|
||||||
document: {
|
children: children || [createNode()]
|
||||||
nodes: [createBlockJSON()]
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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)
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
{
|
{
|
||||||
"include": ["src/**/*"],
|
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["./src/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"rootDir": "./src",
|
||||||
"outDir": "lib",
|
"outDir": "./lib",
|
||||||
"rootDir": "src",
|
|
||||||
"baseUrl": "src",
|
|
||||||
"composite": true
|
"composite": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
scripts-prepend-node-path=true
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
@ -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 }
|
@ -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
|
|
@ -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
|
|
||||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -1,7 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
import Cursor from './Cursor'
|
|
||||||
|
|
||||||
const renderCursor = data => <Cursor {...data} />
|
|
||||||
|
|
||||||
export default renderCursor
|
|
@ -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
|
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -1,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"include": ["src/**/*"],
|
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["./src/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "lib",
|
"rootDir": "./src",
|
||||||
"rootDir": "src",
|
"baseUrl": "./src",
|
||||||
"baseUrl": "src",
|
"outDir": "./lib",
|
||||||
"jsx": "react",
|
"composite": true,
|
||||||
"lib": ["dom", "dom.iterable", "es6"],
|
"paths": {
|
||||||
"esModuleInterop": true
|
"@slate-collaborative/bridge": ["../../bridge"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{ "path": "../bridge" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -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*
|
|
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@slate-collaborative/bridge": ["../../bridge"],
|
||||||
|
"@slate-collaborative/client": ["../../client"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -1,17 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
document: {
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
object: 'block',
|
|
||||||
type: 'paragraph',
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
object: 'text',
|
|
||||||
marks: [],
|
|
||||||
text: 'Hello collaborator!'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,16 +1,21 @@
|
|||||||
{
|
{
|
||||||
"include": ["packages/*/src"],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "esnext",
|
"rootDir": ".",
|
||||||
|
"baseUrl": "./packages",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"noImplicitAny": false,
|
"skipLibCheck": true,
|
||||||
"removeComments": true,
|
"declarationMap": true,
|
||||||
"noLib": false,
|
"esModuleInterop": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"emitDecoratorMetadata": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"target": "es5",
|
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"lib": ["es6", "es5"]
|
"strict": true,
|
||||||
}
|
"suppressImplicitAnyIndexErrors": true,
|
||||||
|
"target": "esnext"
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules"],
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"]
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in new issue