Skip to content

Using Canvas to Create a Doodle Pad Gutenberg Block (Part 2 of 2)

In our last post we set up a build environment and got our MyCanvas component loading in the editor.  My plans for this post is to get this block functioning as a very simple doodle pad.

This is a long post.  I don’t mind this though, because I want it to represent how I work through these kinds of processes.  I hope it’s helpful.

If you want to pick up right here, here is the repo from part 1: https://github.com/JimSchofield/guty-paint-tutorial/tree/master

How we will store data

The canvas element is a great way to draw 2d graphics.  We’re going to be using it to render our the doodling that the user can do.  The downside is that we need to store all of the X-Y points that the user clicks on the canvas.

On top of that, we need a way to store those points on the DOM so that when the view is loaded, the view can pick those points up again and render out the canvas stuffs on the view.

So here is our strategy: We’re going to store data in attributes as our single source of truth.  BUT we will also be saving points as a data attribute in our save() function.  Our goal is to be able to reuse MyCanvas in the editor and in the front end view.

Let’s actually start building toward that…

Creating a clickable canvas

Let’s have our MyCanvas component render out a canvas.  We want React to re-render the canvas whenever data is updated, so we have to set up an update function that should run when data is updated…

import React from 'react';

export default class MyCanvas extends React.Component {

    // Whenever component mounts or update, re-render the canvas
    componentDidMount() {
        this.updateCanvas();
    }
    componentDidUpdate() {
        this.updateCanvas();
    }

    // This should always re-render the canvas as if its a blank slate
    // This means we need to store a "history" of all strokes that the user
    // creates in the block
    updateCanvas() {
        // Find the width/height of the canvas to use later
        const width = this.refs.canvas.width;
        const height = this.refs.canvas.height;

        // We're going to access the canvas as a ref so we can do things directly
        // to the canvas.  If you aren't familiar with context, take a look at
        // a canvas tutorial.  Basically, it's what you do most of your
        // painting and work on
        const canvas = this.refs.canvas;
        const ctx = canvas.getContext("2d");

        // We're going to start by filling the canvas with orange
        ctx.fillStyle = 'orange'
        ctx.fillRect(0, 0, width, height);
    }

    render() {
        return (
            <canvas 
                width="400"
                height="400"
                ref="canvas"/>
        )
    }
}

Okay, so we have an orange box.  Yay!

Collecting user created paths

Alright, so it’s going to go something like this:

  1. The user clicks and holds down on the canvas (we triggers an ‘isDrawing’ boolean)
  2. As the user moves, if the ‘isDrawing’ boolean is turned on, we will collect the coordinates of the moves in an array
  3. On mouse up, we check to make sure there was more than one point, and then we save the array as a “stroke” in Gutenberg attributes.
  4. We pass the stroke list to the canvas component to render the stroke.

Easier said than done.  Let’s do this:

1 – On user mouse down

Now we need state- a temporary place to store coordinates until we save the coordinates as a stroke.  We also need to toggle ‘isDrawing’ when someone clicks down on the canvas.  So let’s add these things to MyCanvas.js

constructor(props) {
    super(props);

    this.state = {
        isDrawing: false
    }

    // need to bind this to the method so we can do
    // things like 'setState'
    this.startDrawing = this.startDrawing.bind(this);
}

...

startDrawing() {
    this.setState({ isDrawing: true });
}

render() {
    return (
        <canvas 
            width="400"
            height="400"
            ref="canvas"
            onMouseDown={startDrawing}
            />
    )
}

2 – Collecting coordinates

Now we have to start collecting mouse positions and end the drawing on mouse up.  Next, let’s just console log the (x,y) points on mouse move, and then turn off ‘isDrawing’ on mouse up.

// need to bind this to the method so we can do
// things like 'setState'
this.startDrawing = this.startDrawing.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.endDrawing = this.endDrawing.bind(this);

...

startDrawing() {
    this.setState({ isDrawing: true });
}

handleMouseMove(event) {
    if (this.state.isDrawing) {
        console.log(event);
    }
}

endDrawing() {
    this.setState({ isDrawing: false });
}

render() {
    return (
        <canvas 
            width="400"
            height="400"
            ref="canvas"
            onMouseDown={this.startDrawing}
            onMouseMove={this.handleMouseMove}
            onMouseUp={this.endDrawing}
            />
    )
}

Cool.  Now, if you load the block in the editor and click-drag on the canvas, you will start to see the event being logged to the window.

Let’s tweak the mouse move method a little bit so that we only log the x/y position in relation to the canvas element.  Remember that (x,y) given in an event is from the top and from the left of the object.

handleMouseMove(event) {
    if (this.state.isDrawing) {
        console.log(`${event.clientX},${event.clientY}`);
    }
}

Now if you click drag you can see `x,y` points being printed out.  The problem, though, is that if you go to the upper left of the orange box you should see 0,0, but I see instead something like 310,267.  The x,y we’re getting is in relation to the client viewport.  So the solution is to get the left/top dimensions of the canvas, and subtract those away.  Remember, we only want the x,y points in relation to the top left of our canvas.

