In this blog post, I will walk you through the easiest way to create a multi-user live editing experience in a rich text editor.
Here's the Live Preview
Problems in implementing
Our first solution when editing a document without considering a collaborative experience is just a single UPDATE
API. Whatever changes the user makes we will trigger an UPDATE
API which has the entire document's content to the server and we store that in the database.
If multiple users update the same document at the same time, then if we use the above approach, there is a chance of data loss or breaking the integrity of the document.
In the above image, you can see the final state of the document is different for two users.
Even if you share the same between users using web sockets, the same issue happens.
There are many approaches in which this problem is solved. But the most recommended approach to solve this problem is using a technique called Operational Transformation (OT)**.**
What is Operational Transformation (OT)?
Operational Transformation is a technique used to synchronize and coordinate the actions of multiple users working on a shared document or data structure in a distributed system. The primary goal is to maintain consistency and coherence among the various versions of the document, even when multiple users are making changes simultaneously.
Operational Transformation Principles:
Concurrency Control:
One of the primary objectives of operational transformation is to manage concurrent edits by different users. This involves detecting conflicts and resolving them in a way that maintains the integrity of the document.
Transform Functions
Transform functions are at the core of operational transformation. These functions define how operations performed by one user can be transformed to account for the changes made by another user concurrently.
Idempotent and Commutative Operations:
Idempotent and commutative operations are fundamental concepts in operational transformation. An idempotent operation produces the same result regardless of how many times it is applied, while commutative operations can be applied in any order without changing the result. These properties facilitate the transformation process.
How it works?
So consider Google Docs where a single document is being concurrently edited by more than one person. If a user makes a change,
In the code sandbox below, Add any contents to see the operations in the logs.
Add contents
Apply styles (bold, italics)
Remove contents
And yes, We won't be sending updates for every keypress. To optimize the workflow the user actions are debounced.
Do the same in the below code sandbox, you can see a debounced (500ms) log with all the actions composed into a single operation.
Operations
Mainly Operational Transform is made up of 3 types of operations
Insert
{insert: "Hello!"}
- InsertsHello!
at a particular positionRetain
{retain: 5}
- Retains 5 characters. (Will make more sense later)Delete
{delete: 10}
- Delete 10 characters.
These 3 operations are used in the overall editing experience.
Insert or delete are straightforward, add or remove contents.
insert
and retain
can have another property called as attributes
to maintain applied styles to it. (Apply some styles like bold, and italics to the above code sandbox to see the logs. )
Any formatting or commenting present in the editing text can be persisted using extra properties as well. Like the attributes
property you see in the above image.
So to build this we can create the editor, schema, and operations from scratch. Or we can use some open-source packages that help us achieve this.
I'm using
Quill - This provides changes as operations and tools to work on Operational Transform.
React Quill - A Quill component for React. (We will be using this for React)
quill-delta
- We will be using this package from Quill itself to create and maintain the document asdelta
(A format that Quill follows to achieve OT)
As mentioned above quill-delta
is the package that we use to do the operational transformation. To give a gist of how Operational Transformation works, Here's a small Node.js app
To set and run this node app
mkdir ot-node-demo
cd ot-node-demo
npm init -y
npm i quill-delta
Create a file app.js
and paste the code below in that file.
The code considers two users A
and B
who does concurrent changes onto a same document at the same time (a_after_changes
, b_after_changes
).
Instead of applying those changes directly, Those changes are transformed against the current changes using the transform
function provided by quill-delta
that's where the Operational Transformation occurs.
This makes sure that the changes from another remote are applied considering the changes in the current machine. So the intention as well as the integrity is preserved.
node app
The output of that code will be
A: [ { insert: 'Something' } ]
B: [ { insert: 'Something' } ]
A after changes: Delta { ops: [ { insert: 'Someone' } ] }
B after changes: Delta { ops: [ { insert: 'Something!' } ] }
A after operational transform: Delta { ops: [ { retain: 16 }, { insert: '!' } ] }
B after operational transform: Delta { ops: [ { retain: 14 }, { insert: 'one' }, { delete: 5 } ] }
A's final draft: Delta {
ops: [ { insert: 'Someone' }, { retain: 9 }, { insert: '!' } ]
}
B's final draft: Delta {
ops: [
{ insert: 'Something!' },
{ retain: 4 },
{ insert: 'one' },
{ delete: 5 }
]
}
If you evaluate the operations of the final draft, both the users A
and B
will end up having exact same document. ✨
Let's dive into the actual demo
Let's bring all the things that we learned above into a web app. Here we will be building a Frontend App for a quick intro to OT which mocks the server in the frontend itself. We won't be building a complete system as it requires a lot of time and effort (And resources so it can't be demoed)
This is how the layout of our demo app looks like,
In here By default, there will be 2 users (So we can directly go with the demo without any unwanted flow)
And an Add user button. Consider extra n
users joining the same room (like more users visiting the same Google doc.)
The component structure for this looks like
<App>
<AddUser />
<User />
</App>
Where,
<App />
- Acts as a controller and also as a server in a real-world scenario. It holds the data which supposed to be stored on the server.operations: {delta: Delta, name: string}[]
- The temporary operations that are being transferred between clients.delta
- TheDelta
object fromquill-delta
that has the current change.name
- User's name who is responsible for the change. (So it doesn't need to be applied for that user)
content: Delta
- The actual content of the document that is centralized in the server.users: string[]
- Number of users that the current room has.
<User />
- Acts as individual browsers/users who can view and edit the document in the rich text editor.If user changes the document, it sends the changes to the
operations
state value. (View code for more info)If some other user changes the document, it receives the changes and does the OT and composes those changes to get the final data.
I have the runnable code in this Github repository operational-transformation-demo-fe.
Github Repo
Live Demo link
The app's operations transfer is debounced to 2 seconds mocking the turnaround time for the change message to arrive at the client.
As I mentioned above, The primary code for this app is only inside these two files
App.jsx - Acts as a Server and only passes operations and initial state to the
<User />
components (when a new user joins in a room which already has some content)User.jsx - Does two things. If there are any changes from the user
// App.jsx
import { useEffect, useRef, useState } from "react";
import Delta from "quill-delta";
import { AddUser } from "./components/AddUser";
import { User } from "./components/User";
import { Footer } from "./components/Footer";
import "./App.css";
function App() {
// Server state
const [users, setUsers] = useState(["Peter", "Stewie"]);
const [operations, setOperations] = useState([]);
// Frequently updated and not depended for any state to update
// Used to send initial state when a new user is created.
const contentRef = useRef(new Delta());
// Effects
// Updating Server state
useEffect(() => {
if (operations.length) {
const newcontent = operations.reduce((acc, curr) => {
return acc.compose(curr.delta);
}, contentRef.current);
contentRef.current = newcontent;
console.log("server updated...");
setOperations([]);
}
}, [operations]);
// API mock
const getInitialState = () => {
return contentRef.current;
};
// Handlers
const onCreateUser = (username) => {
if (users.includes(username)) {
const message = `There's already an user with name "${username}". Try a different name`;
alert(message);
return;
}
setUsers((pre) => [username, ...pre]);
};
return (
<>
<div className="container">
<h1 className="center">Operational Transform OT (Demo)</h1>
<AddUser onCreateUser={onCreateUser} />
<div className="users">
{users.map((user) => (
<User
key={user}
name={user}
operations={operations}
setOperations={setOperations}
getInitialState={getInitialState}
/>
))}
</div>
<Footer />
</div>
</>
);
}
export default App;
// User.jsx
import Avatar from "boring-avatars";
import { useCallback, useEffect, useState } from "react";
import ReactQuill from "react-quill";
import Delta from "quill-delta";
import { debounce } from "../utils/debounce";
export function User(props) {
const { name, operations, setOperations, getInitialState } = props;
// State
const [content, setContent] = useState(new Delta(getInitialState()));
const [newOps, setNewOps] = useState(new Delta());
// Effects
// The effect that listens for any operations and does OT to keep the current doc in sync
useEffect(() => {
if (!operations.length) return;
const indices = [];
operations.forEach((operation, index) => {
// To make it as a broadcast event.
if (operation.name === name) {
// As return in forEach just breaks the current execution
return;
}
indices.push(index);
// As compose is done on the `value` prop of `<ReactQuill />` - see the codebelow
const transformedDelta = new Delta().transform(operation.delta, true);
const composedDelta = content.compose(transformedDelta);
setNewOps(new Delta());
setContent(composedDelta);
});
if (!indices.length) return;
const newOperations = operations.filter(
(_, index) => !indices.includes(index)
);
setOperations(newOperations);
}, [operations]);
// Handlers
const handleDebounceChange = (delta, currentDelta, oldDelta) => {
const diff = oldDelta.diff(currentDelta);
setContent((pre) => pre.compose(diff));
setNewOps(new Delta());
setOperations((pre) => [...pre, { delta: diff, name }]);
console.log("Operations pushed---");
};
const debouncedHandleChange = useCallback(
debounce(handleDebounceChange, 2000),
[]
);
const onChange = (_, delta, source, editor) => {
// If the change is not caused due to user input ignore...
if (source === "api") return;
const deltaContents = editor.getContents();
const diff = content.diff(deltaContents);
setNewOps(diff);
debouncedHandleChange(delta, deltaContents, content);
};
return (
<div className="user">
<div className="user-info">
<Avatar
size={40}
name={name}
variant="beam"
colors={["#92A1C6", "#146A7C", "#F0AB3D", "#C271B4", "#C20D90"]}
/>
<h3>{`${name}'s machine`}</h3>
</div>
<div className="editor">
<ReactQuill value={content.compose(newOps)} onChange={onChange} />
</div>
</div>
);
}
So with this starter, we can build further to get a good collaborative editing experience.
The above blog is to show how OT works. And it is not a real-world example. It needs a dedicated system design and approach based on your requirements. Providing follow-up links to dive deeper into this.
I used this to implement a collaborative live experience in a project of mine. I'm still learning more about this. If you have better solutions or approaches, please let me know 😃.
Thanks for reading!
Love to connect with you all!
Blog • Email • LinkedIn • GitHub • X
References
Links in this blog
quill-delta
packagereact-quill
package
More about Operational Transformation