Back to search

Build a collaborative note-taking application with Node.js

  • Neo Ighodaro
March 21st, 2018
You will need Node.js and npm installed on your machine. A basic knowledge of JavaScript (Node.js and jQuery) will be useful.

If you are like me, you use Dropbox Paper to take notes and write some drafts. With the collaborative feature, multiple people can collectively work on one document without issues. This makes Paper a really powerful application.

In this article, we will be building a collaborative note-taking application similar to Dropbox Paper. We will be considering how we can build this amazing feature using TextSync.

Requirements

Before we continue with the tutorial, please make sure you have all the following requirements:

Assuming you have satisfied all the requirements, let’s dig in.

What we will be building

We will be building a note-taking application similar to Dropbox Paper. We will be using TextSync, which will handle most of the actual syncing. We will be using Node.js (Express) to make our web application.

When we are done, our application should behave similar to this:

Collaborative-Note-taking-Application-Javascript

Setting up our Node.js application

Open the terminal application on your machine. Run the command below to create a new directory:

    $ mkdir application-name

This will be the directory where all our code will reside. In that directory, create a new file called package.json. In this file, we will define all the node modules required for our application to run. In the file, paste the following code:

    {
      "name": "notez",
      "description": "Collaborative note taking application.",
      "main": "index.js",
      "dependencies": {
        "express": "^4.16.2",
        "body-parser": "^1.16.0"
      }
    } 

As seen above, we have defined our dependencies. To install these dependencies and make them available to our project, run the command below in your terminal application:

    $ npm install

After the packages are successfully installed, create a new file named index.js in the root directory of your project. In this file, we will be adding all the backend logic for our application. Open the index.js file and paste the following code below:

    // ------------------------------------------------------
    // Import Node Modules...
    // ------------------------------------------------------

    const express    = require('express')
    const bodyParser = require('body-parser')

    // ------------------------------------------------------
    // Create the Express app
    // ------------------------------------------------------

    const app = express()
    let data = {}

    // ------------------------------------------------------
    // Load the middlewares
    // ------------------------------------------------------

    app.use(bodyParser.json());
    app.use(express.static(__dirname + '/assets'))
    app.use(bodyParser.urlencoded({ extended: false }));

    // ------------------------------------------------------
    // Helper function(s)
    // ------------------------------------------------------
    function slugify(text) {
        return text.toString().toLowerCase().trim()
                   .replace(/\s+/g, '-')
                   .replace(/[^\w\-]+/g, '')
                   .replace(/\-\-+/g, '-')
                   .replace(/^-+/, '')
                   .replace(/-+$/, '')
    }

    function randomString(count) {
        let text = "";
        const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        for (let i = 0; i < count; i++) {
            text += possible.charAt(Math.floor(Math.random() * possible.length));
        }
        return text;
    }

    // ------------------------------------------------------
    // API Routes
    // ------------------------------------------------------

    app.get('/api/notes/:slug', (req, res, next) => {
        if (data[req.params.slug] === undefined) {
            res.status(404).json({status: 'error'})
        } else {
            res.json({data:data[req.params.slug]})
        }
    })

    app.put('/api/notes/:slug', (req, res, next) => {
        if (data[req.params.slug] === undefined) {
            res.status(404).json({status: 'error'})
        } else {
            data[req.params.slug]["Title"] = req.body.title
            res.json({status: "ok"})
        }
    })

    app.get('/api/notes', (req, res, next) => res.json({data}))

    app.post('/api/notes', (req, res, next) => {
        const title = randomString(24)
        res.json({data: data[title] = {Slug: title, Title: "Untitled Note"}})
    })

    // ------------------------------------------------------
    // Define Routes: Static
    // ------------------------------------------------------

    app.get('/notes/:slug', (req, res) => res.sendFile(__dirname + '/views/editor.html'))
    app.get('/', (req, res) => res.sendFile(__dirname + '/views/index.html'))

    // ------------------------------------------------------
    // Start application
    // ------------------------------------------------------

      app.listen(3000, () => console.log('App listening on port 3000!'))