handleMouseMove(event) {
    if (this.state.isDrawing) {
        const domRect = event.target.getBoundingClientRect();
        const top = domRect.top;
        const left = domRect.left;
        const point = { x: event.pageX - left, y: event.pageY - top };
        console.log(point);
    }
}

Now if you click and drag near the upper right corner you should see numbers close to 0,0.  Ahhhhh, very nice.

3 – Saving the stroke as an attribute

First, we need to be collecting these points as an array of x,y coordinates, so quickly…

handleMouseMove(event) {
    if (this.state.isDrawing) {
        const domRect = event.target.getBoundingClientRect();
        const top = domRect.top;
        const left = domRect.left;
        const point = { x: event.pageX - left, y: event.pageY - top };
        
        // Merge current point with previous points
        this.setState({
            inProgressPath: [...this.state.inProgressPath, point],
        })
    }
}

endDrawing() {
    this.setState({ isDrawing: false });
    console.log(this.state.inProgressPath);
}

If you’re following this far, you should get one array printed out after you click and drag on the canvas for a bit.

That is the array of points we need to save to attributes.  We will create an ‘addStroke’ function in our gutenberg block (index.js) and pass this function to MyCanvas to use when the drawing is ended.  While we’re doing this, we will create a ‘strokeList’ attribute to save all the strokes.

import MyCanvas from './MyCanvas'; // new!

const { registerBlockType } = wp.blocks;

registerBlockType("guty-paint/block", {
    title: "Guty Paint",
    icon: "admin-customizer",
    category: "common",

    attributes: {
        strokeList: {
            type: Array,
            default: []
        }
    },

    edit(props) {

        const { setAttributes } = props;
        const { strokeList } = props.attributes;

        function addStroke(stroke) {
            setAttributes({
                strokeList: [...strokeList, stroke],
            });
        }

        return (
            <MyCanvas
                addStroke={addStroke}
                strokeList={strokeList}
                />
        );
    },

    save(props) {
        return (
            <MyCanvas />
        );
    }
});

Now ‘strokeList’ is accessible as a prop in MyCanvas and we can trigger adding a stroke because addStroke is available as a prop as well!  Let’s store the stroke and reset the inProgressPath when we do:

...
endDrawing() {
    this.props.addStroke(this.state.inProgressPath);
    this.setState({ isDrawing: false, inProgressPath: [] }, () =>
        console.log(this.props.strokeList)
    );
}
}
...

(I did the console log callback just so we can see the props after state updates.  I’m not going to keep that.)  So, if you save and load the block again, after you click drag on the canvas, you should see an array of points- our stroke- show up in your console.  And rest assured, we have a stroke added to our attributes in our Gutenberg block and it will be saved with the block!  Now!  Let’s get those strokes actually painting on our canvas!

4 – Rendering all the strokes!

Cool, so what we need to do now is render strokes in MyCanvas if there are strokes to render.  In MyCanvas.js:

// inside updateCanvas() ...
// We're going to start by filling the canvas with orange
ctx.fillStyle = "orange";
ctx.fillRect(0, 0, width, height);

// draw all the strokes
this.props.strokeList.forEach(stroke => {
    console.log(stroke);
    this.drawStrokes(stroke, ctx);
});
...

// as its  own method in MyCanvas...

drawStrokes(strokes, context) {
    const firstPoint = strokes[0];
    context.moveTo(firstPoint.x, firstPoint.y);

    for (let index = 1; index < strokes.length; index++) {
        const nextPoint = strokes[index];
        context.lineTo(nextPoint.x, nextPoint.y);
    }

    context.stroke();
}

Now, if you click drag and release, you should see a path painted on your canvas!

Satisfying, no?

Okay, so that’s cool, but you may have noticed that while you’re drawing you don’t see the path.  The solution is actually pretty easy, let’s add another little bit to our updateCanvas method.

...
// draw any stroke that is in progress
if (this.state.isDrawing && this.state.inProgressPath.length > 1) {
    this.drawStrokes(this.state.inProgressPath,ctx);
}
...

So why the ‘this.state.inProgressPath.length > 1’ thing?  If we want to draw a line, we need to have at least two points, otherwise our drawStrokes method will error!

Hopefully now You can see those strokes as you draw them:

Getting this to show up on the front end

Okay, so sweet: we have a block loading and if you hit “update” it reloads if you refresh the editor.  You may have already gone to view the post and noticed that nothing happens.

In fact, you will see a <canvas> element being rendered, but nothing is being painted on it.  That’s because we haven’t yet set up our React component MyCanvas to run, and we need to pass the paths to the front end so that MyCanvas knows what to paint.

Setting up MyCanvas to run on the front end

The problem is that the save() method in Gutenberg blocks is only meant to render static html.  So while our MyCanvas element is run when we save, it’s never called when we look at the front end.

We’re going to save the strokeList as a data attribute on a div, and then manually load MyCanvas through a front end script.

First, to save the strokeList as props

let’s change index.js save() method to this:

