🎉 New! Web Push Notifications for Chatkit. Learn more in our latest blog post.
Hide
Products
chatkit_full-logo

Extensible API for in-app chat

channels_full-logo

Build scalable realtime features

beams_full-logo

Programmatic push notifications

Developers

Docs

Read the docs to learn how to use our products

Tutorials

Explore our tutorials to build apps with Pusher products

Support

Reach out to our support team for help and advice

Sign in
Sign up

Create a whiteboard Electron app with React - Part 1: Adding the whiteboard

  • Wern Ancheta

March 25th, 2019
You will need Node and Yarn installed on your machine.

In this tutorial, we’ll take a look at how we can build a whiteboard app using React.

Prerequisites

Basic knowledge of React is required. We’ll also be using Electron, but you don’t even need to know it since we’re only using it as a container.

You need Node and Yarn to install packages.

You’ll also need a Pusher app instance. Enable client events on its settings because we will be triggering events directly from the client side.

Optionally, you need an ngrok account if you want to test the app with someone outside your network.

App overview

The whiteboard app will allow the users to communicate their ideas via a canvas similar to a physical whiteboard. Thus, it will have the following tools:

  • Select - for selecting objects in the canvas so they can be modified or removed.
  • Pen - for free-hand drawing.
  • Rectangle - for drawing rectangle shapes.
  • Circle - for drawing circle shapes.
  • Text - for adding text.

Here’s what the final output for this part will look like:

You can find the code for this tutorial on its GitHub repo.

Note that I’ll be using the terms “canvas” and “whiteboard” interchangeably throughout the article, but they will mean the same thing.

Choosing the tool

The main challenge with building a whiteboard is the implementation of canvas. With plain JavaScript, we have a bunch of options, but only FabricJS and Konva seem to fit the bill.

The next step is to find out if any of these libraries have decent React integration. Of the two, only Konva meets the requirement with its React Konva library.

The only problem is I don’t have prior Konva experience and the various elements (for example, rectangle and circle) are actually rendered using components. There’s also no free-drawing tool which is often the most important.

Digging further, I found the ReactJS Fabric library. Unfortunately, it also has the same problem as React Konva. It’s also not very well-maintained.

Finally, I found this React Sketch package from Thomas. It’s exactly what we need to quickly create a whiteboard app. And it uses FabricJS behind the scenes so I know that it’s going to be good since I previously used FabricJS on another project.

Bootstrapping the app

Now that you know why we ended up using React Sketch, it’s time to start building the app. I’ve already created a starter project which has navigation and all the styles already set up. This will serve as the starting point for this tutorial. Go ahead and clone it on your working directory:

    git clone https://github.com/anchetaWern/ElectronWhiteboard

Switch to the starter branch and install the dependencies:

    git checkout starter
    yarn

Extending the React Sketch package

In this section, we’ll extend the React Sketch package in order for it to be able to trigger client events when a whiteboard object is created, updated, or removed.

This is an optional section since I’ve already included the compiled version of the updated package in the node_modules/react-sketch/dist folder of the part1 branch.

If you’re planning to make use of the same package in the future or you want to learn how the package works, I encourage you to follow along. Otherwise, simply skip to the next section.

If you decide to skip this section, you need to copy this file over to the node_modules/react-sketch/dist directory of the project.

Installing the dependencies

If you’re still here, the next step is to clone the React Sketch package so we can update it accordingly:

    git clone https://github.com/tbolis/react-sketch.git

If you’re on Mac, you need to install Macports. This software allows you to install various open source software that’s required to compile FabricJS. Go ahead and download the installer file from this page and install it on your machine. Once it’s installed, you need to install the following packages via Macports:

    sudo port install pkgconfig cairo pango libpng jpeg giflib libsrvg

These are all required to compile FabricJS.

If you’re on Ubuntu or any other Linux distribution, you won’t have any problem because these open-source tools are native to Linux. But if you don’t have it, you can simply install it via your default way of installing software.

For Windows, you need to install Node 8.11 because it’s required by windows-build-tools. This will install Python 2.7 and Visual Studio build tools on your machine:

    npm install -g windows-build-tools

You also need node-gyp:

    npm install -g node-gyp

Updating the code

