Building Real-Time Collaborative Text Editors: Understanding and Implementing Operational Transformations
Written by Byte Supreme, published on Byte Supreme
Have you ever wondered how platforms like Google Docs allow multiple people to edit the same document in real-time, without any chaos? The magic behind this smooth collaboration is Operational Transformation (OT). In this article, I’ll guide you through OT in great detail, providing you with complete coding files and thorough explanations. By the end of this article, you’ll not only understand how OT works but also have the necessary code to build your own real-time collaborative text editor.
Overview of Operational Transformation
Before we jump into the code, let me give you a brief refresher on OT. Simply put, OT is an algorithm designed to resolve conflicts when multiple users edit a document at the same time. It ensures that everyone’s changes are reflected in the document, no matter the order in which those changes were made.
Key Components of OT
- Operations: The basic building blocks of OT. These represent user actions like insertions, deletions, or updates.
- Transformation: The process of modifying operations to ensure they don’t conflict with each other.
- Concurrency: Handling edits made by multiple users at the same time.
Now that we have a basic understanding, let’s start coding!
Project Structure
Before diving into the actual code, here’s how I structured my project. Feel free to adjust based on your preferences:
collaborative-editor/
│
├── src/
│ ├── server.js
│ ├── client.js
│ └── ot.js
│
├── index.html
├── style.css
└── README.md
File Overview
- server.js: The backend server that will handle client communication and operations.
- client.js: The front-end code responsible for connecting to the server and managing local document edits.
- ot.js: The core OT algorithm that handles transforming operations.
- index.html: The simple HTML interface for the collaborative editor.
- style.css: Basic styling for the editor UI.
Step 1: Setting Up the Server
In real-time applications, a server is critical. It serves as the central point where all clients send their operations and receive updates.
// src/server.js
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
let documentState = ''; // The global document state
let operationQueue = []; // Keeps track of all operations
// Broadcasting a message to all connected clients
const broadcast = (data) => {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
}
});
};
// When a client connects
wss.on('connection', (ws) => {
console.log('Client connected');
// Send the initial document state to the newly connected client
ws.send(JSON.stringify({ type: 'documentState', document: documentState }));
// Listen for operations from the client
ws.on('message', (message) => {
const operation = JSON.parse(message);
// Update the global document state
if (operation.type === 'edit') {
const transformedOp = applyOperation(documentState, operation);
documentState = transformedOp.newDocument;
operationQueue.push(transformedOp);
// Broadcast the updated document state to all clients
broadcast({ type: 'edit', operation: transformedOp });
}
});
// Handle client disconnection
ws.on('close', () => {
console.log('Client disconnected');
});
});
server.listen(3000, () => {
console.log('Server started on port 3000');
});
Explanation
- The server listens for connections using WebSockets and maintains the global document state.
- When a client connects, the server sends them the current document.
- Whenever a client sends an edit operation, the server processes it and broadcasts the transformed version to all connected clients.
Step 2: Operational Transformation Algorithm
The core part of this project lies in the ot.js
file. This is where I implemented the OT algorithm. Here’s how I tackled it:
// src/ot.js
class Operation {
constructor(type, position, character) {
this.type = type; // 'insert' or 'delete'
this.position = position;
this.character = character; // The character being inserted or deleted
}
}
// Applying an operation to the document
function applyOperation(document, operation) {
let newDocument;
if (operation.type === 'insert') {
newDocument = document.slice(0, operation.position) + operation.character + document.slice(operation.position);
} else if (operation.type === 'delete') {
newDocument = document.slice(0, operation.position) + document.slice(operation.position + 1);
}
return {
newDocument,
appliedOperation: operation
};
}
// Transform an incoming operation to fit the current state
function transformOperation(localOp, remoteOp) {
// Example: Handle positional shifts in case of concurrent inserts or deletes
if (remoteOp.type === 'insert' && remoteOp.position <= localOp.position) {
localOp.position++;
} else if (remoteOp.type === 'delete' && remoteOp.position < localOp.position) {
localOp.position--;
}
return localOp;
}
module.exports = { applyOperation, transformOperation };
Explanation
- applyOperation: This function applies either an insertion or a deletion to the current document state.
- transformOperation: This is where the magic happens. It ensures that concurrent operations (made by other users) are adjusted based on their position in the document. For example, if someone else inserts a character before your change, your position must be shifted accordingly.
Step 3: The Client-Side Code
The client needs to send its edits to the server and update its local document with remote changes. Here’s how I set that up:
// src/client.js
const socket = new WebSocket('ws://localhost:3000');
let localDocument = '';
const editor = document.getElementById('editor');
// When a new message is received from the server
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'documentState') {
// Initial document state from the server
localDocument = data.document;
editor.value = localDocument;
} else if (data.type === 'edit') {
// Apply remote edit
const operation = data.operation;
const result = applyOperation(localDocument, operation.appliedOperation);
localDocument = result.newDocument;
editor.value = localDocument;
}
};
// Send local edits to the server
editor.addEventListener('input', (e) => {
const position = editor.selectionStart;
const character = e.data || '';
const operation = new Operation('insert', position, character);
// Apply locally and send to server
const result = applyOperation(localDocument, operation);
localDocument = result.newDocument;
socket.send(JSON.stringify({ type: 'edit', ...operation }));
});
Explanation
- The client connects to the server using WebSockets.
- It listens for remote changes and updates the local document accordingly.
- Whenever the user makes an edit, the client applies the operation locally and sends the operation to the server.
Step 4: Creating the Front-End (HTML & CSS)
I kept the front-end as simple as possible. Here’s the HTML and CSS I used to create the text editor interface.
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Collaborative Text Editor</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="editor-container">
<textarea id="editor" rows="20" cols="100"></textarea>
</div>
<script src="src/client.js"></script>
</body>
</html>
/* style.css */
.editor-container {
display: flex;
justify-content: center;
margin-top: 50px;
}
textarea {
font-family: 'Courier New', Courier, monospace;
padding: 10px;
border: 2px solid #333;
border-radius: 4px;
width: 80%;
height: 500px;
}
Testing the Application
To test the real-time collaboration, follow these steps:
- Run the Server:
node src/server.js
- Open Two Browser Windows: Open two tabs or browser windows and point them to
index.html
. - Start Editing: Type into one window and see the updates appear in real-time in the other window.
Conclusion
Building a real-time collaborative text editor might seem daunting at first, but with a clear understanding of Operational Transformation and the right structure, it’s entirely achievable. I walked you through each part of the process, from setting up the server, coding the OT algorithm, to implementing the front-end. Now, you can expand on this basic editor, adding features like user authentication, rich text formatting, or even code collaboration.
If you found this article helpful, make sure to check out more tutorials on Byte Supreme for more advanced projects and coding techniques!