-
Notifications
You must be signed in to change notification settings - Fork 0
Collaborative editing with Yjs and Monaco editor in Next.js application
Collaborative editing is a process in which multiple users work together to create or edit a document. In my case, to edit a JSON file with snapshot metadata.
How does it work at The NFT Snapshot?
When a user enters an URL of a non-existing snapshot in the browser's address bar, such as https://thenftsnapshot.com/snapshot-name
, he will be automatically redirected to the snapshot editing page with a JSON metadata file opened in the online code editor. Each subsequent user who opens the same page will be able to join in real-time collaborative editing of this metadata file.
For online code editing, I'm using Monaco editor, a browser-based code editor that was developed by Microsoft and used in many of its products, including Visual Studio Code.
For a collaboration - Yjs, an open-source real-time collaboration framework, which allows multiple users to simultaneously edit the same document, with changes synchronized across all users' devices.
How Yjs works?
Yjs uses a technique called CRDTs (Conflict-free Replicated Data Types) to ensure that changes made by multiple users are correctly merged and synchronized, even if those changes are made simultaneously. This means that users can edit the same document at the same time, without worrying about conflicts or data loss.
As for communication providers, there are several options available, the first one is a centralized server approach using websockets - y-websocket. Another one is a peer-to-peer connection using webrtc - y-webrtc, which uses a websocket signaling server to exchange signaling data to find peers (pseudo decentralized).
Here are some challenges I faced while trying to make it work together.
The first task was to get Monaco editor working in the Next.js app.
If we try to install Monaco editor package and import it into our app, we will receive multiple errors, such as Global CSS cannot be imported from within node_modules or module build failed: Error: Final loader didn't return a Buffer or String and others
.
It happens because of the way Next.js handles CSS imports from node_modules, as well as potential conflicts with Monaco Editor's own module system. Additionally, Monaco Editor requires a browser environment to function properly, which can cause additional issues when using it with an SSR framework like Next.js.
I found at least two ways how to handle this, for both of them I will be using @monaco-editor/react package, which is a Monaco editor wrapper for easy, one-line integration with any React application.
Which is the default behavior of the @monaco-editor/react package. In order to load a specific version we can pass a config object to the @monaco-editor/loader:
import { loader } from "@monaco-editor/react";
loader.config({
paths: {
vs: `https://cdn.jsdelivr.net/npm/[email protected]/min/vs`,
},
});
Not a super elegant solution since we still need to have Monaco editor as an npm dependency, but this is a quick and easy solution, and it works without any issues.
To do this, we'll' need to follow several steps.
First, we need to install next-transpile-modules package (not needed if you are using Next.js >= 13.1) and monaco-editor-webpack-plugin
Next, we need to add the following lines into the next.config.js file:
const withTM = require("next-transpile-modules")(["monaco-editor"]);
module.exports = withTM({
webpack(config) {
config.plugins.push(
new MonacoWebpackPlugin({
languages: ["json"],
filename: "static/[name].worker.js",
})
);
return config;
},
});
It will transpile modules from node_modules
using Next.js Babel configuration and fix all import issues like importing CSS files from node_modules
and others. MonacoWebpackPlugin
will bundle specified language worker files, needed to provide syntax highlighting.
Then we can use it like this:
import React, { useEffect, useState } from "react";
import MonacoEditor, { loader } from "@monaco-editor/react";
export default function Editor() {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
async function loadMonacoEditor() {
let monaco = await import("monaco-editor");
self.MonacoEnvironment.getWorkerUrl = (_, label) => {
// an example of returning a worker url for json language
if (label === "json") {
return "_next/static/json.worker.js";
}
return "_next/static/editor.worker.js";
};
loader.config({ monaco });
await loader.init();
}
loadMonacoEditor().then(() => setIsLoaded(true));
}, []);
if (isLoaded) {
return (
<MonacoEditor
options={{
hover: {
enabled: false,
},
}}
defaultLanguage="json"
/>
);
}
return <div>Loading Editor...</div>;
}
Important note: I found the Monaco editor version 0.37.1 content hover widget doesn't work as expected (needed to show tooltips and suggestions), because of errors thrown when we move the mouse over the editor content. That is why I disabled it via options (this needs further investigation).
Further reading:
- https://github.com/microsoft/monaco-editor/blob/main/docs/integrate-esm.md
- https://www.swyx.io/how-to-add-monaco-editor-to-a-next-js-app-ha3
If we initialize Yjs doc with plain text data using p2p connection, each newly connected user will add this text data on top of the existing one (data will be duplicated).
The solution is to initialize Yjs document with a "template" which stores the state of the document. So, it can be merged and if it contains duplicate information, it will be removed.
//base64 encoded document template
const template = "AQGWoNPUDwAEAQCfBnsKICAibmFtZSI...";
const myDoc = new Y.Doc();
Y.applyUpdate(myDoc, fromBase64(template));
Here Yjs community forum discussion thread about this.
When multiple users edit a document using different operating systems, the cursor position may become out of sync. This happens because of different operating systems use different line endings formats. For instance, Windows uses \r\n
, while Unix/MacOS uses \n
. When a user types something in the code editor, the cursor position is calculated with consideration of the line ending symbol(s)
[line: x, column: y]
.
In order to fix this we need to specify the line endings for the editor model.
// Set line ending to \r\n
monacoModel.setEOL(monaco.editor.EndOfLineSequence.CRLF);
And update editor model value using applyEdits
method instead of setValue
, because setValue
resets editor EOL(end of line) state.
monacoModel.applyEdits([
{ text: newValue, range: monacoModel.getFullModelRange() },
]);
If we try to use y-websocket package in our Next.js project, we can get an error like this:
Yjs was already imported. This breaks constructor checks and will lead to issues!
- https://github.com/yjs/yjs/issues/438
This happens due to webpack imports both CommonJS and ESM modules from y-websocket package. There are two possible solutions for this issue:
First one is to fork the y-websocket package and change the way how it exports modules, by adding "type": "module"
to the y-websocket package.json file. The second one is by using next-transpile-modules
and pass y-websocket package to it. In order to do it we need to update next.config.js file:
const withTM = require("next-transpile-modules")(["y-websocket"]);
Despite these challenges, collaborative editing was successfully implemented in the NFT Snapshot. Try here
Useful links:
- Scalable websocket backend for Yjs
- y-socket.io provider for Yjs
- Tiptap - headless, framework-agnostic and extendable rich text editor, based on ProseMirror with collaborative editing option
- Collaborative editing with Yjs
- React store on top of Yjs
- Syncing text files between browser and disk using Yjs and the File System Access API
- Remirror Yjs Annotations Demo (via WebRTC)
- Serverless Yjs
- DRAWEE - Collaborative drawing app