Build a collaborative painting application with Angular

  • Christian Nwamba
May 18th, 2018
You will need Node and npm installed on your machine. A basic understanding of Angular is required.

We’ll be creating a realtime paint application. Using our application, users can easily collaborate while working on the application and receive changes in realtime. We’ll be using Pusher’s pub/sub pattern to get realtime updates and Angular for templating.

To follow this tutorial a basic understanding of Angular and Node.js is required. Please ensure that you have Node and npm installed before you begin.

If you have no prior knowledge of Angular, kindly follow the tutorial here. Come back and finish the tutorial when you’re done.

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

Here’s a demo of the final product:

Initializing the application and installing dependencies

To get started, we will use the CLI (command line interface) provided by the Angular team to initialize our project.

First, install the CLI by running npm install -g @angular/cli. NPM is a package manager used for installing packages. It will be available on your PC if you have Node installed.

To create a new Angular project using the CLI, open a terminal and run:
ng new angular-realtime-paintapp

This command is used to initialize a new Angular project.

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

    // install depencies required to build the server
    npm install express body-parser dotenv pusher

    // front-end dependencies
    npm install pusher-js uuid @types/uuid

Start the Angular development server by running ng serve in a terminal in the root folder of your project.

Building our 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

    // server.js

    require('dotenv').config();
    const express = require('express');
    const bodyParser = require('body-parser');
    const Pusher = require('pusher');

    const app = express();
    const port = process.env.PORT || 4000;
    const pusher = new Pusher({
      appId: process.env.PUSHER_APP_ID,
      key: process.env.PUSHER_KEY,
      secret: process.env.PUSHER_SECRET,
      cluster: 'eu',
    });

    app.use(bodyParser.json());
    app.use(bodyParser.urlencoded({extended: false}));
    app.use((req, res, next) => {
      res.header('Access-Control-Allow-Origin', '*');
      res.header(
        'Access-Control-Allow-Headers',
        'Origin, X-Requested-With, Content-Type, Accept'
      );
      next();
    });

    app.listen(port, () => {
      console.log(`Server started on port ${port}`);
    });

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.

Create a Pusher account and a new Pusher Channels app if you haven’t done so yet and 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.

    // .env

    // Replace the placeholder values with your actual pusher credentials
    PUSHER_APP_ID=PUSHER_APP_ID
    PUSHER_KEY=PUSHER_KEY
    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 make a request to this route containing the mouse events needed to show the updates of a guest user.

    // server.js
    require('dotenv').config();
    ...

    app.use((req, res, next) => {
      res.header('Access-Control-Allow-Origin', '*');
      ...
    });


    app.post('/draw', (req, res) => {
      pusher.trigger('painting', 'draw', req.body);
      res.json(req.body);
    });

     ...
  • 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 view

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

Run ng generate directive canvas to create the canvas directive.

