Build a collaborative painting app using Vue.js

Introduction

Today, we’ll be creating a realtime paint application. Using our application, users can easily collaborate while using the application and receive changes in realtime. We’ll be using Pusher’s pub/sub pattern to get realtime updates and Vue.js for creating the user interface.

To follow this tutorial a basic understanding of Vue and Node.js is required. Please ensure that you have at least Node version 6>= installed before you begin.

We’ll be using these tools to build our application:

Here’s a screenshot of the final product:

paint-app-vue-preview

Initializing the application and installing project dependencies

To get started, we will use the vue-cli to bootstrap our application. First, we’ll install the CLI by running npm install -g @vue/cli in a terminal.

To create a Vuejs project using the CLI, we’ll run the following command:

    vue create vue-paintapp

After running this command, you will be asked by the CLI to pick a preset. Please select the default preset.

NOTE: The @vue/cli 3.0 is still in beta and should not be used in production.

Next, run the following commands in the root folder of the project to install dependencies.

1// install depencies required to build the server
2    npm install express body-parser dotenv pusher
3    
4    // front-end dependencies
5    npm install pusher-js uuid

Start the app dev server by running npm run serve in a terminal in the root folder of your project.

A browser tab should open on http://localhost:8080. The screenshot below should be similar to what you see in your browser:

paint-app-vue-start-page

Building the server

We’ll build our server using Express. Express is a fast, unopinionated, minimalist web framework for Node.js.

Create a file called server.js in the root of the project and update it with the code snippet below

1// server.js
2    
3    require('dotenv').config();
4    const express = require('express');
5    const bodyParser = require('body-parser');
6    const Pusher = require('pusher');
7    
8    const app = express();
9    const port = process.env.PORT || 4000;
10    const pusher = new Pusher({
11      appId: process.env.PUSHER_APP_ID,
12      key: process.env.PUSHER_KEY,
13      secret: process.env.PUSHER_SECRET,
14      cluster: 'eu',
15    });
16    
17    app.use(bodyParser.json());
18    app.use(bodyParser.urlencoded({extended: false}));
19    app.use((req, res, next) => {
20      res.header('Access-Control-Allow-Origin', '*');
21      res.header(
22        'Access-Control-Allow-Headers',
23        'Origin, X-Requested-With, Content-Type, Accept'
24      );
25      next();
26    });
27    
28    app.listen(port, () => {
29      console.log(`Server started on port ${port}`);
30    });

The calls to our endpoint will be coming in from a different origin. Therefore, we need to make sure we include the CORS headers (Access-Control-Allow-Origin). If you are unfamiliar with the concept of CORS headers, you can find more information here.

To get started with Pusher Channels, sign up for a free Pusher account. Then go to the dashboard and create a new Channels app. Get your appId, key and secret.

Create a file in the root folder of the project and name it .env. Copy the following snippet into the .env file and ensure to replace the placeholder values with your Pusher credentials.

1// .env
2    
3    // Replace the placeholder values with your actual pusher credentials
4    PUSHER_APP_ID=PUSHER_APP_ID
5    PUSHER_KEY=PUSHER_KEY
6    PUSHER_SECRET=PUSHER_SECRET

We’ll make use of the dotenv library to load the variables contained in the .env file into the Node environment. The dotenv library should be initialized as early as possible in the application.

Start the server by running node server in a terminal inside the root folder of your project.

Draw route

Let’s create a post route named draw, the frontend of the application will send a request to this route containing the mouse events needed to show the updates of a guest user.

1// server.js
2    require('dotenv').config();
3    ...
4    
5    app.use((req, res, next) => {
6      res.header('Access-Control-Allow-Origin', '*');
7      ...
8    });
9    
10    
11    app.post('/paint', (req, res) => {
12      pusher.trigger('painting', 'draw', req.body);
13      res.json(req.body);
14    });
15    
16     ...
  • The request body will be sent as the data for the triggered Pusher event. The same object will be sent as a response to the user.
  • The trigger is achieved using the trigger method which takes the trigger identifier(painting), an event name (draw), and a payload.

Canvas directive

We’ll be creating and attaching a Vue directive to the canvas element. Using the directive, we’ll listen for events on the host element and also bind attributes to it

