Check out my books on Amazon at https://www.amazon.com/John-Au-Yeung/e/B08FT5NT62
Subscribe to my email list now at http://jauyeung.net/subscribe/
React is a flexible framework that provides structured UI code while allowing the flexibility to manipulate DOM elements directly. All React components can be accessed by their refs. A React ref
provides access to the underlying HTML DOM element that you can manipulate directly.
To use refs, we use the React.createRef
function the useRef
hook to create a ref
object and then assign it to a variable. he variable is set as the value of the ref
prop.
For example, we define a ref with:
const inputRef = React.createRef();
Then in the input
element, we add the following:
<input type="text" ref={inputRef} />
Then we can access the input element by adding:
inputRef.current
This provides access to the HTML element, and we can utilize native DOM functionality. In our app, we will use the useRef
hook, and you will see how to implement that below.
What we are building
In this article, we will build a whiteboard app that allows users to add shapes, texts, lines, and images to a whiteboard. In addition, users can undo their work and erase stuff from the screen.
We use the Konva library to let us add the shapes/text and erase them. The Konva library abstracts the hard work of adding items to the canvas. It allows you to add many shapes by simply writing a few lines of code. There are also React bindings for Konva, which abstracts some functionality even further for React. However, the feature set of React Konva is rather limited, so in order to meet the requirements of most apps, React Konva should be used as a companion to Konva.
We also want to allow users to move and transform your shapes easily, which you would have to write yourself if you wanted to do it in the HTML Canvas API.
Konva works by creating a stage and a layer in the stage which will allow you to add the lines, shapes, and text that you want.
Getting started
To start, we will create a React app with the Create React App command line program. Run npx create-react-app whiteboard-app
to create the initial files for our app. Next, we need to add some packages. We want to use Bootstrap for styling, in addition to the Konva packages, and helper package for creating unique IDs for our shapes, lines, and text. We also need React Router for routing.
To install the libraries, we run:
npm i bootstrap react-bootstrap konva react-konva react-router-dom use-image uuid
use-image
is a package to convert image URLs into image objects that can be displayed on canvas. The UUID package generates unique IDs for our shapes.
With the packages installed, we can start writing code. First we start with the entry point of our app, which is App.js
. Replace the existing code of the file with:
import React from "react";
import { Router, Route, Link } from "react-router-dom";
import HomePage from "./HomePage";
import TopBar from "./TopBar";
import { createBrowserHistory as createHistory } from "history";
import "./App.css";
const history = createHistory();
function App() {
return (
<div className="App">
<Router history={history}>
<TopBar />
<Route path="/" exact component={HomePage} />
</Router>
</div>
);
}
export default App;
All we added is a top navigation bar and our only route which is the home page.
Next we add the code for the shapes. React Konva has libraries for common shapes like rectangles and circles. We first start with a circle. In the src
folder, create a file called Circle.js
and add:
import React from "react";
import { Circle, Transformer } from "react-konva";
const Circ = ({ shapeProps, isSelected, onSelect, onChange }) => {
const shapeRef = React.useRef();
const trRef = React.useRef();
React.useEffect(() => {
if (isSelected) {
trRef.current.setNode(shapeRef.current);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
return (
<React.Fragment>
<Circle
onClick={onSelect}
ref={shapeRef}
{...shapeProps}
draggable
onDragEnd={e => {
onChange({
...shapeProps,
x: e.target.x(),
y: e.target.y(),
});
}}
onTransformEnd={e => {
// transformer is changing scale
const node = shapeRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
onChange({
...shapeProps,
x: node.x(),
y: node.y(),
width: node.width() * scaleX,
height: node.height() * scaleY,
});
}}
/>
{isSelected && <Transformer ref={trRef} />}
</React.Fragment>
);
};
export default Circ;
This code returns the Circle shape which can be added onto the canvas at will. In the React.useEffect
’s callback function, we detect if the shape is selected and then draw a handle for the shape so that it can be resized and moved.
In this file, we added refs to the Circle
component so that we can access it in the useEffect
callback. The setNode
function takes an HTML DOM element.
The component in the return
statement is the main code for the Circle
. We have an onClick
handler that gets the ID of the selected shape. The draggable
prop makes the Circle draggable. onDragEnd
handles the event when the user stops dragging. The position is updated there. onTransformEnd
scales the shape as the user drags the handles that are available. The width
and height
are changed as the handles are dragged. {isSelected && <Transformer ref={trRef} />}
create the Transformer
object, which is a Konva object that lets you change the size of a shape when you select.
Next we add a component for the image. Create a file called Image.js
in the src
folder and add the following:
import React from "react";
import { Image, Transformer } from "react-konva";
import useImage from "use-image";
const Img = ({ shapeProps, isSelected, onSelect, onChange, imageUrl }) => {
const shapeRef = React.useRef();
const trRef = React.useRef();
const [image] = useImage(imageUrl);
React.useEffect(() => {
if (isSelected) {
trRef.current.setNode(shapeRef.current);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
return (
<React.Fragment>
<Image
onClick={onSelect}
image={image}
ref={shapeRef}
draggable
onDragEnd={e => {
onChange({
...shapeProps,
x: e.target.x(),
y: e.target.y(),
});
}}
onTransformEnd={e => {
const node = shapeRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
onChange({
...shapeProps,
x: node.x(),
y: node.y(),
width: node.width() * scaleX,
height: node.height() * scaleY,
});
}}
/>
{isSelected && <Transformer ref={trRef} />}
</React.Fragment>
);
};
export default Img;
This is very similar to the Circle
component except we have the useImage
function provided by the use-image
library to convert the given imageUrl
prop to an image that is displayed on the canvas.
In this file, we added refs to the Image
component so that we can access it in the useEffect
callback. The setNode
function takes an HTML DOM element.
Next we create a free drawing line. Create a file called line.js
in the src
folder and add:
import Konva from "konva";
export const addLine = (stage, layer, mode = "brush") => {
let isPaint = false;
let lastLine; stage.on("mousedown touchstart", function(e) {
isPaint = true;
let pos = stage.getPointerPosition();
lastLine = new Konva.Line({
stroke: mode == "brush" ? "red" : "white",
strokeWidth: mode == "brush" ? 5 : 20,
globalCompositeOperation:
mode === "brush" ? "source-over" : "destination-out",
points: [pos.x, pos.y],
draggable: mode == "brush",
});
layer.add(lastLine);
});
stage.on("mouseup touchend", function() {
isPaint = false;
});
stage.on("mousemove touchmove", function() {
if (!isPaint) {
return;
} const pos = stage.getPointerPosition();
let newPoints = lastLine.points().concat(\[pos.x, pos.y\]);
lastLine.points(newPoints);
layer.batchDraw();
});
};
In this file, we use plain Konva since React Konva does not have a convenient way to make free drawing a line where a user drags the mouse. When the mousedown
and touchstart
is triggered, we set the color of the line depending on what the mode
is. When it is brush
, we draw a red line. If it’s erase
we draw a thick white line so that users can draw it over their content, letting users erase their changes.
When the mousemove
and touchend
events are triggered, we set isPaint
to false so we stop drawing the line. When the mousemove
and touchmove
events are triggered, we add dots along the way to draw the line in the direction the user wants when the user moves the mouse when clicking or touching the touchscreen.
stage
and layer
are the Konva Stage and Layer objects which we pass in when the addLine
function is called.
Next we create the Rectangle component for drawing free form rectangles. In the src
folder, create a file called Rectangle.js
and add:
import React from "react";
import { Rect, Transformer } from "react-konva";
const Rectangle = ({ shapeProps, isSelected, onSelect, onChange }) => {
const shapeRef = React.useRef();
const trRef = React.useRef();
React.useEffect(() => {
if (isSelected) {
// we need to attach transformer manually
trRef.current.setNode(shapeRef.current);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
return (
<React.Fragment>
<Rect
onClick={onSelect}
ref={shapeRef}
{...shapeProps}
draggable
onDragEnd={e => {
onChange({
...shapeProps,
x: e.target.x(),
y: e.target.y(),
});
}}
onTransformEnd={e => {
// transformer is changing scale
const node = shapeRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
onChange({
...shapeProps,
x: node.x(),
y: node.y(),
width: node.width() * scaleX,
height: node.height() * scaleY,
});
}}
/>
{isSelected && <Transformer ref={trRef} />}
</React.Fragment>
);
};export default Rectangle;
This component is similar to Circle
component. We have the drag handles to move and resize the rectangle by adding the onDragEnd
and onTransformEnd
callbacks, change the x
and y
coordinates in the onDragEnd
handler, and change the width
and height
in the onTransformEnd
event callback.
The Transformer
component is added if the shape is selected so that users can move or resize the shape with the handles when selected.
Similar to the Circle
component, we added refs to the Rectangle
component so that we can access it in the useEffect
callback. The setNode
function takes an HTML DOM element.
Next we add a text
field component to let users can add text to the whiteboard. Create a file called textNode.js
and add the following:
import Konva from "konva";
const uuidv1 = require("uuid/v1");
export const addTextNode = (stage, layer) => {
const id = uuidv1();
const textNode = new Konva.Text({
text: "type here",
x: 50,
y: 80,
fontSize: 20,
draggable: true,
width: 200,
id,
});
layer.add(textNode); let tr = new Konva.Transformer({
node: textNode,
enabledAnchors: ["middle-left", "middle-right"],
// set minimum width of text
boundBoxFunc: function(oldBox, newBox) {
newBox.width = Math.max(30, newBox.width);
return newBox;
},
});
stage.on("click", function(e) {
if (!this.clickStartShape) {
return;
}
if (e.target._id == this.clickStartShape._id) {
layer.add(tr);
tr.attachTo(e.target);
layer.draw();
} else {
tr.detach();
layer.draw();
}
});
textNode.on("transform", function() {
// reset scale, so only with is changing by transformer
textNode.setAttrs({
width: textNode.width() \* textNode.scaleX(),
scaleX: 1,
});
});
layer.add(tr); layer.draw(); textNode.on("dblclick", () => {
// hide text node and transformer:
textNode.hide();
tr.hide();
layer.draw();
// create textarea over canvas with absolute position
// first we need to find position for textarea
// how to find it?
// at first lets find position of text node relative to the stage:
let textPosition = textNode.absolutePosition();
// then lets find position of stage container on the page:
let stageBox = stage.container().getBoundingClientRect();
// so position of textarea will be the sum of positions above:
let areaPosition = {
x: stageBox.left + textPosition.x,
y: stageBox.top + textPosition.y,
};
// create textarea and style it
let textarea = document.createElement("textarea");
document.body.appendChild(textarea);
// apply many styles to match text on canvas as close as possible
// remember that text rendering on canvas and on the textarea can be different
// and sometimes it is hard to make it 100% the same. But we will try...
textarea.value = textNode.text();
textarea.style.position = "absolute";
textarea.style.top = areaPosition.y + "px";
textarea.style.left = areaPosition.x + "px";
textarea.style.width = textNode.width() - textNode.padding() * 2 + "px";
textarea.style.height =
textNode.height() - textNode.padding() * 2 + 5 + "px";
textarea.style.fontSize = textNode.fontSize() + "px";
textarea.style.border = "none";
textarea.style.padding = "0px";
textarea.style.margin = "0px";
textarea.style.overflow = "hidden";
textarea.style.background = "none";
textarea.style.outline = "none";
textarea.style.resize = "none";
textarea.style.lineHeight = textNode.lineHeight();
textarea.style.fontFamily = textNode.fontFamily();
textarea.style.transformOrigin = "left top";
textarea.style.textAlign = textNode.align();
textarea.style.color = textNode.fill();
let rotation = textNode.rotation();
let transform = "";
if (rotation) {
transform += "rotateZ(" + rotation + "deg)";
} let px = 0;
let isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
if (isFirefox) {
px += 2 + Math.round(textNode.fontSize() / 20);
}
transform += "translateY(-" + px + "px)"; textarea.style.transform = transform;
textarea.style.height = "auto";
// after browsers resized it we can set actual value
textarea.style.height = textarea.scrollHeight + 3 + "px"; textarea.focus();
function removeTextarea() {
textarea.parentNode.removeChild(textarea);
window.removeEventListener("click", handleOutsideClick);
textNode.show();
tr.show();
tr.forceUpdate();
layer.draw();
}
function setTextareaWidth(newWidth) {
if (!newWidth) {
// set width for placeholder
newWidth = textNode.placeholder.length * textNode.fontSize();
}
// some extra fixes on different browsers
let isSafari = /^((?!chrome|android).)\*safari/i.test(navigator.userAgent);
let isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
if (isSafari || isFirefox) {
newWidth = Math.ceil(newWidth);
} let isEdge = document.documentMode || /Edge/.test(navigator.userAgent);
if (isEdge) {
newWidth += 1;
}
textarea.style.width = newWidth + "px";
}
textarea.addEventListener("keydown", function(e) {
// hide on enter
// but don't hide on shift + enter
if (e.keyCode === 13 && !e.shiftKey) {
textNode.text(textarea.value);
removeTextarea();
}
// on esc do not set value back to node
if (e.keyCode === 27) {
removeTextarea();
}
});
textarea.addEventListener("keydown", function(e) {
let scale = textNode.getAbsoluteScale().x;
setTextareaWidth(textNode.width() * scale);
textarea.style.height = "auto";
textarea.style.height =
textarea.scrollHeight + textNode.fontSize() + "px";
});
function handleOutsideClick(e) {
if (e.target !== textarea) {
removeTextarea();
}
}
setTimeout(() => {
window.addEventListener("click", handleOutsideClick);
});
});
return id;
};
We add a text area, and then we handle the events created by the text area. When the user clicks the text area, a box will with handles will be displayed to let the user move the text area around the canvas. This is what the click
handler for the stage is doing. It finds the text area by ID and then attaches a KonvaTransformer
to it, adding the box with handles.
We have a transform
handler for the textNode
text area to resize the text area when the user drags the handles. We have a double click handler to let users enter text when they double click. Most of the code is for styling the text box as close to the canvas as possible so that it will blend into the canvas. Otherwise, it will look strange. We also let users rotate the text area by applying CSS for rotating the text area as the user drags the handles.
In the keydown
event handler, we change the size of the text area as the user types to make sure it displays all the text without scrolling.
When the user clicks outside the text area, the box with handles will disappear, letting the user select other items.
The home page is where we put everything together. Create a new file called HomePage.js
in the src
folder and add:
import React, { useState, useRef } from "react";
import ButtonGroup from "react-bootstrap/ButtonGroup";
import Button from "react-bootstrap/Button";
import "./HomePage.css";
import { Stage, Layer } from "react-konva";
import Rectangle from "./Rectangle";
import Circle from "./Circle";
import { addLine } from "./line";
import { addTextNode } from "./textNode";
import Image from "./Image";
const uuidv1 = require("uuid/v1");
function HomePage() {
const [rectangles, setRectangles] = useState([]);
const [circles, setCircles] = useState([]);
const [images, setImages] = useState([]);
const [selectedId, selectShape] = useState(null);
const [shapes, setShapes] = useState([]);
const [, updateState] = React.useState();
const stageEl = React.createRef();
const layerEl = React.createRef();
const fileUploadEl = React.createRef();
const getRandomInt = max => {
return Math.floor(Math.random() * Math.floor(max));
};
const addRectangle = () => {
const rect = {
x: getRandomInt(100),
y: getRandomInt(100),
width: 100,
height: 100,
fill: "red",
id: `rect${rectangles.length + 1}`,
};
const rects = rectangles.concat(\[rect\]);
setRectangles(rects);
const shs = shapes.concat([`rect${rectangles.length + 1}`]);
setShapes(shs);
};
const addCircle = () => {
const circ = {
x: getRandomInt(100),
y: getRandomInt(100),
width: 100,
height: 100,
fill: "red",
id: `circ${circles.length + 1}`,
};
const circs = circles.concat([circ]);
setCircles(circs);
const shs = shapes.concat([`circ${circles.length + 1}`]);
setShapes(shs);
};
const drawLine = () => {
addLine(stageEl.current.getStage(), layerEl.current);
};
const eraseLine = () => {
addLine(stageEl.current.getStage(), layerEl.current, "erase");
};
const drawText = () => {
const id = addTextNode(stageEl.current.getStage(), layerEl.current);
const shs = shapes.concat([id]);
setShapes(shs);
};
const drawImage = () => {
fileUploadEl.current.click();
};
const forceUpdate = React.useCallback(() => updateState({}), []);
const fileChange = ev => {
let file = ev.target.files[0];
let reader = new FileReader();
reader.addEventListener(
"load",
() => {
const id = uuidv1();
images.push({
content: reader.result,
id,
});
setImages(images);
fileUploadEl.current.value = null;
shapes.push(id);
setShapes(shapes);
forceUpdate();
},
false
);
if (file) {
reader.readAsDataURL(file);
}
};
const undo = () => {
const lastId = shapes[shapes.length - 1];
let index = circles.findIndex(c => c.id == lastId);
if (index != -1) {
circles.splice(index, 1);
setCircles(circles);
}
index = rectangles.findIndex(r => r.id == lastId);
if (index != -1) {
rectangles.splice(index, 1);
setRectangles(rectangles);
}
index = images.findIndex(r => r.id == lastId);
if (index != -1) {
images.splice(index, 1);
setImages(images);
}
shapes.pop();
setShapes(shapes);
forceUpdate();
};
document.addEventListener("keydown", ev => {
if (ev.code == "Delete") {
let index = circles.findIndex(c => c.id == selectedId);
if (index != -1) {
circles.splice(index, 1);
setCircles(circles);
}
index = rectangles.findIndex(r => r.id == selectedId);
if (index != -1) {
rectangles.splice(index, 1);
setRectangles(rectangles);
}
index = images.findIndex(r => r.id == selectedId);
if (index != -1) {
images.splice(index, 1);
setImages(images);
}
forceUpdate();
}
});
return (
<div className="home-page">
<h1>Whiteboard</h1>
<ButtonGroup>
<Button variant="secondary" onClick={addRectangle}>
Rectangle
</Button>
<Button variant="secondary" onClick={addCircle}>
Circle
</Button>
<Button variant="secondary" onClick={drawLine}>
Line
</Button>
<Button variant="secondary" onClick={eraseLine}>
Erase
</Button>
<Button variant="secondary" onClick={drawText}>
Text
</Button>
<Button variant="secondary" onClick={drawImage}>
Image
</Button>
<Button variant="secondary" onClick={undo}>
Undo
</Button>
</ButtonGroup>
<input
style={{ display: "none" }}
type="file"
ref={fileUploadEl}
onChange={fileChange}
/>
<Stage
width={window.innerWidth * 0.9}
height={window.innerHeight - 150}
ref={stageEl}
onMouseDown={e => {
// deselect when clicked on empty area
const clickedOnEmpty = e.target === e.target.getStage();
if (clickedOnEmpty) {
selectShape(null);
}
}}
>
<Layer ref={layerEl}>
{rectangles.map((rect, i) => {
return (
<Rectangle
key={i}
shapeProps={rect}
isSelected={rect.id === selectedId}
onSelect={() => {
selectShape(rect.id);
}}
onChange={newAttrs => {
const rects = rectangles.slice();
rects[i] = newAttrs;
setRectangles(rects);
}}
/>
);
})}
{circles.map((circle, i) => {
return (
<Circle
key={i}
shapeProps={circle}
isSelected={circle.id === selectedId}
onSelect={() => {
selectShape(circle.id);
}}
onChange={newAttrs => {
const circs = circles.slice();
circs\[i\] = newAttrs;
setCircles(circs);
}}
/>
);
})}
{images.map((image, i) => {
return (
<Image
key={i}
imageUrl={image.content}
isSelected={image.id === selectedId}
onSelect={() => {
selectShape(image.id);
}}
onChange={newAttrs => {
const imgs = images.slice();
imgs[i] = newAttrs;
}}
/>
);
})}
</Layer>
</Stage>
</div>
);
}
export default HomePage;
This is where we add the buttons to create the shapes. For the shapes provided by React Konva, we create the shapes by adding an object to the array for the shape and then map them to the shape with the properties specified by the object.
For example, to add a rectangle, we create an object, add it to the array by pushing the object and then calling setRectangles
and then map them to the actual Rectangle
component when we render the canvas. We pass in the onSelect
handler so that the user can click on the shape and get the ID of the selected shape. The onChange
handler lets us update the properties of an existing shape and then update the corresponding array for the shapes.
Every React Konva shape we add should be inside the Layer
component. They provide the place for the shapes to reside. The Stage
component provides a place for the Layer
to be in. We set the ref
prop for the Stage
and Layer
components so that we can access it directly.
In the call to the addLine
function, we get the refs for the Stage
and Layer
components to get the reference to the Konva Stage and Layer instances so that we can use them in the addLine
function. Note that to get the Konva Stage object, we have to call setStage
after the the current
attribute.
In the Stage
component, we have a onMouseDown
handler to deselect all shapes when the click is outside all the shapes.
To the undo a change, we keep track of all the shapes with the shapes
array and then when the Undo button is clicked, then the last shape is removed from the array and also the corresponding shapes array. For example, if the undo removes a rectangle, then it will be removed from the shapes
array and the rectangle
array. The shapes
array is an array of IDs of all the shapes.
To build the image upload feature, we add an input element which we don’t show, and we use the ref
to write a function to let the user click on the hidden file input. Once the file input is clicked the user can choose files and the image is read with the FileReader
object into base64, which will be converted to an image displayed on the canvas with the use-image
library.
Similarly for letting users delete shapes when a shape is selected with the delete key, we add a key down handler. In the key down handler function, when a delete key event is triggered then the handler will find the shapes in the arrays by ID and delete it. It will also delete it from the shapes
array. We defined the forceUpdate
function so that the canvas will be updated even when there is DOM manipulation done without going through React. The keydown
handler is added by using document.addEventListener
which is not React code, so we need to call forceUpdate
to re-render according to the new states.
Finally, to finish it off, we add the top bar. Create a file called TopBar.js
in the src
folder and add:
import React from "react";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";
import NavDropdown from "react-bootstrap/NavDropdown";
import "./TopBar.css";
import { withRouter } from "react-router-dom";
function TopBar({ location }) {
const { pathname } = location;
return (
<Navbar bg="primary" expand="lg" variant="dark">
<Navbar.Brand href="#home">React Canvas App</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="mr-auto">
<Nav.Link href="/" active={pathname == "/"}>
Home
</Nav.Link>
</Nav>
</Navbar.Collapse>
</Navbar>
);
}
export default withRouter(TopBar);
The Navbar
component is provided by React Boostrap.
After all the work is done, we get a whiteboard that we can draw on.