启用协作编辑
A common use case for text editors is collaborative editing, and the Slate editor was designed with this in mind. You can enable multiplayer editing with Yjs, a network-agnostic CRDT implementation that allows you to share data among connected users. Because Yjs is network-agnostic, each project requires a communication provider set up on the back end to link users together.
In this guide, we'll show you how to set up a collaborative Slate editor using a Yjs provider. We'll also be adding slate-yjs which allows you to add multiplayer features to Slate, such as live cursors.
Let's start with a basic editor:
import { Slate } from 'slate-react'
const initialValue = {
children: [{ text: '' }],
}
export const CollaborativeEditor = () => {
return <SlateEditor />
}
const SlateEditor = () => {
const [editor] = useState(() => withReact(createEditor()))
return (
<Slate editor={editor} initialValue={initialValue}>
<Editable />
</Slate>
)
}
Yjs is network-agnostic, which means each Yjs provider is set up in a slightly different way. For example @liveblocks/yjs is fully-hosted, whereas others such as y-websocket require you to host your own WebSocket server. Because of this, we'll use code snippets that work for each provider, without going into too much detail about setting up the provider itself.
This is how to connect to a collaborative Yjs document, ready to be used in your Slate editor.
import { useEffect, useMemo, useState } from 'react'
import { createEditor, Editor, Transforms } from 'slate'
import { Editable, Slate, withReact } from 'slate-react'
import * as Y from 'yjs'
const initialValue = {
children: [{ text: '' }],
}
export const CollaborativeEditor = () => {
const [connected, setConnected] = useState(false)
const [sharedType, setSharedType] = useState()
const [provider, setProvider] = useState()
// Set up your Yjs provider and document
useEffect(() => {
const yDoc = new Y.Doc()
const sharedDoc = yDoc.get('slate', Y.XmlText)
// Set up your Yjs provider. This line of code is different for each provider.
const yProvider = new YjsProvider(/* ... */)
yProvider.on('sync', setConnected)
setSharedType(sharedDoc)
setProvider(yProvider)
return () => {
yDoc?.destroy()
yProvider?.off('sync', setConnected)
yProvider?.destroy()
}
}, [])
if (!connected || !sharedType || !provider) {
return <div>Loading…</div>
}
return <SlateEditor />
}
const SlateEditor = () => {
const [editor] = useState(() => withReact(createEditor()))
return (
<Slate editor={editor} initialValue={initialValue}>
<Editable />
</Slate>
)
}
After setting up your Yjs document like this, you can then link it your editor by passing down sharedType
, which
contains the multiplayer text, and by using functions from slate-yjs
. We're also passing down provider
which will be
helpful later.
import { useEffect, useMemo, useState } from 'react'
import { createEditor, Editor, Transforms } from 'slate'
import { Editable, Slate, withReact } from 'slate-react'
import { withYjs, YjsEditor } from '@slate-yjs/core'
import * as Y from 'yjs'
const initialValue = {
children: [{ text: '' }],
}
export const CollaborativeEditor = () => {
const [connected, setConnected] = useState(false)
const [sharedType, setSharedType] = useState()
const [provider, setProvider] = useState()
// Connect to your Yjs provider and document
useEffect(() => {
const yDoc = new Y.Doc()
const sharedDoc = yDoc.get('slate', Y.XmlText)
// Set up your Yjs provider. This line of code is different for each provider.
const yProvider = new YjsProvider(/* ... */)
yProvider.on('sync', setConnected)
setSharedType(sharedDoc)
setProvider(yProvider)
return () => {
yDoc?.destroy()
yProvider?.off('sync', setConnected)
yProvider?.destroy()
}
}, [])
if (!connected || !sharedType || !provider) {
return <div>Loading…</div>
}
return <SlateEditor sharedType={sharedType} provider={provider} />
}
const SlateEditor = ({ sharedType, provider }) => {
const editor = useMemo(() => {
const e = withReact(withYjs(createEditor(), sharedType))
// Ensure editor always has at least 1 valid child
const { normalizeNode } = e
e.normalizeNode = entry => {
const [node] = entry
if (!Editor.isEditor(node) || node.children.length > 0) {
return normalizeNode(entry)
}
Transforms.insertNodes(editor, initialValue, { at: [0] })
}
return e
}, [])
useEffect(() => {
YjsEditor.connect(editor)
return () => YjsEditor.disconnect(editor)
}, [editor])
return (
<Slate editor={editor} initialValue={initialValue}>
<Editable />
</Slate>
)
}
That's all you need to attach Yjs to Slate!
Let's look at a real-world example of setting up Yjs—here's a code snippet for setting up
a Liveblocks provider. Liveblocks uses the concept of rooms,
spaces where users can
collaborative. To use a Liveblocks provider, you join a multiplayer room with RoomProvider
, then pass the room
to new LiveblocksProvider
, along with the Yjs document.
import LiveblocksProvider from '@liveblocks/yjs'
import { RoomProvider, useRoom } from '../liveblocks.config'
// Join a Liveblocks room and show the editor after connecting
export const App = () => {
return (
<RoomProvider id="my-room-name" initialPresence={{}}>
<ClientSideSuspense fallback={<div>Loading…</div>}>
{() => <CollaborativeEditor />}
</ClientSideSuspense>
</RoomProvider>
)
}
export const CollaborativeEditor = () => {
const room = useRoom()
const [connected, setConnected] = useState(false)
const [sharedType, setSharedType] = useState()
const [provider, setProvider] = useState()
// Connect to your Yjs provider and document
useEffect(() => {
const yDoc = new Y.Doc()
const sharedDoc = yDoc.get('slate', Y.XmlText)
// Set up your Liveblocks provider with the current room and document
const yProvider = new LiveblocksProvider(room, yDoc)
yProvider.on('sync', setConnected)
setSharedType(sharedDoc)
setProvider(yProvider)
return () => {
yDoc?.destroy()
yProvider?.off('sync', setConnected)
yProvider?.destroy()
}
}, [room])
if (!connected || !sharedType || !provider) {
return <div>Loading…</div>
}
return <SlateEditor sharedType={sharedType} provider={provider} />
}
const SlateEditor = ({ sharedType, provider }) => {
// ...
}
Unlike other providers, Liveblocks hosts your Yjs back end for you, which means you don't need to run your own server to get this working. For more information on setting up Liveblocks providers, make sure to read their Slate getting started guide.
Note that Liveblocks is independent of the Slate project, and isn't required for collaboration, but it may be convenient depending on your needs. Other providers are available should you wish to set up and host a Yjs back end yourself.
After setting up Yjs, it's possible to add multiplayer cursors to your app. You can do this with hooks supplied by slate-yjs, which allow you to find the cursor positions of other users. Here's an example of setting up a cursor component.
import {
CursorOverlayData,
useRemoteCursorOverlayPositions,
} from '@slate-yjs/react'
import { useRef } from 'react'
export function Cursors({ children }) {
const containerRef = useRef(null)
const [cursors] = useRemoteCursorOverlayPositions({ containerRef })
return (
<div className="cursors" ref={containerRef}>
{children}
{cursors.map(cursor => (
<Selection key={cursor.clientId} {...cursor} />
))}
</div>
)
}
function Selection({ data, selectionRects, caretPosition }) {
if (!data) {
return null
}
const selectionStyle = {
backgroundColor: data.color,
}
return (
<>
{selectionRects.map((position, i) => (
<div
style={{ ...selectionStyle, ...position }}
className="selection"
key={i}
/>
))}
{caretPosition && <Caret caretPosition={caretPosition} data={data} />}
</>
)
}
function Caret({ caretPosition, data }) {
const caretStyle = {
...caretPosition,
background: data?.color,
}
const labelStyle = {
transform: 'translateY(-100%)',
background: data?.color,
}
return (
<div style={caretStyle} className="caretMarker">
<div className="caret" style={labelStyle}>
{data?.name}
</div>
</div>
)
}
With some matching styles to set up the positioning:
.cursors {
position: relative;
}
.caretMarker {
position: absolute;
width: 2px;
}
.caret {
position: absolute;
font-size: 14px;
color: #fff;
white-space: nowrap;
top: 0;
border-radius: 6px;
border-bottom-left-radius: 0;
padding: 2px 6px;
pointer-events: none;
}
.selection {
position: absolute;
pointer-events: none;
opacity: 0.2;
}
You can then import this into your SlateEditor
component. Notice that we're using withCursors
from slate-yjs
,
adding provider.awareness
and the current user's name to it. We're then wrapping <Editable>
in the new <Cursors>
component we've just created.
import { useEffect, useMemo, useState } from 'react'
import { createEditor, Editor, Transforms } from 'slate'
import { Editable, Slate, withReact } from 'slate-react'
import { withCursors, withYjs, YjsEditor } from '@slate-yjs/core'
import { Cursors } from './Cursors'
import * as Y from 'yjs'
export const CollaborativeEditor = () => {
// ...
}
const SlateEditor = ({ sharedType, provider }) => {
const editor = useMemo(() => {
const e = withReact(
withCursors(withYjs(createEditor(), sharedType), provider.awareness, {
// The current user's name and color
data: {
name: 'Chris',
color: '##00ff00',
},
})
)
// Ensure editor always has at least 1 valid child
const { normalizeNode } = e
e.normalizeNode = entry => {
const [node] = entry
if (!Editor.isEditor(node) || node.children.length > 0) {
return normalizeNode(entry)
}
Transforms.insertNodes(editor, initialValue, { at: [0] })
}
return e
}, [])
useEffect(() => {
YjsEditor.connect(editor)
return () => YjsEditor.disconnect(editor)
}, [editor])
return (
<Slate editor={editor} initialValue={initialValue}>
<Cursors>
<Editable />
</Cursors>
</Slate>
)
}
You should now be seeing multiplayer cursors! To learn more, make sure to read the slate-yjs documentation.