We have a few things going on in the code above.

First, at the top of the page we import the dependencies we specified on the package.json file. Next, we instantiated Express and added a few Express middlewares. We define a slugify helper function. This function takes a string and turns it into a slug.

The next thing we do is define all the routes that are necessary for the application to function. We have divided the routes into two: the API routes and the static routes. The API routes store and retrieve data from the data array. This mimics fetching data from a database and saving to it.

We also define the static routes which loads two different HTML files. These HTML files are the views of our application and they will make requests to the API routes to fetch and update data.

Finally, the last thing we do is serve the Express application. Now open the terminal and run the command below to start our Node server:

    $ node index.js

Your application will be served on http://localhost:3000. Now that we have set up the server side of the application, let’s move on to the client side.

Creating the frontend part of your note-taking application

Create a new directory in the root of your project called views. In this directory, we will be adding the HTML files for our application. Create a new file inside the folder called index.html and paste the contents below inside it:

    <html>
    <head>
        <title>Notez App | Easily collaborate on notes</title>
        <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css">
        <link rel="stylesheet" href="/app.css">
    </head>
    <body>
        <nav class="navbar navbar-expand-lg sticky-top navbar-light bg-light">
            <div class="container">
                <span class="navbar-brand wide">
                    <span class="title">Notez App</span>
                    <span class="commentary">Select a note or create one below</span>
                </span>
            </div>
        </nav>
        <div class="container" style="margin-top: 50px">
            <a href="#" class="btn btn-primary btn-sm" id="create-btn">Create New Note</a>
            <hr>
            <div class="card">
                <div class="list-group list-group-flush">
                </div>
            </div>
        </div>
        <script src="//cdn.jsdelivr.net/combine/npm/jquery@3.2.1,npm/axios@0.17.0/dist/axios.min.js"></script>
        <script src="/app.js"></script>
    </body>
    </html>

In the HTML file above, we are using Bootstrap to make development easy and fast.

We have a “Create New Note” button. When this button is clicked, we will send a POST request to the Node backend /api/notes which will create a new note for us. When the response returns, we will redirect the browser to the new note.

To load the available notes, we’ll use Axios to make a GET request to the /api/notes endpoint. This endpoint will return all the available notes. When this list is retrieved, the UI will be updated with the links to the notes.

Before we move on to the next HTML file, let us create the CSS and JS file that were referenced in the file above. Create a new file assets/app.css and paste this in the file:

    .navbar .navbar-brand {
        margin: 0;
    }
    .navbar .navbar-brand.wide {
        width: 100%;
        display: block;
        text-align: center;
    }
    .navbar .navbar-brand > .title {
        display: block;
        font-size: 16px;
        color: #1b2733;
        font-weight: 600;
        line-height: 22px;
        margin-bottom: 5px;
    }
    .navbar .navbar-brand > .commentary {
        display: block;
        font-size: 12px;
        color: #707e8d;
    }
    /* Override the style of the editor */
    .text-editor .ql-toolbar {
        width: auto;
        float: left;
        border-radius: 5px;
        position: relative;
        top: 7px;
        background: #f4f4f4;
        border: none;
        position: fixed;
        margin-top: 87px;
        z-index: 99999;
        opacity: .3;
        transition: opacity .3s ease;
    }
    .text-editor .ql-toolbar:hover {
        opacity: 1;
    }
    .text-editor .tsync-presence-container {
        float: right;
        position: sticky;
        z-index: 1;
        transition: opacity .3s ease;
    }
    .text-editor .tsync-presence-container:hover {
        opacity: 1;
    }
    .text-editor #tsync-editor {
        clear: both;
        border: none;
        padding-top: 10px;
    }
    .text-editor #tsync-editor > .ql-editor {
        font-size: 18px;
        color: #444;
        line-height: 1.4;
        font-family: 'Source sans pro', 'Helvetica Neue', sans-serif;
    }
    .title .ql-toolbar {
        display: none
    }
    .title #tsync-editor {
        height: 30px;
        border: none;
    }
    .title #tsync-editor .ql-editor {
        padding: 0;
        font-size: 18px;
    }
    #titleEditor {
        max-height: 30px;
    }
    footer {
        position: fixed;
        width: 100%;
        bottom: 0;
        text-align: center;
        padding: 5px;
        font-size: 14px;
        background: #f5f5f5;
    }