We’re now ready to update the code. Start by declaring the additional props that we will be supplying to this component:

    // react-sketch/src/SketchField.jsx
    static propTypes = {
      // <existing code>
      style: PropTypes.object,

      // add these:
      onUpdate: PropTypes.func, // function to execute when an object is modified
      username: PropTypes.string, // username of the current user
      shortid: PropTypes.func // helper for generating random unique IDs for objects
    }

Next, update the _onObjectAdded function to add a unique ID for an object that was created. Then execute the onUpdate function that we will pass as props later on:

    _onObjectAdded = (e) => {
      // <existing code>
      this._history.keep([obj, state, state])

      // add these:
      if (!obj.sender) { 
        const id = this.props.shortid.generate(); 
        Object.assign(obj, { id });
        this.props.onUpdate(JSON.stringify(obj), 'add', this.props.username, id); 
      }
    }

The above function is automatically triggered whenever a new object is added to the canvas. That’s why it’s the perfect place to execute the function that will update the UI of all the other users in the channel.

Note that we’re checking for the non-existence of the sender property (username of the user who added the object) in the object before we trigger the function for updating the UI of the other users because it will be a catastrophe if we don’t.

We already know that the _onObjectAdded function is automatically executed every time a new object is added. So if we’re not checking for the existence of the sender property, this.props.onUpdate will basically ping-pong between the users in the channel.

As you’ll see in the src/screens/Whiteboard.js file later, the sender property is being added to the object when the event for updating the canvas (client-whiteboard-updated) is received. This effectively prevents the receiver from triggering the same event to all the other users.

Do the same for the _onObjectModified function. This function is automatically executed every time an object is updated:

    _onObjectModified = (e) => {
      // <existing code>  
      this._history.keep([obj, prevState, currState]);
      // add these:
      if (!obj.sender) {
        let strObj = JSON.stringify(obj);
        this.props.onUpdate(strObj, 'update', this.props.username, obj.id);
      }
    }

Next, add a function for getting the currently selected object. This will get called from the src/screens/Whiteboard.js file later to get the id of the currently selected object for removal.

As you’ve seen in the _onObjectAdded function earlier, this is a unique ID assigned to the object. By using it, all the other users can find the object so they can also remove it from their canvas:

    getSelected = () => {
      let canvas = this._fc;
      let activeObj = canvas.getActiveObject();
      return activeObj;
    }

The setSelected function is used for programmatically setting an active (currently selected) object in the canvas. We will call it when the other users in the channel receive the event for updating the whiteboard.

If the event has a type of remove, this function sets the object with the id passed in the event as active. From there, we simply use the existing removeSelected function to remove the object from the canvas:

    setSelected = (id) => {
      let canvas = this._fc;
      var objToSelect = canvas.getObjects().find((o) => {
        return id == o.id;
      });
      canvas.setActiveObject(objToSelect);
      canvas.requestRenderAll();
    }

Next, add a function for programmatically adding an object to the canvas. This gets fired on all the other users in the channel every time a user adds a new object. Note that each object type has a different way of initialization:

    addObject = (obj) => {

      let canvas = this._fc;
      let shapeData = JSON.parse(obj);

      let shape = null;
      const type = this._capsFirstLetter(shapeData.type);
      if (type == 'Path') {
        let string_path = '';
        shapeData.path.forEach((x) => {
          string_path += x.join(' ');
        });

        shape = new fabric.Path(string_path);
        delete shapeData.path;
        shape.set(shapeData);
      } else if (type == 'I-text') {
        shape = new fabric.Text(shapeData.text); 
        delete shapeData.text;
        shape.set(shapeData);
      } else {
        // for Rectangle and Circle objects
        shape = new fabric\[type\](shapeData);
      }

      canvas.add(shape);
    }

Here’s the _capsFirstLetter function. It’s used to convert the first letter of a string to uppercase so it matches an actual FabricJS object type:

    _capsFirstLetter = (str) => {
      return str.charAt(0).toUpperCase() + str.slice(1);
    }

