Welcome folks today in this blog post we will be building a google docs
clone in react.js and node.js using quill wysiwyg
editor & socket.io. All the full source code of the application is shown below.
Live Demo
You can see the live demo of the google docs
clone as shown below
Get Started
In order to get started you need to make a new directory
called googledocs
using the below command
mkdir googledocs
And now you need to make a client
folder and inside which we will be creating the react.js
project as shown below
mkdir client
cd client
And now you need to create the react.js
project using the below command
npx create-react-app docsapp
cd docsapp
And now you need to install the below libraries using the npm
command inside react.js project as shown below
npm i react-quill
npm i react-router-dom
npm i uuid
npm i socket.io-client
And now you will see the below directory
structure of the react.js app as shown below
And now you need to edit the App.js
file and copy paste the following code
App.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import TextEditor from "./TextEditor" import { BrowserRouter as Router, Switch, Route, Redirect, } from "react-router-dom" import { v4 as uuidV4 } from "uuid" function App() { return ( <Router> <Switch> <Route path="/" exact> <Redirect to={`/documents/${uuidV4()}`} /> </Route> <Route path="/documents/:id"> <TextEditor /> </Route> </Switch> </Router> ) } export default App |
And now as you can see in the above code we are importing the react-router
and also we are importing the switch
and redirect and route
methods. And then we are initializing the react router
and inside that we are using the switch
statement to add two
routes and inside it we have the parent
route in which we are redirecting the user to a random
document using the uuidV4()
method. And in the second route we are showing the quill editor
when the random document is loaded.
Displaying the Quill Wysiwyg Editor
Now we need to create the TextEditor.js
component file and copy paste the following code
TextEditor.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
import { useCallback, useEffect, useState } from "react"; import Quill from "quill"; import "quill/dist/quill.snow.css"; import { io } from "socket.io-client"; const TOOLBAR_OPTIONS = [ [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ font: [] }], [{ list: "ordered" }, { list: "bullet" }], ["bold", "italic", "underline"], [{ color: [] }, { background: [] }], [{ script: "sub" }, { script: "super" }], [{ align: [] }], ["image", "blockquote", "code-block"], ["clean"], ]; export default function TextEditor() { const [socket, setSocket] = useState(); const [quill, setQuill] = useState(); useEffect(() => { const s = io("http://localhost:3001"); setSocket(s); return () => { s.disconnect(); }; }, []); const wrapperRef = useCallback((wrapper) => { if (wrapper == null) return; wrapper.innerHTML = ""; const editor = document.createElement("div"); wrapper.append(editor); const q = new Quill(editor, { theme: "snow", modules: { toolbar: TOOLBAR_OPTIONS }, }); q.disable(); q.setText("Loading..."); setQuill(q); }, []); return <div className="container" ref={wrapperRef}></div>; } |
As you can see we are connecting to the socket.io
server using the port 3001
. And then we are declaring the useState
variables for the socket
and quill
editor. And also we are declaring the options
for the quill editor which includes advanced utilities for bolding the text and also supporting for adding the image. And then we are using the useCallback
hook to embed the quill
editor. And we are using the snow
theme for the quill
editor. And then we are also attaching the toolbar
using the modules property.
Styling the Editor
Now guys just copy paste the below css
styles inside the styles.css
file to style the quill
editor
styles.css
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
*, *::before, *::after { box-sizing: border-box; } body { background-color: #F3F3F3; margin: 0; } .container .ql-editor { width: 8.5in; min-height: 11in; padding: 1in; margin: 1rem; box-shadow: 0 0 5px 0 rgba(0, 0, 0, .5); background-color: white; } .container .ql-container.ql-snow { border: none; display: flex; justify-content: center; } .container .ql-toolbar.ql-snow { display: flex; justify-content: center; position: sticky; top: 0; z-index: 1; background-color: #F3F3F3; border: none; box-shadow: 0 0 5px 0 rgba(0, 0, 0, .5); } @page { margin: 1in; } @media print { body { background: none; } .container .ql-editor { width: 6.5in; height: 9in; padding: 0; margin: 0; box-shadow: none; align-self: flex-start; } .container .ql-toolbar.ql-snow { display: none; } } |
Loading the Content of New Document in Quill Editor
Now we will be using the useParams
from the react-router-dom
library and we will be extracting the documentId
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import { useParams } from "react-router-dom"; const SAVE_INTERVAL_MS = 2000; export default function TextEditor() { const { id: documentId } = useParams(); const [socket, setSocket] = useState(); const [quill, setQuill] = useState(); useEffect(() => { if (socket == null || quill == null) return; socket.once("load-document", (document) => { quill.setContents(document); quill.enable(); }); socket.emit("get-document", documentId); }, [socket, quill, documentId]); } |
As you can see we are extracting the documentid
from the url and then we are sending or emitting the id
to the socket.io at the server side to store the document and there we will be joining
the room. And then we are receiving the event
which is called load-document
inside which we are setting the content of the quill
editor.
Sending & Receiving Realtime Changes in Editor
Now guys we will be writing the code
of socket.io where we will be sending realtime changes
of the editor
to the socket.io at the server side so that the changes made by the concurrent
users are reflected back in the browser.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
useEffect(() => { if (socket == null || quill == null) return; const handler = (delta) => { quill.updateContents(delta); }; socket.on("receive-changes", handler); return () => { socket.off("receive-changes", handler); }; }, [socket, quill]); useEffect(() => { if (socket == null || quill == null) return; const handler = (delta, oldDelta, source) => { if (source !== "user") return; socket.emit("send-changes", delta); }; quill.on("text-change", handler); return () => { quill.off("text-change", handler); }; }, [socket, quill]); |
As you can see we have two useState
hooks and inside that we first of all fetching
the content from the quill editor and then sending
those changes to the socket.io
server side using the text-change
event inside the quill-editor
and then we are emitting the event from client to server using the send-changes
and similarly we are receiving the changes using the receive-changes
event and then we are updating the contents
of the quill editor using the updateContents()
method.
Saving the Contents of Editor in MongoDB
Now guys we will be automatically saving the contents
of the quill editor after 2 seconds. For this we will be sending the event
to the socket.io
at the server side where the content will be saved
to mongodb.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
const SAVE_INTERVAL_MS = 2000; useEffect(() => { if (socket == null || quill == null) return; const interval = setInterval(() => { socket.emit("save-document", quill.getContents()); }, SAVE_INTERVAL_MS); return () => { clearInterval(interval); }; }, [socket, quill]); |
As you can see we have declared the constant
variable where we are storing the minimum time after which we are storing the contents
inside the mongodb. And for this we are sending the save-document
event to the socket.io at the server side. And inside this we are first of all getting the contents
of the quill editor using the getContents()
method.
Full Source Code of App.js
App.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
import { useCallback, useEffect, useState } from "react"; import Quill from "quill"; import "quill/dist/quill.snow.css"; import { io } from "socket.io-client"; import { useParams } from "react-router-dom"; const SAVE_INTERVAL_MS = 2000; const TOOLBAR_OPTIONS = [ [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ font: [] }], [{ list: "ordered" }, { list: "bullet" }], ["bold", "italic", "underline"], [{ color: [] }, { background: [] }], [{ script: "sub" }, { script: "super" }], [{ align: [] }], ["image", "blockquote", "code-block"], ["clean"], ]; export default function TextEditor() { const { id: documentId } = useParams(); const [socket, setSocket] = useState(); const [quill, setQuill] = useState(); useEffect(() => { const s = io("http://localhost:3001"); setSocket(s); return () => { s.disconnect(); }; }, []); useEffect(() => { if (socket == null || quill == null) return; socket.once("load-document", (document) => { quill.setContents(document); quill.enable(); }); socket.emit("get-document", documentId); }, [socket, quill, documentId]); useEffect(() => { if (socket == null || quill == null) return; const interval = setInterval(() => { socket.emit("save-document", quill.getContents()); }, SAVE_INTERVAL_MS); return () => { clearInterval(interval); }; }, [socket, quill]); useEffect(() => { if (socket == null || quill == null) return; const handler = (delta) => { quill.updateContents(delta); }; socket.on("receive-changes", handler); return () => { socket.off("receive-changes", handler); }; }, [socket, quill]); useEffect(() => { if (socket == null || quill == null) return; const handler = (delta, oldDelta, source) => { if (source !== "user") return; socket.emit("send-changes", delta); }; quill.on("text-change", handler); return () => { quill.off("text-change", handler); }; }, [socket, quill]); const wrapperRef = useCallback((wrapper) => { if (wrapper == null) return; wrapper.innerHTML = ""; const editor = document.createElement("div"); wrapper.append(editor); const q = new Quill(editor, { theme: "snow", modules: { toolbar: TOOLBAR_OPTIONS }, }); q.disable(); q.setText("Loading..."); setQuill(q); }, []); return <div className="container" ref={wrapperRef}></div>; } |
Making the Socket.io Backend in Node.js
Now guys we will be making the backend
for this app. Just create the server
folder and execute the below commands as shown below
mkdir server
cd server
And now we need to install the below libraries as shown below
npm i socket.io
npm i mongoose
And now just see the directory structure
of the backend is shown below
Making the Database in MongoDB
Now guys first of all you need to make a new database
using the mongodb community edition as shown below
Connecting to MongoDB Database
Now guys just create the server.js
file which will be main file of the backend
and copy paste the following code
server.js
1 2 3 4 5 6 7 8 9 |
const mongoose = require("mongoose") const Document = require("./Document") mongoose.connect("mongodb://localhost:27017/google-docs-clone", { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false, useCreateIndex: true, }) |
As you can see we are connecting to the mongodb
database at the port number 27017
where mongodb service typically runs in the computer. And then we are providing the database
name that we have created in the earlier step. And also we are including the schema
file for the document
model which can be defined as shown below
Document.js
1 2 3 4 5 6 7 8 |
const { Schema, model } = require("mongoose") const Document = new Schema({ _id: String, data: Object, }) module.exports = model("Document", Document) |
Making the Socket.io Server
Now guys we will be making the socket.io
server where we will be receiving and sending the events
from the client side.
1 2 3 4 5 6 |
const io = require("socket.io")(3001, { cors: { origin: "http://localhost:3000", methods: ["GET", "POST"], }, }) |
As you can see we are connecting to the 3000
port where our client side
react app lives. And for this we are using the cors
module to connect to that origin and we are allowing the get
and post methods on that origin. And we are starting this socket.io
server at the port 3001
.
Sending and Receiving Events in Socket.io
Now guys we will be seeing how basically we can receive the events which are sent by the client
and also send events
to the client side from the backend in socket.io
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
const defaultValue = "" io.on("connection", socket => { socket.on("get-document", async documentId => { const document = await findOrCreateDocument(documentId) socket.join(documentId) socket.emit("load-document", document.data) socket.on("send-changes", delta => { socket.broadcast.to(documentId).emit("receive-changes", delta) }) socket.on("save-document", async data => { await Document.findByIdAndUpdate(documentId, { data }) }) }) }) |
As you can see guys we are connecting to the random room
using the documentId
that we receive from the client side. Basically we join the rooms
inside socket.io using the join()
method And then we are sending the load-document
event to the client and also we are sending the default value for the document. And also all this code is wrapped inside the get-document
event that we receive from the client side and here we are listening that event. And for the changes we are listening for send-changes
event here we are broadcasting
that event to all the clients connected in the same room
except the client.
And lastly for saving the document
inside the mongodb database we are using the findByIdAndUpdate()
of mongodb database to store the content of the editor. Now we need to define the method where we select
the document using it’s id.
1 2 3 4 5 6 7 |
async function findOrCreateDocument(id) { if (id == null) return const document = await Document.findById(id) if (document) return document return await Document.create({ _id: id, data: defaultValue }) } |
As you can see we are receiving the documentId
inside this function. If the documentId is not found then we are creating the brand new
document using the create()
method and insert the info inside mongodb
and if the documentId is found then we are simply returning the document
back to the client.
Starting the MERN Stack App
Now guys we can start both the client
and the backend
app as shown below
starting react.js app
npm start
starting backend
nodemon server.js