The CSS file will give the UI a new look. The areas below “Override the style of the editor” above is where we try to manipulate the default UI of TextSync to behave how we want it to.

Now, for the JavaScript, create a new file assets/app.js and in the file paste the content below:

    (function() {
        const noteDocument = $(document)
        const createButton = $('#create-btn')
        const notesList    = $('.list-group.list-group-flush')

        const helpers = {
            /**
             * On click, create a new note and redirect to it...
             */
            createNoteAndRedirect: evt => {
                axios.post('/api/notes').then(response => (window.location = '/notes/'+response.data.data.Slug))
            },

            /**
             * Load the notes to the page...
             */
            loadNotes: evt => {
                axios.get('/api/notes').then(response => {
                    const notes = response.data.data
                    for (const key in notes) {
                        if (notes.hasOwnProperty(key)) {
                            notesList.append(
                                `<a href="/notes/${notes[key].Slug}" class="list-group-item list-group-item-action">
                                    ${notes[key].Title}
                                </a>`
                            )
                        }
                    }
                })
            }
        }

        noteDocument.ready(helpers.loadNotes)
        createButton.click(helpers.createNoteAndRedirect)
    }())

In the JS script above we define a few variables. Most notably, the helpers variable. In there, we define some helper methods that we will need.

The first is createNoteAndRedirect. This helper is triggered when the “Create new Note” button is clicked. It will send a POST request to /api/notes; this endpoint will create the note and return it as a response. When the response is received, the method will redirect the browser to the created note.

The next helper is loadNotes. This is triggered immediately the DOM is loaded and ready. This helper sends a GET request to /api/notes which returns all the available notes on the server. Links to the notes are then displayed so they can be navigated to.

The next file we will create in the views directory is editor.html. When a user views a note, this view will be loaded. In the file you just created, add the content below:

    <html>
    <head>
        <title></title>
        <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css">
        <link rel="stylesheet" href="/app.css">
    </head>
    <body>
        <nav class="navbar navbar-expand-lg sticky-top navbar-light bg-light">
            <div class="container">
                <span class="navbar-brand">
                    <span class="title" id="titleEditor"></span>
                    <span class="commentary">Loading...</span>
                </span>
                <div class="back-home">
                    <a href="/" title="Back to notes" class="btn btn-sm btn-secondary">Back to notes</a>
                </div>
            </div>
        </nav>
        <div class="container text-editor">
            <div id="editor"></div>
        </div>
        <footer class="footer">
            <div class="container">
                <span class="text-muted">Powered by <a href="https://docs.pusher.com/textsync">TextSync</a></span>
            </div>
        </footer>
        <script type="text/javascript" src="https://cdn.jsdelivr.net/combine/npm/jquery@3.2.1,npm/axios@0.17.0/dist/axios.min.js"></script>
        <script type="text/javascript" src="https://cdn.rawgit.com/pusher/deliverfoo/us1/textsync.js"></script>
        <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>
        <script src="/editor.js"></script>
    </body>
    </html>

This file is similar to the index.html we created earlier. One difference is the JavaScript files imported. In this page, we import: jQuery, Axios, TextSync, Lodash and a custom JS file.