Next, add the function for modifying existing objects. This gets called every time an existing object is updated by another user in the channel:

    modifyObject = (obj) => {

      let objData = JSON.parse(obj);
      let canvas = this._fc;

      var objToModify = canvas.getObjects().find((o) => {
        return objData.id == o.id;
      });
      objToModify.set(objData); // update the object
      objToModify.setCoords(); // useful if the object's coordinates in the canvas also changed (usually by moving)
      canvas.requestRenderAll(); // refresh the canvas so changes will appear
    }

Next, update the addText function to include the id to the object. This id will be passed from the src/screens/Whiteboard.js file later:

    addText = (text, options = {}) => {
      // <existing code>
      Object.assign(options, opts);
      iText.set({
        'id': options.id, // add this
        // <existing code>
      });
    }

Building the package

Now we’re ready to install all the dependencies and build the package:

    yarn
    yarn prebuild
    yarn build

This generates a dist/index.js file. Copy that file and replace the node_modules/react-sketch/dist/index.js file inside the ElectronWhiteboard folder with it to update React Sketch in your project.

Remember to do this before you compile the ElectronWhiteboard project using yarn start or yarn build so it uses the updated version of the package instead of the original one. You can also add a build script to automatically do that if you want.

Login screen

Create a src/screens/Login.js file and add the following:

    import React, { Component } from "react";
    import { Container, Row, Col, Button, Input } from 'reactstrap';
    import Pusher from "pusher-js";

    import uniquename from "../helpers/uniquename";

    const channel_name = uniquename();

    const PUSHER_APP_KEY = process.env.REACT_APP_PUSHER_APP_KEY;
    const PUSHER_APP_CLUSTER = process.env.REACT_APP_PUSHER_APP_CLUSTER;
    const BASE_URL = "http://localhost:5000";

    class LoginScreen extends Component {

      state = {
        myUsername: "",
        channelName: channel_name,
        isLoading: false
      }

      constructor(props) {
        super(props);
        this.pusher = null;
        this.group_channel = null; // channel for communicating changes to the canvas
      }

      // next: add render function

    }

Next, render the login UI. This will ask for the user’s username and channel they want to enter. Note that when logging in, the channel doesn’t already need to exist:

    render() {
      return (
        <Container>
          <Row>
            <Col lg={12}>
              <div className="centered">
                <div className="textInputContainer">
                  <Input 
                    type="text"
                    placeholder="myUsername"
                    onChange={this.onUpdateText}
                    value={this.state.myUsername} />
                </div>

                <div className="textInputContainer">
                  <Input 
                    type="text"
                    placeholder="channelName"
                    onChange={this.onUpdateText}
                    value={this.state.channelName} />
                </div>

                <div className="buttonContainer">
                  <Button 
                    type="button" 
                    color="primary" 
                    onClick={this.login} 
                    disabled={this.state.isLoading} 
                    block>
                      {this.state.isLoading ? "Logging in…" : "Login"}
                  </Button>
                </div>

              </div>
            </Col>
          </Row>
        </Container>
      );
    }

Here’s the function for updating the value of the text fields:

    onUpdateText = (evt) => {
      const field = evt.target.getAttribute("placeholder");
      this.setState({
        [field]: evt.target.value
      });
    };

When the user logs in, we authenticate them with Pusher so they can trigger events from the client side. Once authenticated, we subscribe them to a common channel where all the changes in the whiteboard will be communicated:

    login = () => {

      const { myUsername, channelName } = this.state;

      this.setState({
        isLoading: true
      });

      this.pusher = new Pusher(PUSHER_APP_KEY, {
        authEndpoint: `${BASE_URL}/pusher/auth`,
        cluster: PUSHER_APP_CLUSTER,
        encrypted: true
      });

      this.group_channel = this.pusher.subscribe(`private-group-${channelName}`);
      this.group_channel.bind("pusher:subscription_error", (status) => {
        console.log("error subscribing to group channel: ", status);
      });

      this.group_channel.bind("pusher:subscription_succeeded", () => {
        console.log("subscription to group succeeded");

        // navigate to the whiteboard screen
        this.props.navigation.navigate("Whiteboard", {
          myUsername,
          pusher: this.pusher,
          group_channel: this.group_channel
        });

      });

    }

Don’t forget to add a .env file at the project root. This contains your Pusher app credentials:

    REACT_APP_PUSHER_APP_KEY="YOUR PUSHER APP KEY"
    REACT_APP_PUSHER_APP_CLUSTER="YOUR PUSHER APP CLUSTER"

