diff --git a/docs/Summary.md b/docs/Summary.md index 3fcfc24ce..2b8b2faa1 100644 --- a/docs/Summary.md +++ b/docs/Summary.md @@ -10,6 +10,7 @@ - [Applying Custom Formatting](walkthroughs/04-applying-custom-formatting.md) - [Executing Commands](walkthroughs/05-executing-commands.md) - [Saving to a Database](walkthroughs/06-saving-to-a-database.md) +- [Enabling Collaborative Editing](walkthroughs/07-enabling-collaborative-editing.md) - [Using the Bundled Source](walkthroughs/xx-using-the-bundled-source.md) ## Concepts diff --git a/docs/general/resources.md b/docs/general/resources.md index 89f2f2071..00165b75b 100644 --- a/docs/general/resources.md +++ b/docs/general/resources.md @@ -6,16 +6,21 @@ A few resources that are helpful for building with Slate. These libraries are helpful when developing with Slate: -- [`is-hotkey`](https://github.com/ianstormtaylor/is-hotkey) is a simple way to check whether an `onKeyDown` handler should fire for a given hotkey, handling cross-platform concerns like cmd vs. ctrl keys for you automatically. +- [`is-hotkey`](https://github.com/ianstormtaylor/is-hotkey) is a simple way to check whether an `onKeyDown` handler + should fire for a given hotkey, handling cross-platform concerns like cmd vs. ctrl keys for you automatically. ## Extensions and Plugins These extensions and plugins add additional features and capabilities to Slate: +- [@liveblocks/yjs](https://liveblocks.io/docs/api-reference/liveblocks-yjs) A fully-hosted WebSocket infrastructure and + persisted data store for Yjs documents - [Plate](https://github.com/udecode/plate) Rich text editor plugin system for Slate & React -- [`slate-angular`](https://github.com/worktile/slate-angular) Angular-based view layer, which is a useful supplement to Slate for building a rich text editor using Angular. +- [`slate-angular`](https://github.com/worktile/slate-angular) Angular-based view layer, which is a useful supplement to + Slate for building a rich text editor using Angular. - [`slate-yjs`](https://github.com/BitPhinix/slate-yjs/) Collaborative editing utilities for Slate leveraging Yjs -- [`slate-collaborative`](https://github.com/cudr/slate-collaborative) Collaborative editing utilities for Slate leveraging Automerge +- [`slate-collaborative`](https://github.com/cudr/slate-collaborative) Collaborative editing utilities for Slate + leveraging Automerge ## Products @@ -53,15 +58,22 @@ These products use Slate, and can give you an idea of what's possible: These pre-packaged editors are built on top of Slate, and can be helpful to see how you might structure your code: -- [Accord Project Markdown Editor](https://github.com/accordproject/web-components) is a WYSIWYG editor for [CommonMark](https://commonmark.org/). +- [Accord Project Markdown Editor](https://github.com/accordproject/web-components) is a WYSIWYG editor + for [CommonMark](https://commonmark.org/). - [Canner Editor](https://github.com/Canner/canner-slate-editor) is a rich text editor. -- [Chatterslate](https://github.com/chatterbugapp/chatterslate) helps teach language grammar and more at [Chatterbug](https://chatterbug.com). +- [Chatterslate](https://github.com/chatterbugapp/chatterslate) helps teach language grammar and more + at [Chatterbug](https://chatterbug.com). - [CoCalc](https://github.com/sagemathinc/cocalc/) Collaborative Calculation editor in the Cloud -- [French Press Editor](https://github.com/roast-cms/french-press-editor) is a customizeable editor with offline support. +- [French Press Editor](https://github.com/roast-cms/french-press-editor) is a customizeable editor with offline + support. - [Nossas Editor](http://slate-editor.bonde.org/) is a drop-in WYSIWYG editor. -- [React Force Slate Editor](https://github.com/nareshbhatia/react-force/tree/master/packages/slate-editor) is a light-weight medium-style editor with no editor chrome. -- [React Page](https://github.com/react-page/react-page) is a self-contained, customizable inline WYSIWYG editor library. -- [Slate Plugins Next](https://github.com/zbeyens/slate-plugins-next) provides an editor with configurable and extendable plugins. +- [React Force Slate Editor](https://github.com/nareshbhatia/react-force/tree/master/packages/slate-editor) is a + light-weight medium-style editor with no editor chrome. +- [React Page](https://github.com/react-page/react-page) is a self-contained, customizable inline WYSIWYG editor + library. +- [Plate (Plugins for Slate)](https://github.com/udecode/plate) provides an editor with configurable and + extendable plugins. +- - [Tripdocs](https://github.com/ctripcorp/tripdocs): It's a modern, production-ready rich text editor. \(Or, if you have their exact use case, can be a drop-in editor for you.\) diff --git a/docs/walkthroughs/07-enabling-collaborative-editing.md b/docs/walkthroughs/07-enabling-collaborative-editing.md new file mode 100644 index 000000000..8b6b2cf1a --- /dev/null +++ b/docs/walkthroughs/07-enabling-collaborative-editing.md @@ -0,0 +1,401 @@ +# Enabling Collaborative Editing + +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](https://github.com/yjs/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](https://github.com/yjs/yjs#providers) 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](https://github.com/BitPhinix/slate-yjs) which allows you to add multiplayer features to Slate, such +as live cursors. + +Let's start with a basic editor: + +```jsx +import { Slate } from 'slate-react' + +const initialValue = { + children: [{ text: '' }], +} + +export const CollaborativeEditor = () => { + return +} + +const SlateEditor = () => { + const [editor] = useState(() => withReact(createEditor())) + + return ( + + + + ) +} +``` + +Yjs is network-agnostic, which means each Yjs provider is set up in a slightly different way. For +example [@liveblocks/yjs](https://liveblocks.io/docs/api-reference/liveblocks-yjs) is +fully-hosted, whereas others such as [y-websocket](https://github.com/yjs/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. + +```jsx +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
Loading…
+ } + + return +} + +const SlateEditor = () => { + const [editor] = useState(() => withReact(createEditor())) + + return ( + + + + ) +} +``` + +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. + +```jsx +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
Loading…
+ } + + return +} + +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 ( + + + + ) +} +``` + +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](https://liveblocks.io/docs/get-started/yjs-slate-react). 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. + +```jsx +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 ( + + Loading…}> + {() => } + + + ) +} + +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
Loading…
+ } + + return +} + +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](https://liveblocks.io/docs/get-started/yjs-slate-react) 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](https://github.com/yjs/yjs#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. + +```jsx +import { + CursorOverlayData, + useRemoteCursorOverlayPositions, +} from '@slate-yjs/react' +import { useRef } from 'react' + +export function Cursors({ children }) { + const containerRef = useRef(null) + const [cursors] = useRemoteCursorOverlayPositions({ containerRef }) + + return ( +
+ {children} + {cursors.map(cursor => ( + + ))} +
+ ) +} + +function Selection({ data, selectionRects, caretPosition }) { + if (!data) { + return null + } + + const selectionStyle = { + backgroundColor: data.color, + } + + return ( + <> + {selectionRects.map((position, i) => ( +
+ ))} + {caretPosition && } + + ) +} + +function Caret({ caretPosition, data }) { + const caretStyle = { + ...caretPosition, + background: data?.color, + } + + const labelStyle = { + transform: 'translateY(-100%)', + background: data?.color, + } + + return ( +
+
+ {data?.name} +
+
+ ) +} +``` + +With some matching styles to set up the positioning: + +```css +.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 `` in the new `` +component we've just created. + +```jsx +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 ( + + + + + + ) +} +``` + +You should now be seeing multiplayer cursors! To learn more, make sure to read +the [slate-yjs documentation](https://docs.slate-yjs.dev/).