DEV Community

Cover image for Building a Real-Time Collaborative Text Editor with Slate.js
priolo
priolo

Posted on • Edited on

Building a Real-Time Collaborative Text Editor with Slate.js

While slate-yjs provides a solid foundation for collaborative editing with Slate.js, I found it challenging to manage updates in a way that suited my project's requirements. To address this, I decided to build a simple synchronization system tailored to my needs, offering more control and flexibility over how updates are handled.

Jess (JavaScript Easy Sync System) is a lightweight library that enables real-time synchronization of shared objects between clients and a server. While it can be used for any type of collaborative editing.

Core Concepts

Jess is built around two main classes:

  • ClientObjects: Maintains local copies of shared objects on the client side and handles server communication
  • ServerObjects: Manages the authoritative state on the server and broadcasts changes to connected clients

The library uses a command-based approach where clients send commands to modify objects, which are then synchronized across all connected clients.

Basic Example with Slate Editor

Let's build a collaborative text editor using Jess and Slate.

First, we'll set up the server:

import { ServerObjects, SlateApplicator } from '@priolo/jess';
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 })
const server = new ServerObjects()
// Use the Slate-specific applicator
server.apply = SlateApplicator.ApplyCommands
let timeoutId: any = null

wss.on('connection', (ws) => {
    console.log('New client connected')

    server.onSend = async (client:WebSocket, message) => client.send(JSON.stringify(message))

    ws.on('message', (message) => {
        console.log(`Received message => ${message}`)

        server.receive(message.toString(), ws)

        clearTimeout(timeoutId)
        timeoutId = setTimeout(() => server.update(), 1000)
    })

    ws.on('close', () => server.disconnect(ws))
})

console.log('WebSocket server is running on ws://localhost:8080')
Enter fullscreen mode Exit fullscreen mode

On the client side, we need to set up the connection and Slate editor:

import { ClientObjects, SlateApplicator } from "@priolo/jess"

// create the local repository of objects
export const clientObjects = new ClientObjects()
// apply SLATE-friendly commands
clientObjects.apply = SlateApplicator.ApplyCommands

// create the socket
const socket = new WebSocket(`ws://${window.location.hostname}:${8080}/`);
// connection to the SERVER: observe the object with id = "doc"
socket.onopen = () => clientObjects.init("doc", true)
// receiving a message from SERVER: send it to the local repository
socket.onmessage = (event) => {
    console.log("received:", event.data)
    clientObjects.receive(event.data)
}

// specific function to send messages to the SERVER (in this case using the WEB SOCKET)
clientObjects.onSend = async (messages) => socket.send(JSON.stringify(messages))

// store COMMANDs and send them when everything is calm
let idTimeout: NodeJS.Timeout
export function sendCommands (command:any) {
    clientObjects.command("doc", command)
    clearTimeout(idTimeout)
    idTimeout = setTimeout(() => clientObjects.update(), 1000)
}
Enter fullscreen mode Exit fullscreen mode

Finally, integrate with Slate in your React component:

function App() {

    const editor = useMemo(() => {
        const editor = withHistory(withReact(createEditor()))
        const { apply } = editor;
        editor.apply = (operation: Operation) => {
            // synchronize everything that is NOT a selection operation
            if (!Operation.isSelectionOperation(operation)) {
                console.log("operation:", operation)
                sendCommands(operation)
            }
            apply(operation);
        }
        clientObjects.observe("doc", () => {
            const children = clientObjects.getObject("doc").valueTemp
            SlateApplicator.UpdateChildren(editor, children)
        })
        return editor
    }, [])


    return (
        <Slate
            editor={editor}
            initialValue={[{ children: [{ text: '' }] }]}
        >
            <Editable
                style={{ backgroundColor: "lightgray", width: 400, height: 400, padding: 5 }}
                renderElement={({ attributes, element, children }) =>
                    <div {...attributes}>{children}</div>
                }
                renderLeaf={({ attributes, children, leaf }) =>
                    <span {...attributes}>{children}</span>
                }
            />
        </Slate>
    )
}

export default App
Enter fullscreen mode Exit fullscreen mode

How It Works

1) When a user makes changes in Slate, operations are intercepted and sent as commands to the server via Jess
2) The server applies the commands and broadcasts updates to all connected clients
3) Clients receive updates and apply them to their local Slate editors
4) Changes are batched and synchronized with a delay to improve performance

Try It Out

You can find a complete working example in the repository. Clone it and run:

# Start the server
cd examples/websocket_slate/server
npm install
npm start

# Start the client
cd examples/websocket_slate/client  
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

Open multiple browser tabs and start typing - you'll see changes sync across all instances.

Link to GitHub Repository

Top comments (0)