Unique name helper

Here’s the code for the unique name helper:

    // src/helpers/uniquename.js
    var generateName = require("sillyname");

    const generateUsername = () => {
      const min = 10;
      const max = 99;
      const number = Math.floor(Math.random() * (max - min + 1)) + min;
      const username = generateName().replace(" ", "_") + number;
      return username;
    };

    export default generateUsername;

Whiteboard screen

Now we’re ready to add the code for the Whiteboard screen. Create a src/screens/Whiteboard.js file and add the following:

    import React, { Component } from "react";
    import { Container, Row, Col, Button, Input } from 'reactstrap';
    import { SketchField, Tools } from 'react-sketch';

    import { FaMousePointer, FaPen, FaCircle, FaSquare, FaTrash } from 'react-icons/fa';

    import shortid from 'shortid'; // for generating unique IDs

Next, initialize the state and the instance variables. this.tools contains the tools that the users can use to draw objects in the whiteboard. This corresponds to the object types available in FabricJS:

    class WhiteboardScreen extends Component {

      state = {
        text: '',
        myUsername: '',
        tool: Tools.Pencil
      }

      constructor(props) {
        super(props);

        this.tools = [
          {
            name: 'select',
            icon: <FaMousePointer />,
            tool: Tools.Select
          },
          {
            name: 'pencil', 
            icon: <FaPen />,
            tool: Tools.Pencil
          },
          {
            name: 'rect',
            icon: <FaSquare />,
            tool: Tools.Rectangle
          },
          {
            name: 'circle',
            icon: <FaCircle />,
            tool: Tools.Circle
          }
        ];

        this.auto_create_tools = ['circle', 'rect']; // tools that will automatically create their corresponding object when selected

        // next: add settings for auto-created objects
      }

      // next: add componentDidMount
    }

Next, add the default settings for the objects that will be automatically created upon selection. Note that the user can always modify the object afterward so it’s safe to create them with some default settings:

    this.initial_objects = {
      'circle': { 
        radius: 75, 
        fill: 'transparent', 
        stroke: '#000', 
        strokeWidth: 3, 
        top: 60, 
        left: 500 
      },

      'rect': { 
        width: 100, 
        height: 50, 
        fill: 'transparent', 
        stroke: '#000', 
        strokeWidth: 3, 
        top: 100, 
        left: 330 
      }
    }

Once the component is mounted, we get the navigation params that were passed from the login screen earlier:

    componentDidMount() {

      const { navigation } = this.props;
      this.myUsername = navigation.getParam("myUsername");
      this.pusher = navigation.getParam("pusher");
      this.group_channel = navigation.getParam("group_channel");

      this.setState({
        myUsername: this.myUsername
      });

      // next: add code for listening for canvas updates
    }

Next, we listen for the event triggered by the user who updates the canvas. Every time this gets fired, we call the textGatherer function. This is a JavaScript closure that allows us to create an internal scope for storing payload data.

We’re doing this because not all messages contain all the data we need in order to update the canvas. A single object update or creation may require the use of five or more messages in order to send its full data. This is necessary because of Pusher’s 10KB per message limit which we’ll talk about later.

Going back to the code below, we check for the is_final property in the payload before we can start processing the message. Having this property in the message means that this is the last part of the message. Only then can we get the contents accumulated by the closure and convert it to an object.

That way, we can assign additional properties to it before we convert it back to a JSON string so we can pass it to the addObject and modifyObject functions:

    let textGatherer = this._gatherText();

    this.group_channel.bind('client-whiteboard-updated', (payload) => {
      textGatherer(payload.data);

      if (payload.is_final) {
        const full_payload = textGatherer(); // get the gathered text
        let obj = '';
        if (full_payload) {
          obj = JSON.parse(full_payload);

          if (payload.id) {
            Object.assign(obj, { id: payload.id, sender: payload.sender });
          } else {
            Object.assign(obj, { sender: payload.sender });
          }
        }

        if (payload.action === 'add') {
          this._sketch.addObject(JSON.stringify(obj));
        } else if(payload.action === 'update') {
          this._sketch.modifyObject(JSON.stringify(obj));
        } else if(payload.action === 'remove') {
          this._sketch.setSelected(payload.id);
          this._sketch.removeSelected();
        }

        textGatherer = this._gatherText(); // reset to an empty string
      }

    });

