HTML Canvas является частью спецификации HTML, которая позволяет разработчикам легко добавлять графику и интерактивность в свои приложения.

На холсте HTML вы можете добавлять изображения, текст и фигуры с разными цветами, заливками и градиентами. Он поддерживается почти всеми последними браузерами.

Чтобы добавить что-то на холст, вы сначала добавляете на страницу элемент canvas. Затем вы можете добавить на холст линии нужной формы.

Например, чтобы добавить линию и кружок к элементу canvas, в файле HTML выполните следующие действия:

<canvas id="canvas" width="200" height="100" style="border:2px solid #000000;">
</canvas>

Затем в своем файле JavaScript вы делаете следующее:

let c = document.getElementById("canvas");
let ctx = c.getContext("2d");
ctx.moveTo(0, 0);
ctx.lineTo(50, 100);
ctx.stroke();
ctx.beginPath();
ctx.arc(1, 50, 40, 0, 2 * Math.PI);
ctx.stroke();

Если вы захотите добавить что-то более сложное, это будет сложно сделать.

Таким образом, библиотека Конва позволяет абстрагироваться от тяжелой работы по добавлению элементов на холст. Он позволяет добавлять гораздо больше фигур, просто написав несколько строк кода. Также существуют привязки React для Konva, в которых есть рефераты с дополнительными функциями. Однако набор функций React Konva довольно ограничен, поэтому для удовлетворения требований большинства приложений React Konva следует использовать в качестве дополнения к Konva.

Вы также можете позволить пользователям легко перемещать и преобразовывать ваши формы, что вам пришлось бы написать самостоятельно, если бы вы захотели сделать это в HTML Canvas API.

Konva работает, создавая сцену и слой на сцене, что позволяет добавлять нужные линии, фигуры и текст.

В этой статье мы создадим приложение для интерактивной доски, которое позволит пользователям добавлять на доску формы, текст, линии и изображения. Кроме того, пользователи могут отменить свою работу и стереть информацию с экрана.

Начиная

Для начала мы создадим приложение React с программой командной строки Create React App. Запустите npx create-react-app whiteboard-app, чтобы создать начальные файлы для нашего приложения.

Далее нам нужно добавить несколько пакетов. Мы хотим использовать Bootstrap для стилизации в дополнение к пакетам Konva и вспомогательному пакету для создания уникальных идентификаторов для наших фигур, линий и текста. Также нам понадобится React Router для маршрутизации.

Для установки библиотек запускаем npm i bootstrap react-bootstrap konva react-konva react-router-dom use-image uuid.

use-image - это пакет для преобразования URL-адресов изображений в объекты изображений, которые могут отображаться на холсте. Пакет UUID генерирует уникальные идентификаторы для наших фигур.

Установив пакеты, мы можем приступить к написанию кода. Во-первых, мы начнем с точки входа нашего приложения, которой является App.js. Замените существующий код файла на:

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;

Все, что мы добавили, - это единственный маршрут, который у нас есть, а именно домашняя страница и верхняя панель.

Затем мы добавляем код для фигур. В React Konva есть библиотеки для обычных форм, таких как прямоугольники и круги. Начнем с круга. В папке src
создайте файл с именем Circle.js и добавьте:

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;

Этот код возвращает фигуру Circle, которую можно добавить на холст по нашему желанию. В функции обратного вызова React.useEffect мы можем определить, выбрана ли форма, а затем нарисовать маркер для формы, чтобы ее можно было изменять размер и перемещать.

Компонент в операторе return является основным кодом для Circle. У нас есть onClick обработчик, который получает идентификатор выбранной формы.

draggable prop делает Circle перетаскиваемым.

onDragEnd обрабатывает событие, когда пользователь прекращает перетаскивание. Позиция там обновляется.

onTransformEnd масштабирует форму, когда пользователь перетаскивает доступные ручки. width и height изменяются при перетаскивании маркеров.

{isSelected && <Transformer ref={trRef} />} создает объект Transformer, который является объектом Konva, который позволяет вам изменять размер фигуры, когда вы выбираете ее, если вы присоединяете ее к фигуре.

Далее мы добавляем компонент для изображения. Создайте файл с именем Image.js в папке src и добавьте следующее:

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) {
      // we need to attach transformer manually
      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;

Это очень похоже на компонент Circle, за исключением того, что у нас есть функция useImage, предоставляемая библиотекой use-image для преобразования заданного imageUrl свойства в изображение, которое будет отображаться на холсте.

Далее создаем свободную линию рисования. Создайте файл с именем line.js в папке src и добавьте:

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();
  });
};

В этом файле мы используем простой Konva, поскольку в React Konva нет удобного способа создать свободную линию рисования, когда пользователь перетаскивает мышь и рисует линию в произвольной форме. Когда срабатывают mousedown и touchstart, мы устанавливаем цвет линии в зависимости от того, что такое mode. Когда это brush, мы рисуем красную линию. Если это erase, мы рисуем толстую белую линию, чтобы пользователи могли нарисовать ее поверх своего контента, позволяя пользователям стирать свои изменения.