Open the canvas.directive.ts file and update it with the content below.

    // canvas.directive.ts
    import {
      Directive,
      ElementRef,
      HostListener,
      HostBinding,
      AfterViewInit,
    } from '@angular/core';
    import { v4 } from 'uuid';
    import { HttpClient } from '@angular/common/http';

    declare interface Position {
      offsetX: number;
      offsetY: number;
    }
    @Directive({
      selector: '[myCanvas]',
    })
    export class CanvasDirective implements AfterViewInit {
      constructor(
        private el: ElementRef,
        private http: HttpClient
      ) {
        // We use the ElementRef to get direct access to the canvas element. Here we set up the properties of the element. 
        this.canvas = this.el.nativeElement;
        this.canvas.width = 1000;
        this.canvas.height = 800;
        // We create a canvas context. 
        this.ctx = this.canvas.getContext('2d');
        this.ctx.lineJoin = 'round';
        this.ctx.lineCap = 'round';
        this.ctx.lineWidth = 5;
      }
      canvas: HTMLCanvasElement;
      ctx: CanvasRenderingContext2D;
      // Stroke styles for user and guest
      userStrokeStyle = '#FAD8D6';
      guestStrokeStyle = '#CD5334';
      position: {
        start: {};
        stop: {};
      };
      // This will hold a list of positions recorded throughout the duration of a paint event
      line = [];
      // Since there's no auth setup, we'll need to able to tell users and guests apart.v4 creates a unique id for each user
      userId = v4();
      // This object will hold the start point of any paint event.
       prevPos: Position = {
        offsetX: 0,
        offsetY: 0,
      };
      // This will be set to true when a user starts painting
      isPainting = false;

      @HostListener('mousedown', ['$event'])
      onMouseDown({ offsetX, offsetY }) {
        this.isPainting = true;
        // Get the offsetX and offsetY properties of the event. 
        this.prevPos = {
          offsetX,
          offsetY,
        };
      }
      @HostListener('mousemove', ['$event'])
      onMouseMove({ offsetX, offsetY }) {
        if (this.isPainting) {
          const offSetData = { offsetX, offsetY };
          // Set the start and stop position of the paint event. 
          this.position = {
            start: { ...this.prevPos },
            stop: { ...offSetData },
          };
          // Add the position to the line array
          this.line = this.line.concat(this.position);
          this.draw(this.prevPos, offSetData, this.userStrokeStyle);
        }
      }
      @HostListener('mouseup')
      onMouseUp() {
        if (this.isPainting) {
          this.isPainting = false;
          // Send a request to the server at the end of a paint event
          this.makeRequest();
        }
      }
      @HostListener('mouseleave')
      onmouseleave() {
        if (this.isPainting) {
          this.isPainting = false;
          this.makeRequest();
        }
      }
      @HostBinding('style.background') background = 'black';

      makeRequest() {
        // Make a request to the server containing the user's Id and the line array.
        this.http
          .post('http://localhost:4000/draw', {
            line: this.line,
            userId: this.userId,
          })
          .subscribe((res) => {
            this.line = [];
          });
      }
      // The draw method takes three parameters; the prevPosition, currentPosition and the strokeStyle
      draw(
        { offsetX: x, offsetY: y }: Position,
        { offsetX, offsetY }: Position,
        strokeStyle
      ){
        // begin drawing
        this.ctx.beginPath();
        this.ctx.strokeStyle = strokeStyle;
        // Move the the prevPosition of the mouse
        this.ctx.moveTo(x, y);
        // Draw a line to the current position of the mouse
        this.ctx.lineTo(offsetX, offsetY);
        // Visualize the line using the strokeStyle
        this.ctx.stroke();
        this.prevPos = {
          offsetX,
          offsetY,
        };
      }
      ngAfterViewInit() {}
    }

Note: a paint event in this context is the duration from when the mousedown event is triggered to when the mouse is up or when the mouse leaves the canvas area. Also remember to rename the directive selector property from appCanvas to myCanvas

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

We are making use of HostListener decorators to listen for mouse events on the host elements. Methods are defined for each event.

  • In the onMouseDown method, we set the isPainting property to true and then we get the offsetX and offsetY properties of the event and store it in the prevPos object.
  • The onMouseMove method is where the painting takes place. 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 event. We update the position object with the previous and current positions of the mouse. We then append the position to the line array and then we call the draw method with the current and previous positions of the mouse as parameters.
  • The onMouseUp and onMouseLeave methods both check 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 makeRequest method is the called to send the paint event to the server.
  • makeRequest: 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.
  • In the draw method, three parameters are required to complete a paint event. The previous position of the mouse, current position and the strokeStyle. 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. ctx.stroke visualizes the line.

We made reference to the HttpClient service. To make use of this in the application, we’ll need to import the HttpClientModule into the app.module.ts file.

    // app.module.ts
    ...
    import { CanvasDirective } from './canvas.directive';
    import { HttpClientModule } from '@angular/common/http';

    @NgModule({
      ...
      imports: [BrowserModule, HttpClientModule],
      ...
    })

    ...

