Build a collaborative painting application with Angular
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 fromappCanvas
tomyCanvas
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 theisPainting
property to true and then we get theoffsetX
andoffsetY
properties of the event and store it in theprevPos
object. -
The
onMouseMove
method is where the painting takes place. Here we check ifisPainting
is set to true, then we create anoffsetData
object to hold the currentoffsetX
andoffsetY
properties of the event. We update theposition
object with the previous and current positions of the mouse. We then append theposition
to theline
array and then we call thedraw
method with the current and previous positions of the mouse as parameters. -
The
onMouseUp
andonMouseLeave
methods both check if the user is currently painting. If true, theisPainting
property is set to false to prevent the user from painting until the nextmousedown
event is triggered. ThemakeRequest
method is the called to send the paint event to the server. -
makeRequest
: this method sends a post request to the server containing theuserId
and theline
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. Thectx.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 thectx.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
andnode 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.
18 May 2018
by Christian Nwamba