Categories
React

Use React Refs to Manipulate the DOM and Konva to Add Graphics

Spread the love

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(); 
    let textPosition = textNode.absolutePosition();
    let stageBox = stage.container().getBoundingClientRect();
    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);
    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";
 
    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.

<img class="ds t u hd ak" src="https://miro.medium.com/max/2726/1*olGeWQXMj-a2LY-xH5If3w.png" width="1363" height="625" role="presentation"/>

By John Au-Yeung

Web developer specializing in React, Vue, and front end development.

Leave a Reply

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