Now that the directive has been set up, let’s add a canvas element to the app.component.html file and attach the myCanvas directive to it. Open the app.component.html file and replace the content with the following:

    <!-- app.component.html -->
    <div class="main">
      <div class="color-guide">
        <h5>Color Guide</h5>
        <div class="user user">User</div>
        <div class="user guest">Guest</div>
      </div>
      <canvas myCanvas></canvas>
    </div>

Add the following styles to the app.component.css file:

    // app.component.css
    .main {
      display: flex;
      justify-content: center;
      font-family: 'Arimo', sans-serif;
    }
    .color-guide {
      margin: 20px 40px;
    }
    h5{
      margin-bottom: 10px;
    }
    .user {
      padding: 7px 15px;
      border-radius: 4px;
      color: black;
      font-size: 13px;
      font-weight: bold;
      background: #fad8d6;
      margin: 10px 0;
    }
    .guest {
      background: #cd5334;
      color: white;
    }

We’re making use of an external font; so let’s include a link to the stylesheet in the index.html file.

    <!-- index.html -->
    <head>
      ...
      <link rel="icon" type="image/x-icon" href="favicon.ico">
      <link href="https://fonts.googleapis.com/css?family=Arimo:400,700" rel="stylesheet">
    </head>

Run ng serve in your terminal and visit http://localhost:4200/ to have a look of the application. It should be similar to the screenshot below:

Introducing Pusher

To make the Pusher library available in our project, add the library as a third party script to be loaded by Angular CLI. All CLI config is stored in .angular-cli.json file. Modify the scripts property to include the link to pusher.min.js.

    // .angular-cli.json
    ...
    "scripts": [
      "../node_modules/pusher-js/dist/web/pusher.min.js"
    ]
     ...

After updating this file, you’ll need to restart the Angular server so the CLI compiles the new script file added.

Create a Pusher service using the Angular CLI by running the following command:

    ng generate service pusher

This command simply tells the CLI to generate a service named pusher. Now open the pusher.service.ts file and update it with the code below.

    // pusher.service.ts
    import { Injectable } from '@angular/core';
    declare const Pusher: any;
    @Injectable()
    export class PusherService {
      constructor() {
        const pusher = new Pusher('PUSHER_KEY', {
          cluster: 'eu',
        });
        this.channel = pusher.subscribe('painting');
      }
      channel;
      public init() {
        return this.channel;
      }
    }
  • First, we initialize Pusher in the constructor.
  • The init method returns the Pusher property we created.

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

To make the service available application wide, import it into the app.module.ts file.

    // app.module.ts
    ...
    import { HttpClientModule } from '@angular/common/http';
    import {PusherService} from './pusher.service';

    @NgModule({
       ....
       providers: [PusherService],
       ....
     })

    ...

Realtime painting

Let’s include the Pusher service in the canvas.directive.ts file to make use of the realtime functionality made available using Pusher. Update the canvas.directive.ts to include the new Pusher service.

    // canvas.directive.ts
      ...
      import { HttpClient } from '@angular/common/http';
      import { PusherService } from './pusher.service';

      ...
      constructor(
        private el: ElementRef,
        private http: HttpClient,
        private pusher: PusherService
      ) {
        ...
      }

      ...

      ngAfterViewInit() {
        const channel = this.pusher.init();
        channel.bind('draw', (data) => {
          if (data.userId !== this.userId) {
            data.line.forEach((position) => {
              this.draw(position.start, position.stop, this.guestStrokeStyle);
            });
          }
        });
      }
    }

In the AfterViewInit lifecycle, we initialized the Pusher service and listened for the draw event. In the event callback, we check if the there’s a distinct userId. Then we loop through the line property of the data returned from the callback. Wed proceed to draw using the start and stop objects properties of each position contained in the array.

Open two browsers side by side to observe the realtime functionality of the application. A line drawn on one browser should show up on the other. Here’s a screenshot of two browsers side by side using the application:

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

Conclusion

We’ve created a collaborative drawing application in Angular, using Pusher to provide realtime functionality. You can check out the repo containing the demo on GitHub.

  • Channels

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.