Create a file called canvas.directive.js in the src folder of your project. Open the file and copy the code below into it:

1// canvas.directive.js
2    
3    import { v4 } from 'uuid';
4    
5    function inserted(el) {
6      const canvas = el;
7      const ctx = canvas.getContext('2d');
8      
9      canvas.width = 1000;
10      canvas.height = 800;
11      
12      ctx.lineJoin = 'round';
13      ctx.lineCap = 'round';
14      ctx.lineWidth = 5;
15      
16      let prevPos = { offsetX: 0, offsetY: 0 };
17      let line = [];
18      let isPainting = false;
19      const userId = v4();
20      const USER_STROKE = 'red';
21      const GUEST_STROKE = 'greenyellow';
22      
23      function handleMouseDown(e) {
24        const { offsetX, offsetY } = e;
25        isPainting = true;
26        prevPos = { offsetX, offsetY };
27      }
28      function endPaintEvent() {
29        if (isPainting) {
30          isPainting = false;
31          sendPaintData();
32        }
33      }
34      function handleMouseMove(e) {
35        if (isPainting) {
36          const { offsetX, offsetY } = e;
37          const offSetData = { offsetX, offsetY };
38          const positionInfo = {
39            start: { ...prevPos },
40            stop: { ...offSetData },
41          };
42          line = line.concat(positionInfo);
43          paint(prevPos, offSetData, USER_STROKE);
44        }
45      }
46      function sendPaintData() {
47        const body = {
48          line,
49          userId,
50        };
51        fetch('http://localhost:4000/paint', {
52          method: 'post',
53          body: JSON.stringify(body),
54          headers: {
55            'content-type': 'application/json',
56          },
57        }).then(() => (line = []));
58      }
59      function paint(prevPosition, currPosition, strokeStyle) {
60        const { offsetX, offsetY } = currPosition;
61        const { offsetX: x, offsetY: y } = prevPosition;
62        ctx.beginPath();
63        ctx.strokeStyle = strokeStyle;
64        ctx.moveTo(x, y);
65        ctx.lineTo(offsetX, offsetY);
66        ctx.stroke();
67        prevPos = { offsetX, offsetY };
68      }
69      canvas.addEventListener('mousedown', handleMouseDown);
70      canvas.addEventListener('mousemove', handleMouseMove);
71      canvas.addEventListener('mouseup', endPaintEvent);
72      canvas.addEventListener('mouseleave', endPaintEvent);
73    }
74    export default {
75      inserted,
76    };

Note: we use the paint event to describe the duration from a mouse down event to a mouse up or mouse leave event.

So here, we created a directive that we will be registering locally in our App component. Also, you’ll notice that we exported the inserted function as a property in an object. The inserted function is a hook for when the element has been inserted into the parent node.

There’s quite a bit going on in the file above. Let’s walk through it and explain each step.

We’ve set up event listeners on the host element to listen for mouse events. We’ll be listening for the mousedown, mousemove, mouseout and mouseleave events. Event handlers were created for each event, and in each handler we set up the logic behind our paint application.

  • In the onMouseDown handler, we get the offsetX and offsetY properties of the event. The isPainting variable is set to true and then we store the offset properties in the prevPos object.

  • The onMouseMove method is where the magic happens. Here we check if isPainting is set to true, then we create an offsetData object to hold the current offsetX and offsetY properties of the current event. We then create a positionInfo object containing the previous and current positions of the mouse. Then append the positionData object to the line array. Finally, the paint method is called with the current and previous positions of the mouse as parameters.

  • The mouseup and mouseleave events both use one handler. The endPaintEvent method checks if the user is currently painting. If true, the isPainting property is set to false to prevent the user from painting until the next mousedown event is triggered. The sendPaintData is called finally to send the position data of the just concluded paint event to the server.

  • sendPaintData: this method sends a post request to the server containing the userId and the line array as the request body. The line array is then reset to an empty array after the request is complete. We use the browser’s native fetch API for making network requests.

  • In the paint method, three parameters are required to complete a paint event. The previous position of the mouse, current position and the stroke style. We used object destructuring to get the properties of each parameter. The ctx.moveTo function takes the x and y properties of the previous position. A line is drawn from the previous position to the current mouse position using the ctx.lineTo function and ctx.stroke visualizes the line.