Here’s the code for the _gatherText function:

    _gatherText = () => {
      let sentence = '';
      return (txt = '') => {
       return sentence += txt;
      }
    }

Next, render the UI. This is where we make use of the SketchField component that we updated earlier (if you followed along). this._sketch contains the reference to the component itself, it allows us to use methods from within the component class. tool is the tool that’s used to draw something on the canvas. onUpdate, username, and shortid are the custom ones (they’re not from the original library) we added earlier. Additionally, we have a tool for removing objects and adding text:

    render() {
      return (
        <Container fluid>
          <Row>
            <Col lg={9}>
              {
                this.state.myUsername &&
                <SketchField
                  className="canvas"
                  ref={c => (this._sketch = c)}
                  width='1024px'
                  height='768px'
                  tool={this.state.tool}
                  lineColor='black'
                  lineWidth={3}
                  onUpdate={this.sketchUpdated}
                  username={this.state.myUsername}
                  shortid={shortid} />
              }
            </Col>

            <Col lg={3} className="Sidebar">
              <div className="tools">

                {this.renderTools()}

                <div className="tool">
                  <Button 
                    color="danger" 
                    size="lg" 
                    onClick={this.removeSelected} 
                  >
                    <FaTrash />
                  </Button>
                </div>
              </div>

              <div>
                <div className="textInputContainer">
                  <Input 
                    type="textarea" 
                    name="text_to_add" 
                    id="text_to_add" 
                    placeholder="Enter text here" 
                    value={this.state.text}
                    onChange={this.onUpdateText} />
                  <div className="buttonContainer">
                    <Button type="button" color="primary" onClick={this.addText} block>Add Text</Button>
                  </div>
                </div>
              </div>

            </Col>  
          </Row>
        </Container>
      );
    }

Here’s the function for rendering the buttons for picking the tools:

    renderTools = () => {
      return this.tools.map((tool) => {
        return (
          <div className="tool" key={tool.name}>
            <Button 
              color="secondary" 
              size="lg" 
              onClick={this.pickTool} 
              data-name={tool.name}
              data-tool={tool.tool}
            >
              {tool.icon}
            </Button>
          </div>
        );
      });
    }

The pickTool function is executed when any of the buttons is clicked. This will simply update the value of tool in the state. But if the selected tool is one of those “auto-create” ones, we generate a unique ID and add it as a property to the default object settings (this.initial_objects) before we add it to the canvas.

We also change the tool back to the select tool so the user can select the object and start modifying it to their liking:

    pickTool = (event) => {
      const button = event.target.closest('button');
      const tool = button.getAttribute('data-tool');
      const tool_name = button.getAttribute('data-name');

      this.setState({
        tool
      }, () => {
        if (this.auto_create_tools.indexOf(tool_name) !== -1) {

          const obj = this.initial_objects[tool_name]; // get the initial object settings
          const id = shortid.generate(); // generate a random unique ID
          Object.assign(obj, { id: id, type: tool_name }); // add the ID to the object

          this._sketch.addObject(JSON.stringify(obj)); // add the object to the canvas

          // change the tool back to select
          setTimeout(() => {
            this.setState({
              tool: Tools.Select 
            });
          }, 500);

        }

      });
    }

    // next: add onUpdateText

Next, add the function for updating the value of the text field for entering the text to be added to the canvas:

    onUpdateText = (event) => {
      this.setState({
        text: event.target.value
      });
    }

    // next: add addText

When the button for adding a text is clicked, we call the addText method from inside the SketchField component. This accepts the text value to be added, and an optional object containing additional options for the text. In this case, we’re simply using it to pass the unique object ID:

    addText = () => {
      if (this.state.text) {
        const id = shortid.generate();
        this._sketch.addText(this.state.text, { id }); // add a text object to the canvas

        this.setState({
          text: ''
        });
      }
    }

    // next: add sketchUpdated function