Когда запускаются события mousemove и touchend, мы устанавливаем для isPaint значение false, поэтому мы прекращаем рисование линии. Когда запускаются события mousemove и touchmove, мы добавляем точки по пути, чтобы нарисовать линию в желаемом направлении, когда пользователь перемещает мышь при нажатии или касании сенсорного экрана.

Затем мы создаем компонент Rectangle для рисования прямоугольников произвольной формы. В папке src создайте файл с именем Rectangle.js и добавьте:

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;

Этот компонент похож на компонент Circle.

У нас есть ручки перетаскивания, чтобы перемещать и изменять размер прямоугольника, добавляя обратные вызовы onDragEnd и onTransformEnd. Мы также можем изменить координаты x и y в обработчике onDragEnd и изменить width и height в обратном вызове события onTransformEnd.

Компонент Transformer добавляется, если фигура выбрана, поэтому пользователи могут перемещать или изменять размер фигуры с помощью маркеров при выборе.

Затем мы добавляем компонент текстового поля, чтобы пользователи могли добавлять текст на доску. Создайте файл с именем textNode.js и добавьте следующее:

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;
};

Мы добавляем текстовую область, а затем обрабатываем события, созданные текстовой областью. Когда пользователь щелкает текстовую область, отображается поле с ручками, позволяющее пользователю перемещать текстовую область по холсту. Это то, что делает обработчик click для этапа. Он находит текстовую область по идентификатору, а затем прикрепляет к ней KonvaTransformer, добавляя поле с ручками, позволяющее пользователям перемещать и изменять размер текстовой области.

У нас есть transform обработчик для textNode текстовой области, позволяющий изменять размер текстовой области, когда пользователь перетаскивает ручки. У нас есть обработчик двойного щелчка, позволяющий пользователям вводить текст при двойном щелчке. Большая часть кода предназначена для стилизации текстового поля как можно ближе к холсту, чтобы оно сливалось с холстом. Иначе это будет выглядеть странно. Мы также позволяем пользователям вращать текстовую область, применяя CSS для поворота текстовой области, когда пользователь перетаскивает ручки.

В обработчике событий keydown мы изменяем размер текстовой области по мере ввода пользователем, чтобы обеспечить отображение всего текста без прокрутки.

Когда пользователь щелкает мышью за пределами текстовой области, поле с маркерами исчезает, позволяя пользователю выбирать другие элементы.

На домашней странице мы все собрали. Создайте новый файл с именем HomePage.js в папке src и добавьте:

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;

Здесь мы добавляем кнопки, которые добавляют фигуры, когда пользователь нажимает на них. Для форм, предоставляемых React Konva, мы добавляем формы, добавляя объект в массив для формы. Затем мы сопоставляем их с формой со свойствами, указанными в объекте.

Например, чтобы добавить прямоугольник, мы создаем объект и добавляем его в массив, вызывая нажатие на объект. Затем мы вызываем setRectangles и сопоставляем его с фактическим компонентом Rectangle при рендеринге холста. Мы передаем onSelecthandler, чтобы пользователи получали идентификатор выбранной формы, когда нажимают на нее. Обработчик onChange позволяет нам установить обновление свойств существующей фигуры, а затем обновить соответствующий массив для фигур.

Каждая добавляемая фигура React Konva должна находиться внутри компонента Layer. Этот компонент обеспечивает место для форм. Компонент Stage предоставляет место для Layer.

В компоненте Stage у нас есть обработчик onMouseDown для отмены выделения всех фигур, когда щелчок находится за пределами всех фигур.

При нажатии кнопки отмены из массива удаляется последняя фигура, а также соответствующий массив фигур. Например, если при отмене удаляется прямоугольник, он будет удален из массива shapes и массива rectangle. Массив shapes - это массив идентификаторов всех фигур.

Точно так же, чтобы пользователи могли удалять фигуры, когда фигура выбирается с помощью клавиши удаления, мы добавляем обработчик нажатия клавиши. В функции обработчика нажатия клавиши, когда запускается событие клавиши удаления, обработчик найдет фигуры в массивах по идентификатору и удалит их. Он также удалит его из массива shapes.

Мы определили функцию forceUpdate, поэтому холст будет обновляться, даже если манипуляции с DOM выполняются вне React. Обработчик keydown добавляется с использованием document.addEventListener, который не является кодом React, поэтому нам нужно вызвать forceUpdate для повторного рендеринга в соответствии с новыми состояниями.

Наконец, чтобы закончить, мы добавляем верхнюю планку. Создайте файл с именем TopBar.js в папке src и добавьте:

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);

Компонент Navbar предоставляется React Boostrap.

После того, как все работы проделаны, у нас есть доска, на которой можно рисовать.