Now that the directive has been set up, let’s import the directive and register it locally in the App.vue file. Update the App.vue file as so:

1// /src/App.vue
2    
3    <template>
4      <div id="app">
5        <div class="main">
6          <div class="color-guide">
7            <h5>Color Guide</h5>
8            <div class="user user">User</div>
9            <div class="user guest">Guest</div>
10          </div>
11          <!-- Bind the custom directive to the canvas element -->
12          <canvas v-canvas></canvas>
13        </div>
14      </div>
15    </template>
16    
17    <script>
18    import canvas from './canvas.directive.js';
19    export default {
20      name: 'app',
21      // Here we register our custom directive
22      directives: {
23        canvas,
24      },
25    };
26    </script>
27    <style>
28    #app {
29      font-family: 'Avenir', Helvetica, Arial, sans-serif;
30      -webkit-font-smoothing: antialiased;
31      -moz-osx-font-smoothing: grayscale;
32      text-align: center;
33      color: #2c3e50;
34      margin-top: 60px;
35    }
36    canvas {
37      background: navy;
38    }
39    .main {
40      display: flex;
41      justify-content: center;
42    }
43    .color-guide {
44      margin: 20px 40px;
45    }
46    h5 {
47      margin-bottom: 10px;
48    }
49    .user {
50      padding: 7px 15px;
51      border-radius: 4px;
52      color: white;
53      font-size: 13px;
54      font-weight: bold;
55      background: red;
56      margin: 10px 0;
57    }
58    .guest {
59      background: greenyellow;
60      color: black;
61    }
62    </style>

In our template, we bound the custom directive to the canvas element. We imported and registered the directive in the App component. We added a color guide so users can tell their drawing apart. Finally, we added styles for the new elements added.

Run npm run serve in your terminal and visit http://localhost:8080 to have a look at the application. It should be similar to the screenshot below:

paint-app-vue-stage-1

Introducing Pusher and realtime collaboration

Import the Pusher library into the canvas.directive.j``s file. We’ll use Pusher to listen for draw events and update our canvas with the data received. Open the canvas.directive.js file, import the Pusher library, initialize it and listen for events:

1// /src/canvas.directive.js
2    import { v4 } from 'uuid';
3    import Pusher from 'pusher-js';
4    
5    function inserted(el) {
6      ...
7      ctx.lineCap = 'round';
8      ctx.lineWidth = 5;
9      
10      const pusher = new Pusher('PUSHER_KEY', {
11        cluster: 'eu',
12      });
13      const channel = pusher.subscribe('painting');
14      
15      ...
16      
17      canvas.addEventListener('mouseup', endPaintEvent);
18      canvas.addEventListener('mouseleave', endPaintEvent);
19      channel.bind('draw', (data) => {
20        const { userId: id, line } = data;
21        if (userId !== id) {
22          line.forEach((position) => {
23            paint(position.start, position.stop, GUEST_STROKE);
24          });
25        }
26      });
27      
28      ...
  • First, we initialize Pusher using the appKey provided during creation of the channels ap.
  • Below the event listeners, we subscribe to the painting channel and listen for draw events. In the callback, we get the userId and line properties in the data object returned; using object destructuring, the userId property of the data returned was renamed as id.
  • Finally, check if the draw event came from a different user by comparing the ids. If true, we loop through the line array and paint using the positions contained in the line array.

Note: ensure you replace the PUSHER_KEY string with your actual Pusher key.

Test application

Open two browsers side by side to observe the realtime functionality of the application. Drawings made on one browser should show up on the other with different stroke colors. Here’s a screenshot of two browsers side by side using the application:

Note: Ensure both the server and the dev server are up by running npm run serve and node server on separate terminal sessions.

paint-app-vue-preview

Conclusion

We’ve created a collaborative drawing application using Vue.js, using Pusher Channels to provide realtime functionality. You can extend the application to show each user’s mouse position. It’ll be fun to see where each person is at any point. The source code for this tutorial is available on GitHub here.