Next is the sketchUpdated function. This function is called every time an object is added, modified, or removed from the canvas. It uses the updateOtherUsers function to publish the changes to all the other users on the channel. But before doing so, it first splits up the object based on a specific length.

As mentioned earlier, Pusher has a 10KB limit for publishing messages. This is plenty for the Circle, Rectangle, and Text objects but not for the path (free-drawing) object.

It’s considered a complex shape so it takes a lot more data to describe it. Which means that the 10KB limit is not enough. So the solution is to split up the obj into multiple parts.

Each part contains a specific number of characters. In this case, we’re using 8,000 because 10KB is roughly 10,000 characters. The extra 2,000 is for all the other data that we’re publishing. For the final part, we simply add an is_final property to the payload to signal the receiver that the message is ready to be assembled:

    sketchUpdated = (obj, action, sender, id = null) => {
      if (this.state.myUsername) {

        let length_per_part = 8000; // maximum number of characters that can be alloted to a FabricJS object
        let loop_count = Math.ceil(obj.length / length_per_part);

        let from_str_index = 0;
        for (let x = 0; x < loop_count; x++) {
          const str_part = obj.substr(from_str_index, length_per_part);

          const payload = {
            action: action,
            id: id,
            data: str_part,
            sender: this.state.myUsername
          };

          if (x + 1 === loop_count) { // if this is the final part
            Object.assign(payload, { is_final: true });
          }

          this.updateOtherUsers(payload);
          from_str_index += length_per_part;
        }
      }
    }

Here’s the updateOtherUsers function:

    updateOtherUsers = (payload) => {
      this.group_channel.trigger('client-whiteboard-updated', payload);
    }

Lastly, we have the removeSelected function. This is where we get the currently selected object and publish its id to all the other users in the channel:

    removeSelected = () => {
      const activeObj = this._sketch.getSelected();

      const payload = {
        action: 'remove',
        is_final: true,
        id: activeObj.id,
        sender: this.state.myUsername
      };

      this.updateOtherUsers(payload);
      this._sketch.removeSelected(); // remove the object from the user's canvas
    }

Server code

Here’s the server code. Right now, we’re simply using it to authenticate the user with Pusher so they can send client events:

    // server/server.js
    var express = require("express");
    var bodyParser = require("body-parser");
    var Pusher = require("pusher");
    const cors = require("cors");

    require("dotenv").config();

    var app = express();
    app.use(bodyParser.json());
    app.use(bodyParser.urlencoded({ extended: false }));
    app.use(cors());

    var pusher = new Pusher({
      appId: process.env.APP_ID,
      key: process.env.APP_KEY,
      secret: process.env.APP_SECRET,
      cluster: process.env.APP_CLUSTER
    });

    // for checking if the server works
    app.get("/", (req, res) => {
      res.send("all good...");
    });

    app.post("/pusher/auth", (req, res) => {
      const socketId = req.body.socket_id;
      const channel = req.body.channel_name;
      console.log("authing...");
      var auth = pusher.authenticate(socketId, channel);
      return res.send(auth);
    });

    var port = process.env.PORT || 5000;
    app.listen(port);

Don’t forget to update the server/.env file to include your Pusher app credentials:

    APP_ID="YOUR PUSHER APP ID"
    APP_KEY="YOUR PUSHER APP KEY"
    APP_SECRET="YOUR PUSHER APP SECRET"
    APP_CLUSTER="YOUR PUSHER APP CLUSTER"

Running the app

At this point, you can now run the app. Start by running the server:

    cd server
    node server.js

Optionally, you can use ngrok so you can test the app with someone outside your network. Don’t forget to update the base URL in the login screen if you do so:

    // src/screens/Login.js
    const BASE_URL = "http://localhost:5000";

Finally, run the app itself:

    yarn start
    yarn electron-dev

Conclusion

In this tutorial, we learned how to use FabricJS and React Sketch library to create a whiteboard app in React. Stay tuned for part two where we add a group chat to the whiteboard so the users can talk about what they’re trying to sketch.

You can find the code for this tutorial on its GitHub repo.

Clone the project repository
  • Chat
  • Collaboration
  • JavaScript
  • Live UX
  • Node.js
  • React
  • Social
  • Channels

Products

  • Channels
  • Chatkit
  • Beams

© 2019 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.