How I Built a Google Docs Imitation

Raj Kane
Raj Kane
Aug 6, 2018 · 7 min read

This summer I’ve dived deep into web development and picked up actionable skills in functional programming, asynchronous calls, server requests, and more. Eight weeks into my immersive program at Horizons School of Technology, I harnessed all the resources I learned this summer to finally build my very own full-stack application. Here, I present HDocs: a collaborative rich-text editor desktop application. View the project on GitHub.

I called on a wide breadth of resources to produce this project. In the front end, I used React for the component structure and Material-UI for the aesthetic. In the back end, I used Express and MongoDB for the server, Passport for login authentication, and Socket.IO for enabling collaboration.

My goals for this project were the following:

  • Become comfortable with React.
  • Create a sleek, user-friendly application.
  • Effectively organize and modularize code to enhance readability.

I unfortunately had to use Windows for the first time in months — the first time since partitioning my hard drive to use Ubuntu. This, all because getting Electron to work with Linux is a chore and a half. Please recommend Electron alternatives and save me from having to use Windows again!

Setup

First I initialized an Electron app using npm. This automatically gave me the main files: src/index.js loads src/index.html, which then renders the top-level React component. In React, rendering is handled intelligently in that state changes re-render individual components rather than the entire page. I made a top level React component, App.js, to manage the rendering of components. In particular, the management of component rendering is handled by the redirect method, which is bound in the constructor and is passed as a prop into any components that need to handle redirecting.

redirect(page, options) {                               
this.setState({
currentPage: page,
options: options
})
};

Anticipating that I’d want the App to display certain elements only when logged in and other only when logged out, I defined a method to handle the storing of this information in the state.

To run my app, I defined two scripts in my package.json file: backend-dev for the backend, and start for the frontend. As I was running this on Windows, it was tough to combine these two scripts. If you’re on a Mac (or if you get Electron to work with Linux), however, go crazy with your && to run two scripts at once.

"scripts: { 
“start”: “electron-forge start”,
"package”: “electron-forge package”,
“make”: “electron-forge make”,
“publish”: “electron-forge publish”,
“lint”: “eslint src — color”,
“backend-dev”: “nodemon server/index.js — exec babel-node —
presets es2015,stage-2”,
“backend-build”: “babel server -d server-dist — presets es2015,stage-2”,
“backend-serve”: “node server-dist/index.js”
}

Frontend

Rich-text Editor

The first task to accomplish was setting up a rich text editor. Thankfully, there’s already a comprehensive framework that makes this easy: Draft.js. This framework already comes with an Editor component with rich text capabilities. All I needed to do was incorporate the functionality expected of a text editor. I created a Document.js component which houses the Editor. I created the following methods which are called when the corresponding buttons are clicked:

toggleInlineStyle(e, inlineStyle) {
e.preventDefault()
this.onChange(RichUtils.toggleInlineStyle(
this.state.editorState, inlineStyle ))
};
toggleBlockType(e, blockType) {
e.preventDefault()
this.onChange(RichUtils.toggleBlockType(
this.state.editorState, blockType ))
};

Easily enough, each function takes in a parameter such as ‘BOLD’ or ‘unordered-list-item’ to set the selected text to the provided formatting. To make the Editor look presentable, I used the following Material-UI components: Toolbar, IconButton, RaisedButton. To change text color, I implemented the Colorpicker package. I also added some stale buttons for sharing and saving, which will get functionality later.

Login, registration

The next task was to make Login and Register components. These were very simple and nearly identical. Each features two input fields for username and password, as well as a button for submission.

Next, I made a DocumentsList React component to display the list of all available documents. Later, I wired this page with the backend to display only those documents which belong to the current user. The Material-UI components I used here are List and Avatar.

Backend

Now that I’ve got the skeleton and added flesh and skin with Material-UI, it’s time to bring the animal to life! The backend is where all the excitement is. Here is where I wire the components together and incorporate data.

User registration, login + authentication

To handle data, I used MongoDB. MongoDB allows me to define a model for each data type. The only data types that mattered are users and documents, since those are the only high-level concepts that need to be stored and accessed. There are two major backend files: server/index.js and server/routes.js. The former is the top-level backend file, where I define a local strategy for user authentication.