Let us create our custom JS file. Create a file assets/editor.js and paste the content below in it:

    (function() {
        const noteDocument = $(document)
        const collaboratorsText = $('.navbar-brand .commentary')
        const PUSHER_INSTANCE_LOCATOR = "TEXTSYNC_INSTANCE_LOCATOR"

        let note = {
            title: null,
            collaborators: [],
            textSync: undefined,
            currentNote: undefined,
        }

        const helpers = {
            /**
             * Load the note editors.
             */
            loadNoteEditors: () => {
                const noteSlug = document.URL.substr(document.URL.lastIndexOf('/') + 1)
                axios.get(`/api/notes/${noteSlug}`)
                    .then(response => {
                        note.currentNote = response.data.data
                        $('title').text(note.Title)
                        note.textSync = new TextSync({ instanceLocator: PUSHER_INSTANCE_LOCATOR });
                        helpers.createTitleTextEditor()
                        helpers.createContentTextEditor()
                    })
                    .then(() => {
                        setTimeout(() => {
                            document.querySelector("#titleEditor .ql-editor")
                                    .addEventListener("input", _.debounce(helpers.updateNoteTitle, 1000));
                        }, 3000)
                    })
                    .catch(err => window.location = '/')
            },

            /**
             * Update the title of the note.
             */
            updateNoteTitle: () => {
                const title = $('#titleEditor .ql-editor').text()
                axios.put(`/api/notes/${note.currentNote.Slug}`, {title})
            },

            /**
            * Create the text editor for the main content.
            *
            * @see https://docs.pusher.com/textsync/reference/js#editor-config-properties
            */
            createTitleTextEditor: () => {
                note.textSync.createEditor({
                    element: '#titleEditor',
                    docId: `${note.currentNote.Slug}-title`,
                    richText: false,
                    collaboratorBadges: false,
                    defaultText: note.currentNote.Title,
                })
            },

            /**
             * Create the text editor for the main content.
             *
             * @see https://docs.pusher.com/textsync/reference/js#editor-config-properties
             */
            createContentTextEditor: () => {
                note.textSync.createEditor({
                    element: "#editor",
                    docId: `${note.currentNote.Slug}-content`,
                    onCollaboratorsJoined: users => {
                        for (const key in users) {
                            note.collaborators[users[key].siteId] = users[key]
                        }
                        const count = Object.keys(note.collaborators).length
                        collaboratorsText.text((count > 1 ? `${count} collaborators.` : 'You are alone 😢'))
                    }
                })
            },
        }

        noteDocument.ready(helpers.loadNoteEditors)
    }());

Like the last JS file, we have defined some variables including a helpers variable. Replace the TEXTSYNC_INSTANCE_LOCATOR key with the actual instance locator provided on the TextSync dashboard.

We have a note variable which will store the note title, collaborators on the note, an instance of TextSync, and the current note object.

The helpers object contains some helper functions that will be instrumental. Let's go through all of them and see what they do.

Method: loadNoteEditors This method sends a GET request to /api/notes/:slug, which returns the note details (only the Slug and Title are stored) from the server. Next, we create an instance of TextSync and create two editors using the same instance. We create the note title text editor and the note content text editor.

Also, we set a listener (line 29) to the title text editor so that when the content is updated, we can update the note title on the server.

Method: UpdateNoteTitle This method just does what it says. It sends a PUT request to /api/notes/:slug which updates the title of the note.

Method: createTitleTextEditor and createContentTextEditor These two methods just create text editors with different options and different doc IDs so they are distinct. You can check out the documentation for the available TextSync editor config properties.

TextSync automatically fires the onCollaboratorsJoined function every time a new collaborator joins the session. Using this knowledge, we update our local copy of the collaborators list. We then use this information to update the UI element that tells how many people are collaborating on the file.

💡 If you are joining a session that already has people in there, the onCollaboratorsJoined function will return a list of all the users in that session.

Testing out your application

If you followed the instructions, then your application should be working as intended when you visit http://localhost:3000.

You can use a tool like ngrok to tunnel your application to the world wide web. This way, you can send the link to someone else to see how it works.

If you are using ngrok, then the command to start tunelling your local content is below:

    $ ngrok http 3000 

When you open your web address, you can start creating new notes and sharing the application with your friends so they can contribute to your amazing note. Collaborative-Note-taking-Application-Javascript

Conclusion

In this article, we have been able to create a lovely collaborative note-taking application. Hopefully, you learned a few things from the implementation of TextSync. You can see how much work has been saved by just using TextSync. The source code is to the application built in this article is available on GitHub.

  • TextSync

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 28 Scrutton Street, London EC2A 4RP.