save(props) {

    const { strokeList } = props.attributes;

    return (
        <div 
            class="paint-me"
            data-stroke-list={JSON.stringify(strokeList)}>
        </div>
    );
}

Now if you reload the paint-me block and save, when you inspect the post markup you should see a long array of points saved in the data-stroke-list value.

Second, enqueue a front-end script

I call these view scripts.  It will only run on the front end.  Let’s add ‘paint-me.view.js’ in the src folder.

console.log('hi on the front end!');

We’re going to add a separate webpack config so that this file is still compiled, but it will be treated separately since it should only be added on the front end.

// A node thingy that helps with paths... not sure on specifics
const path = require("path");

// Config for regular blocks
module.exports = [
    { //config for regular block scripts
        // entry points to the main js file for our build
        entry: "./assets/src/index.js",
        // and output lets us specify where it should be built to
        output: {
            path: path.resolve(__dirname, "./assets/dist"),
            filename: "build.js"
        },
        module: {
            rules: [
                {
                    // If Webpack sees a .js file, use babel when it builds
                    test: /\.js$/,
                    exclude: /node_modules/,
                    loader: "babel-loader"
                }
            ]
        },
        stats: {
            // Pretty colors in messages!
            colors: true
        },
        externals: {
            // We don't want webpack to bundle React in our files (that would make our
            // files huge!) so we say that whenever a file requires "React",
            // Webpack won't bundle it.  We're promising, though, that we're providing
            // React somewhere else (maybe a CDN or on the window)
            // so luckily we can through WordPress.  React is in core now, yay!
            react: "React"
        }
    }, // Config for view scripts
    {
        entry: "./assets/src/paint-me.view.js",
        output: {
            path: path.resolve(__dirname, "assets/dist"),
            filename: "build.view.js"
        },
        module: {
            rules: [
                {
                    test: /.js/,
                    loader: "babel-loader"
                }
            ]
        },
        stats: {
            colors: true
        },
        externals: {
            react: "React",
            "react-dom": "ReactDOM"
        }
    }
];

And now we’re going to add to the guty-paint.php file to enqueue the newly build javascript.

...

/**
 * Enqueue view scripts
 */
function guty_paint_plugin_view_scripts() {
    if ( is_admin() ) {
        return;
    }

    wp_enqueue_script(
		'guty-paint/view-scripts',
		plugins_url( '/assets/dist/build.view.js', __FILE__ ),
        array( 'wp-blocks', 'wp-element', 'react', 'react-dom' )
    );
}

add_action( 'enqueue_block_assets', 'guty_paint_plugin_view_scripts' );

If you reload the post, you should see our new Javascript.  It should only run when viewing the site.

Third and last step: pick up the props and run MyCanvas

Instead of explaining everything, I’m going to annotate the view script below

// Even though they're not bundled, they need to be imported
import React from 'react';
import ReactDOM from 'react-dom';

// Import our MyCanvas to call on later
import MyCanvas from './MyCanvas';

ready(() => {
    // There may be many of these, so query them all
    const containers = document.querySelectorAll(".paint-me");
    // turn into array instead of node list
    const containersArray = Array.prototype.slice.call(containers);

    containersArray.forEach((element) => {
        // get the props from this div
        const strokeList = JSON.parse(element.dataset.strokeList);

        // Call react!
        ReactDOM.render(
            <MyCanvas strokeList={strokeList} />, // call MyCanvas and pass the strokeList as props
            element // need to specify the element to render on
        )
    })
});

// Thank you http://youmightnotneedjquery.com/
// Very much like $.ready() from jQuery
function ready(fn) {
    if (
        document.attachEvent
            ? document.readyState === "complete"
            : document.readyState !== "loading"
    ) {
        fn();
    } else {
        document.addEventListener("DOMContentLoaded", fn);
    }
}

If all goes well, you can load your post, and you will see your block rendering nicely!

Reflections

A few things:

I tried to sprint to the end of a minimum “viable” block.  My main goals were to show how to use the canvas element, and how to run a block that calls components in the editor and the post view.

The completed block source code is located in a repo here: https://github.com/JimSchofield/paint-me-tutorial

In my guty-paint example from my first post I added a bunch of features, like stroke color, stroke width, background color, etc.  Take a look there if you want to extend this block further.

Also, there are some bugs with this block that we don’t have time to address right now.  for example, if you drag off the canvas you won’t trigger the end stroke method. Also, I never turned off the addStroke functionality when the block is loaded in the post, so if you click on the post you will see console logs and errors.  I suggest taking a look at my more advanced block https://github.com/JimSchofield/paint-me-gutenberg-block to see how I overcame some of these things.

I know that this was a lot and this was a long post.  I feel, though, that the strategies used here are very useful for loading live javascript on the front end.  I think there will be a lot of work done to make it easy to edit a block in the editor, and then run a block on the front end.

There may be a much easier way.  I’m going to try to redo this block using SVG instead.  With SVG we would be able to render out the lines without calling a view script on the front end.

Please let me know what you think in the comments, or let me know on twitter

Leave a Reply

Your email address will not be published. Required fields are marked *