passport.use( new LocalStrategy((username, password, done) => {                           
models.User.findOne({ username: username }, (err, user) => {
if(err) return done(err)
if(!user) return done(null, false, {message: 'Incorrect
username'})
if (user.password !== password) return done(null, false, {
message: 'Incorrect password'})
return done( null, user )
})
}));

This file also has access to the register, login, and logout routes, defined in the routes file. The most interesting of these requests is the register POST route; the login POST route and logout GET route are very simple.

router.post('/register', function(req, res) {                                
var u = new models.User({
username: req.body.username,
password: req.body.password,
docList: []
})
u.save(function(err, user) {
if(err) {
res.status(500).json({err: err.message})
return
}
res.status(200).json({success: true})
})
});

With these routes wired up, the app can now register new users, authenticate login requests, and logout users who are logged in.

Now, the app need only handle document storage and data sync. To do so, I used Socket.IO.

Document storage

Documents are created (and saved) with a simple POST route. Upon login, the app redirects to the DocumentsList component. To load all the documents, I defined a componentDidMount method, which is invoked immediately upon the component rendering. This method fetches the data by using the find function built into MongoDB,

app.get('/documentList', function(req, res) {                          
models.Document.find({collaborators: {$in: [req.user]}}, (err,
docs) => {
if(err) res.status.end(err.message)
else res.json(docs)
})
});

stores the fetched data in a list in the state, and maps each datum to a HTML element.

<List>                                            
{this.state.list.map(item =>
<ListItem
leftAvatar={<Avatar icon={<ActionAssignment />}
backgroundColor={blue500} />}
primaryText = {item.title}
secondaryText = {'Created at ' + item.timeOfCreation}
onMouseDown={e => this.openDocument(item)}
/>)
}
</List>

The list also allows an individual document to be accessed using the findById function build into MongoDB.

MongoDB also provides each new Document with its own unique ID. I used this ID as the “password” for sharing. User A can find the shareable ID for a particular document upon clicking the Share button above the Editor. If user A wants to share a document with user B, he can send B the unique shareable ID for the particular document. User B can input that ID in a field in her Documents List page, and then she also can gain access to the document. Here is where sockets and rooms come into play.

In server/index.js, I registered three events:

  • openDocument: The user is added to the room specified by the ID.
  • joinDocument: Document changes are broadcast only to those users who have joined that room.
  • closeDocument: The user is removed from the room.

I then wired these events with the frontend. First, I defined a helper method that is used for syncing document content.

remoteStateChange(content) {                               
if(!content) return
this.setState({editorState:
EditorState.createWithContent(convertFromRaw(content))}) };

Then, I wired the opening and syncing of documents in the Document component’s componentDidMount method, using remoteStateChange.

Now, users sharing a document have access to the same real time content. They can edit documents and view each other’s edits real time, just like in Google Docs! With this, I completed the essence of Google Docs.

There are still a few relatively simple things to take care of. Importantly, I ensure that user A’s changes are synchronized with user B even when B is not viewing a shared document.

Neat tricks

Finally, I incorporated some neat CSS and Material-UI tricks to make the app flow more smoothly.

Material-UI’s Appbar component comes with a stale menu button in the left corner. I made this button come alive. On clicking the button, I activate a Drawer component from Material-UI. The menu items of this Drawer change depending on whether a user is logged in. Remember how I said that I stored this information in the App component? I put this storage to use here. If a user is logged in, the Drawer displays options to create a document, view the user’s document list, or log out. If no user is logged, the Drawer displays options to login or register. I accomplished this using a simple ternary. I also added a Material-UI ClickAwayListener component. This allows a user to click away from the Drawer to close it. This is a really easy to use feature.

Lessons

  • Material-UI is truly easy to use and makes designing sleek user interfaces a whole lot easier.
  • The most difficult part of this project was keeping account of how the frontend and backend transmit information between each other. Once I figured that out, it was easy to wire the two ends.
  • It’s important to always keep a high-level understanding of your app at each stage.
  • I love React!

Many thanks to Demi for his help.


Originally published at rajrkane.com on August 6, 2018.

Raj Kane

Written by

Raj Kane

Wannabe cypherpunk

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade