javascript

Arbeiten mit Canvas: Ein Diagramm-Tool in ReactJS bauen

Soumyajit Pathak · 23. September 2020

Dieser Beitrag wurde aus dem englischen Original übersetzt.

In einem früheren Artikel haben wir darüber gesprochen, wie wichtig es ist, die Landschaft eines Integrationsprojekts mit diagrammatischen Darstellungen zu entwerfen. Die in jenem Artikel gezeigten Diagramme wurden mit dem INTEGRTR Digital Transformer erstellt, einer spezialisierten Software zum Erstellen von Diagrammen, die es einfacher denn je macht, komplexe Enterprise-Integrationslandschaften zu visualisieren.

Beim Bau dieses Tools haben wir mit zahlreichen Frameworks und Technologien experimentiert, bevor wir uns für ReactJS und HTML5 Canvas entschieden haben. Mit diesem praxisnahen Blogbeitrag möchten wir unsere Erkenntnisse und Erfahrungen aus dem Projekt mit allen teilen, die ähnliche Tools bauen oder verstehen möchten, wie HTML5 Canvas funktioniert. Wir beginnen bei den Grundlagen und bauen ein minimales Tool zum Erstellen von Diagrammen, um Sie auf diese Reise zu schicken.

Wir verwenden react für die Verwaltung unserer DOM-View-Schicht, konva.js (und dessen React-Bindings react-konva) für die Canvas-bezogene Logik sowie eine winzige State-Management-Bibliothek namens @halka/state für unseren Anwendungszustand.

HINWEIS: Wir setzen voraus, dass Sie mit React und React Hooks einigermaßen vertraut sind. Machen Sie sich um die anderen Bibliotheken keine Sorgen, wir erklären ihre Einsatzzwecke unterwegs.

Wenn Sie nur wegen des Quellcodes hier sind, können Sie direkt einsteigen: GitHub-Repo

Bevor wir loslegen, schauen wir uns an, was wir bauen werden:

Funktionen

  • Ein linkes Panel mit Formen, die wir per Drag-and-drop auf die Canvas in der Mitte ziehen können.
  • Wir können die Formen auf der Canvas auswählen, in der Größe ändern und rotieren.
  • Wir ändern Strichfarbe und Füllfarbe der Formen über das rechte Panel.
  • Den Zustand der Canvas beziehungsweise des Diagramms im LocalStorage des Browsers speichern.

Das war es für diese Runde.

HINWEIS: Ich verwende im gesamten Artikel yarn zum Ausführen von Skripten, Sie können aber ebenso npm nutzen. Ersetzen Sie yarn einfach durch die entsprechenden npm-Befehle.

Erste Schritte

Zum Einstieg:

  • Das Repo klonen: git clone https://github.com/integrtr/integrtr_diagrams.git
  • Die Abhängigkeiten installieren: yarn
  • Den Branch step-0 auschecken: git checkout step-0

An diesem Punkt handelt es sich um eine einfache React-App, die mit create-react-app aufgesetzt wurde. Wir haben alle nicht benötigten Dateien entfernt, etwa Tests, serviceWorker usw., und einige Annehmlichkeiten für schnellere Entwicklung aktiviert, zum Beispiel HMR. Um das Setup müssen Sie sich also nicht kümmern.

Wenn Sie den Dev-Server mit yarn start starten, sehen Sie unter localhost:3000 eine React-App mit dem Text "Let's get started".

Panels und Canvas hinzufügen

Wir fügen links die Komponente Palette hinzu, in der Mitte Canvas und rechts Inspector.

Alle drei Komponenten binden wir in die Basiskomponente App ein.

// App.js
import React from "react";

import { Palette } from "./Palette";
import { Canvas } from "./Canvas";
import { Inspector } from "./Inspector";

function App() {
  return (
    <div className="app">
      <Palette />
      <Canvas />
      <Inspector />
    </div>
  );
}

export default App;
// Palette.js
import React from "react";

import { DRAG_DATA_KEY, SHAPE_TYPES } from "./constants";

const handleDragStart = (event) => {
  const type = event.target.dataset.shape;

  if (type) {
    // x,y coordinates of the mouse pointer relative to the position of the padding edge of the target node
    const offsetX = event.nativeEvent.offsetX;
    const offsetY = event.nativeEvent.offsetY;

    // dimensions of the node on the browser
    const clientWidth = event.target.clientWidth;
    const clientHeight = event.target.clientHeight;

    const dragPayload = JSON.stringify({
      type,
      offsetX,
      offsetY,
      clientWidth,
      clientHeight,
    });

    event.nativeEvent.dataTransfer.setData(DRAG_DATA_KEY, dragPayload);
  }
};

export function Palette() {
  return (
    <aside className="palette">
      <h2>Shapes</h2>
      <div
        className="shape rectangle"
        data-shape={SHAPE_TYPES.RECT}
        draggable
        onDragStart={handleDragStart}
      />
      <div
        className="shape circle"
        data-shape={SHAPE_TYPES.CIRCLE}
        draggable
        onDragStart={handleDragStart}
      />
    </aside>
  );
}

Fügen wir nun zwei Formen in die Komponente Palette ein: ein Rechteck und einen Kreis. Wir machen sie draggable und hängen an jedes div außerdem ein data-shape-Attribut, damit wir in den Event-Handlern die jeweils gezogene Form identifizieren können.

Zum Schluss ergänzen wir den Event-Handler onDragStart an den div-Elementen. In unserem Handler handleDragStart nutzen wir die HTML5 Drag-and-Drop-API, um den Wert dataTransfer zu setzen und so die nötigen Daten weiterzugeben, die der Drop-Event-Listener am Container-Element der Canvas verwendet. Die übergebenen Datenpunkte sind der type der Form, die Werte offsetX und offsetY des Events sowie die Abmessungen des Palette-Knotens. Wir können nur String-Werte übergeben, deshalb müssen wir unser Payload-Objekt in einen String umwandeln.

HINWEIS: Wir verwenden hier bewusst das nativeEvent des Browsers, um die dataTransfer-API nutzen zu können. Der Grund ist, dass React Synthetic Events verwendet.

Nun fügen wir unsere Canvas-Komponente hinzu.

// Canvas.js
import React from "react";
import { Layer, Stage } from "react-konva";

export function Canvas() {

  return (
    <main className="canvas">
      <Stage
        width={window.innerWidth - 400}
        height={window.innerHeight}
      >
        <Layer />
      </Stage>
    </main>
  );
}

Die Komponente Stage aus react-konva ist dafür verantwortlich, das canvas-Element ins DOM zu rendern. Wir setzen Höhe und Breite anhand der inneren Abmessungen des Fensters. Wir ziehen 400px ab, um die Panels auf beiden Seiten zu berücksichtigen.

Fügen wir schließlich das rechte Panel hinzu, also die Komponente Inspector. Wir setzen hier vorerst nur etwas Platzhaltertext ein.

// PropertiesPanel.js
import React from "react";

export function PropertiesPanel() {
  return (
    <aside className="panel">
      <h2>Properties</h2>
      <div className="properties">
        <div className="no-data">Nothing is selected</div>
      </div>
    </aside>
  );
}

Mit etwas CSS im Hintergrund (das können Sie im Repo nachsehen) beginnt unser Ergebnis, dem Ziel zu ähneln. Es tut aber noch nichts.

Formen per Drag-and-drop von der Palette auf die Canvas ziehen

Wir haben die Formen in der Palette bereits als draggable eingerichtet. Nun müssen wir zulassen, dass sie auf der Canvas abgelegt und dort hinzugefügt werden. Dafür brauchen wir zunächst einen Zustand, der festhält, welche Formen sich zu einem gegebenen Zeitpunkt auf der Canvas befinden, und welche Eigenschaften sie haben, etwa Formtyp, Position, Farbe usw. Wir werden diesen Zustand künftig auch in anderen Komponenten benötigen, etwa in unserer Inspector-Komponente zum Aktualisieren der Attribute. Deshalb machen wir diesen Zustand global. Dafür nutzen wir die winzige State-Management-Bibliothek @halka/state.

Legen wir eine Datei an, in der wir unseren globalen Zustand und die Handler zu dessen Aktualisierung unterbringen.

// state.js
import { createStore } from "@halka/state";
import produce from "immer";

import { SHAPE_TYPES, DEFAULTS } from "./constants";

const baseState = {
  selected: null,
  shapes: {},
};

export const useShapes = createStore(baseState);
const setState = (fn) => useShapes.set(produce(fn));

@halka/state exportiert nur eine einzige Funktion, nämlich createStore, um einen globalen State Store zu erzeugen, wie der Name schon sagt. Wir übergeben ihr den Initialzustand, ein Objekt mit selected = null, das später die ausgewählte Form auf der Canvas festhält, und shapes, ein Objekt (verwendet als Map-artige Datenstruktur), das alle Daten zu sämtlichen Formen auf der Canvas enthält.

createStore gibt einen React Hook zurück, hier useShapes genannt, der in jeder React-Funktionskomponente genutzt werden kann, um den Zustand zu abonnieren. Der Hook useShapes bringt außerdem eine Methode set mit, eine State-Updater-Funktion ähnlich zu Reacts setState, mit der sich der Zustand aktualisieren lässt. Wir erstellen eine eigene setState-Funktion, die immer nutzt, damit sich verschachtelte beziehungsweise tiefe State-Updates leichter schreiben lassen.

HINWEIS: Wir verwenden hier die Bibliothek immer, damit wir verschachtelte Objekt- und Array-Zustände bequem über mutierende APIs aktualisieren können und gleichzeitig die Vorteile unveränderlicher State-Updates behalten. Mehr dazu erfahren.

// state.js
import { nanoid } from "nanoid";

.
.
.

export const createRectangle = ({ x, y }) => {
  setState((state) => {
    state.shapes[nanoid()] = {
      type: SHAPE_TYPES.RECT, // rect
      width: DEFAULTS.RECT.WIDTH, // 150
      height: DEFAULTS.RECT.HEIGHT, // 100
      fill: DEFAULTS.RECT.FILL, // #ffffff
      stroke: DEFAULTS.RECT.STROKE, // #000000,
      rotation: DEFAULTS.RECT.ROTATION, // 0
      x,
      y,
    };
  });
};

Anschließend schreiben wir die Funktion, die dem Zustand ein Rechteck hinzufügt, wenn ihr ein Paar aus x- und y-Koordinaten übergeben wird. Wir nutzen nanoid, um für jede Form eindeutige Schlüssel zu erzeugen, und speichern die übergebenen x- und y-Werte zusammen mit dem type und den Standardwerten für weitere visuelle Attribute wie width, height, fill und stroke.

Dasselbe können wir für den Kreis tun, indem wir radius statt width und height ergänzen.

// state.js
export const createCircle = ({ x, y }) => {
  setState((state) => {
    state.shapes[nanoid()] = {
      type: SHAPE_TYPES.CIRCLE, // circle
      radius: DEFAULTS.CIRCLE.RADIUS, // 50
      fill: DEFAULTS.CIRCLE.FILL, // white
      stroke: DEFAULTS.CIRCLE.STROKE, // black
      x,
      y,
    };
  });
};

Als Nächstes kümmern wir uns um das Hinzufügen der Formen in der Canvas-Komponente:

Um das Ablegen des Elements zu behandeln, müssen wir einen onDrop-Event-Listener anhängen.

Schauen wir uns an, wie wir das Hinzufügen von Formen zum Zustand in der Handler-Funktion handleDrop umsetzen. Wir greifen auf den type der gezogenen Form zu, indem wir den Payload-Wert DRAG_DATA_KEY am Attribut dataTransfer des nativeEvent auslesen.

Denken Sie daran: Wir haben das Payload-Objekt als String in unserem Drag-Start-Event-Handler gesetzt.

Wir müssen außerdem die x- und y-Position des Drop-Events kennen, und zwar so, dass die Konva-Instanz sie versteht, damit wir die Form an der Stelle des Drops einfügen können. Dafür brauchen wir Zugriff auf die Konva-Instanz der Canvas. Das erreichen wir, indem wir eine ref an die Stage-Komponente hängen. Nun können wir über stageRef.current auf die Konva-Instanz der Canvas zugreifen.

Um die korrekten x- und y-Koordinaten der Drop-Position auf der Canvas zu erhalten, müssen wir zunächst die Zeigerposition in der Konva-Instanz manuell setzen, so als wäre das Event innerhalb der Canvas selbst ausgelöst worden. Das machen wir, indem wir setPointersPositions auf der Konva-Instanz aufrufen und ihr den Wert event übergeben. Anschließend erhalten wir die abgeleitete Canvas-Position zurück, indem wir getPointersPositions auf der Konva-Instanz aufrufen.

Je nach gezogenem type rufen wir die oben erstellte State-Updater-Funktion createRectangle oder createCircle auf. Der Offset des Mausevents in Relation zu den Abmessungen des in der Palette gezogenen Knotens muss ebenfalls berücksichtigt werden.

Damit sich das Ablegen nahtlos anfühlt und der Knoten exakt an der Position eingefügt wird, an der das Drop-Event stattfindet, sind ein paar Offset-Berechnungen nötig. Wie im Diagramm oben dargestellt, zeigen bei Rechtecken die Werte x und y auf die obere linke Ecke. Daher können wir die Werte offsetX und offsetY direkt von den x- und y-Werten abziehen, die wir von Konva zurückbekommen.

// state.js
createRectangle({
  x: coords.x - offsetX,
  y: coords.y - offsetY,
});
// state.js
createCircle({
  x: coords.x - (offsetX - clientWidth / 2),
  y: coords.y - (offsetY - clientHeight / 2),
});

Beim Kreis zeigen die Werte x und y auf den Mittelpunkt. Deshalb müssen wir x und y zunächst in Relation zum Mittelpunkt des Knotens berechnen, also clientWidth / 2 beziehungsweise clientHeight / 2. Zum Schluss ziehen wir die resultierenden Werte von den x- und y-Werten ab, die wir von Konva zurückbekommen.

// Canvas.js
import React, { useRef, useCallback } from "react";
import { Layer, Stage } from "react-konva";

import { useShapes, createCircle, createRectangle } from "./state";
import { DRAG_DATA_KEY, SHAPE_TYPES } from "./constants";
import { Shape } from "./Shape";

const handleDragOver = (event) => event.preventDefault();

export function Canvas() {
  const shapes = useShapes((state) => Object.entries(state.shapes));

  const stageRef = useRef();

  const handleDrop = useCallback((event) => {
    const draggedData = event.nativeEvent.dataTransfer.getData(DRAG_DATA_KEY);

    if (draggedData) {
      const { offsetX, offsetY, type, clientHeight, clientWidth } = JSON.parse(
        draggedData
      );

      stageRef.current.setPointersPositions(event);

      const coords = stageRef.current.getPointerPosition();

      if (type === SHAPE_TYPES.RECT) {
        // rectangle x, y is at the top,left corner
        createRectangle({
          x: coords.x - offsetX,
          y: coords.y - offsetY,
        });
      } else if (type === SHAPE_TYPES.CIRCLE) {
        // circle x, y is at the center of the circle
        createCircle({
          x: coords.x - (offsetX - clientWidth / 2),
          y: coords.y - (offsetY - clientHeight / 2),
        });
      }
    }
  }, []);

  return (
    <main className="canvas" onDrop={handleDrop} onDragOver={handleDragOver}>
      <div className="buttons">
        <button onClick={saveDiagram}>Save</button>
        <button onClick={reset}>Reset</button>
      </div>
      <Stage
        ref={stageRef}
        width={window.innerWidth - 400}
        height={window.innerHeight}
        onClick={clearSelection}
      >
        <Layer>
          {shapes.map(([key, shape]) => (
            <Shape key={key} shape={{ ...shape, id: key }} />
          ))}
        </Layer>
      </Stage>
    </main>
  );
}

In der Canvas-Komponente greifen wir mit dem Hook useShapes erneut auf alle Form-Einträge aus unserem Store zu. Anschließend iterieren wir über das Array, um alle Formen als Kinder der Layer-Komponente zu rendern. Wir übergeben den key als id zusammen mit allen formbezogenen Daten als Prop shape an die Komponente Shape.

// Shape.js
import React, { useCallback } from "react";

import { SHAPE_TYPES } from "./constants";
import { useShapes } from "./state";
import { Circle } from "./Circle";
import { Rectangle } from "./Rectangle";

export function Shape({ shape }) {
  if (shape.type === SHAPE_TYPES.RECT) {
    return <Rectangle {...shape} />;
  } else if (shape.type === SHAPE_TYPES.CIRCLE) {
    return <Circle {...shape} />;
  }

  return null;
}

Wir müssen nun eine generische Shape-Komponente erstellen, die anhand der übergebenen id die richtige Komponente rendert.

Als Nächstes brauchen wir die Komponenten, die die eigentlichen Formen auf der Canvas rendern.

// Rectangle.js
import React from "react";
import { Rect as KonvaRectangle } from "react-konva";

export function Rectangle({ type, id, ...shapeProps }) {
  return (
     <KonvaRectangle {...shapeProps} />
  );
}
// Circle.js
import React from "react";
import { Circle as KonvaCircle } from "react-konva";

export function Circle({ type, id, ...shapeProps }) {
  return (
    <KonvaCircle {...shapeProps} />
  );
}

Im Moment interessieren uns nur die Werte in shapeProps (etwa stroke, fill, width, height und radius).

Das war es, nun können wir Formen aus der Palette per Drag-and-drop ziehen und sie werden der Canvas hinzugefügt.

Formen auswählen, verschieben, skalieren und rotieren

Wir beginnen damit, die State-Update-Funktion für das Auswählen und Verschieben von Formen zu ergänzen.

// state.js
export const selectShape = (id) => {
  setState((state) => {
    state.selected = id;
  });
};

export const clearSelection = () => {
  setState((state) => {
    state.selected = null;
  });
};

export const moveShape = (id, event) => {
  setState((state) => {
    const shape = state.shapes[id];

    if (shape) {
      shape.x = event.target.x();
      shape.y = event.target.y();
    }
  });
};

Im Handler selectShape setzen wir schlicht die id der Form als Property selected in unserem Zustand. Der Handler clearSelection setzt selected wieder auf null. Im Handler moveShape prüfen wir zunächst, ob eine Form ausgewählt ist, und aktualisieren dann die x- und y-Koordinaten der Form.

// state.js
export const transformRectangleShape = (node, id, event) => {
  // transformer is changing scale of the node
  // and NOT its width or height
  // but in the store we have only width and height
  // to match the data better we will reset scale on transform end
  const scaleX = node.scaleX();
  const scaleY = node.scaleY();

  // we will reset the scale back
  node.scaleX(1);
  node.scaleY(1);

  setState((state) => {
    const shape = state.shapes[id];

    if (shape) {
      shape.x = node.x();
      shape.y = node.y();
      
      shape.rotation = node.rotation();
      
       shape.width = clamp(
        // increase the width in order of the scale
        node.width() * scaleX,
        // should not be less than the minimum width
        LIMITS.RECT.MIN,
        // should not be more than the maximum width
        LIMITS.RECT.MAX
      );
      shape.height = clamp(
        node.height() * scaleY,
        LIMITS.RECT.MIN,
        LIMITS.RECT.MAX
      );
    }
  });
};

Danach erstellen wir die State-Update-Funktion transformRectangleShape. Diese Funktion behandelt die Resize- und Rotate-Events eines Rechtecks auf der Canvas. Für diese Transformationen auf der Canvas nutzen wir Konva.Transformer. Standardmäßig verändert er die Skalierung der Form, die er transformiert, wir müssen aber Breite und Höhe erhöhen, damit andere Eigenschaften (etwa die Strichstärke) nicht beeinflusst werden. Um die Skalierungsänderung in eine Dimensionsänderung zu übersetzen, merken wir uns zunächst den Skalierungsfaktor und setzen die Skalierung des Knotens auf 1 (Standard) zurück. Anschließend setzen wir den neuen aktuellen Dimensionswert einfach auf den aktuellen Dimensionswert multipliziert mit dem Skalierungsfaktor. Die Breite darf dabei die minimalen und maximalen Grenzwerte nicht überschreiten.

Schauen wir uns nun das Setup in der Komponente Rectangle an, unter Verwendung der Komponente Transformer aus react-konva.

// Rectangle.js
import React, { useRef, useEffect, useCallback } from "react";
import { Rect as KonvaRectangle, Transformer } from "react-konva";

import { LIMITS } from "./constants";
import { selectShape, transformRectangleShape, moveShape } from "./state";

const boundBoxCallbackForRectangle = (oldBox, newBox) => {
  // limit resize
  if (
    newBox.width < LIMITS.RECT.MIN ||
    newBox.height < LIMITS.RECT.MIN ||
    newBox.width > LIMITS.RECT.MAX ||
    newBox.height > LIMITS.RECT.MAX
  ) {
    return oldBox;
  }
  return newBox;
};

export function Rectangle({ id, isSelected, type, ...shapeProps }) {
  const shapeRef = useRef();
  const transformerRef = useRef();

  useEffect(() => {
    if (isSelected) {
      transformerRef.current.nodes([shapeRef.current]);
      transformerRef.current.getLayer().batchDraw();
    }
  }, [isSelected]);

  const handleSelect = useCallback(
    (event) => {
      event.cancelBubble = true;

      selectShape(id);
    },
    [id]
  );

  const handleDrag = useCallback(
    (event) => {
      moveShape(id, event);
    },
    [id]
  );

  const handleTransform = useCallback(
    (event) => {
      transformRectangleShape(shapeRef.current, id, event);
    },
    [id]
  );

  return (
    <>
      <KonvaRectangle
        onClick={handleSelect}
        onTap={handleSelect}
        onDragStart={handleSelect}
        ref={shapeRef}
        {...shapeProps}
        draggable
        onDragEnd={handleDrag}
        onTransformEnd={handleTransform}
      />
      {isSelected && (
        <Transformer
          anchorSize={5}
          borderDash={[6, 2]}
          ref={transformerRef}
          boundBoxFunc={boundBoxCallbackForRectangle}
        />
      )}
    </>
  );
}

Wir hängen den Handler handleSelect an die Events click, tap und dragStart der Komponente KonvaRectangle, der die Funktion selectShape mit der id der Form aufruft. Zusätzlich muss verhindert werden, dass das Event in den übergeordneten Kontext hochblubbert. Der Grund: Wir hängen den Handler clearSelection an das Click-Event der Canvas. Wenn wir das Hochblubbern zur Canvas nicht unterbinden, löst es auch den Click-Handler der Canvas aus, wodurch die Auswahl unmittelbar nach dem Setzen wieder aufgehoben würde. Den Handler handleDrag hängen wir an das Event dragEnd, der die Funktion moveShape mit der id aufruft. Außerdem müssen wir die boolesche Prop draggable ergänzen, damit auf die Drag-Events gehört wird.

Nun schauen wir uns an, wie wir auf die Resize- und Rotate-Events hören können. Dafür müssen wir zunächst eine ref an die Komponente KonvaRectangle hängen, um auf die Konva-Knoten-Instanz der Form zugreifen zu können. Wir müssen außerdem eine Transformer-Komponente mit einer daran gehängten ref rendern. Der Transformer soll nur aktiv sein, wenn die Form ausgewählt ist. Deshalb rendern wir ihn bedingt und ergänzen zusätzlich einen useEffect-Aufruf, der bei Änderung der Prop isSelected ausgeführt wird. Ist isSelected true, fügen wir die Knoten-Instanz der Form (Zugriff über shapeRef.current) den Nodes der Transformer-Instanz hinzu (Zugriff über transformerRef.current) und rufen die Methode batchDraw auf dem Transformer auf, um ihn auf den Canvas-Layer zu zeichnen.

Wir ergänzen außerdem eine boundBoxFunc am Transformer, die verhindert, dass die Form in eine unzulässige Größe transformiert wird (also innerhalb der Min-/Max-Grenzwerte bleibt).

// Circle.js
<Transformer
  anchorSize={5}
  borderDash={[6, 2]}
  ref={transformerRef}
  rotateEnabled={false}
  enabledAnchors={[
    "top-left",
    "top-right",
    "bottom-right",
    "bottom-left",
  ]}
  boundBoxFunc={boundBoxCallbackForCircle}
 />

In der Komponente Circle haben wir ein ähnliches Setup. Die einzigen größeren Änderungen betreffen die Konfiguration der Transformer-Komponente. Wir setzen rotateEnabled auf false, da es keinen Grund gibt, einen Kreis zu rotieren, und die Anfasser zum Skalieren sind nur an den vier Ecken verfügbar, damit das Verhältnis von Höhe und Breite immer erhalten bleibt. Sie können das eigene enabledAnchors-Setup weglassen, wenn Sie zulassen möchten, dass der Kreis zu einer Ellipse verformt wird.

Schauen wir uns nun an, wie die neue Prop isSelected an die Komponente übergeben wird.

// Shape.js
import React, { useCallback } from "react";

import { SHAPE_TYPES } from "./constants";
import { useShapes } from "./state";
import { Circle } from "./Circle";
import { Rectangle } from "./Rectangle";

export function Shape({ shape }) {
  const isSelectedSelector = useCallback(
    (state) => state.selected === shape.id,
    [shape]
  );
  const isSelected = useShapes(isSelectedSelector);

  if (shape.type === SHAPE_TYPES.RECT) {
    return <Rectangle {...shape} isSelected={isSelected} />;
  } else if (shape.type === SHAPE_TYPES.CIRCLE) {
    return <Circle {...shape} isSelected={isSelected} />;
  }

  return null;
}

In der Komponente Shape verwenden wir einen eigenen Selector, um zu prüfen, ob der Wert selected im Zustand der id der Form entspricht, und geben das boolesche Ergebnis an die Komponente weiter, die die Form rendert.

Damit die Auswahl beim Klick auf einen leeren Bereich der Canvas aufgehoben wird, hängen wir den Handler clearSelection an das click-Event der Komponente Stage.

// Canvas.js
<Stage
  ref={stageRef}
  width={window.innerWidth - 400}
  height={window.innerHeight}
  onClick={clearSelection}
>
  <Layer>
    {shapes.map(([key, shape]) => (
      <Shape key={key} shape={{ ...shape, id: key }} />
    ))}
  </Layer>
</Stage>

Strich- und Füllfarbe der Formen aktualisieren

Die Farben stroke und fill der Formen sind standardmäßig auf black beziehungsweise white gesetzt. Wir möchten dem Nutzer aber erlauben, diese Werte zu ändern. Der Nutzer soll diese Werte für die ausgewählte Form über das rechte Panel anpassen können.

// state.js
export const updateAttribute = (attr, value) => {
  setState((state) => {
    const shape = state.shapes[state.selected];

    if (shape) {
      shape[attr] = value;
    }
  });
};

Zunächst erstellen wir die State-Funktion updateAttribute, die einen gegebenen Attributwert der ausgewählten Form aktualisiert.

Anschließend rendern wir in der Komponente PropertiesPanel zuerst alle Eigenschaften, also stroke und fill zusammen mit dem type. Und bei Änderung eines der Farbeingabefelder rufen wir die Funktion updateAttribute mit dem Attributschlüssel und dem neuen Wert auf.

// PropertiesPanel.js
import React, { useCallback } from "react";

import { useShapes, updateAttribute } from "./state";

const shapeSelector = (state) => state.shapes[state.selected];

export function PropertiesPanel() {
  const selectedShape = useShapes(shapeSelector);

  const updateAttr = useCallback((event) => {
    const attr = event.target.name;

    updateAttribute(attr, event.target.value);
  }, []);

  return (
    <aside className="panel">
      <h2>Properties</h2>
      <div className="properties">
        {selectedShape ? (
          <>
            <div className="key">
              Type <span className="value">{selectedShape.type}</span>
            </div>

            <div className="key">
              Stroke{" "}
              <input
                className="value"
                name="stroke"
                type="color"
                value={selectedShape.stroke}
                onChange={updateAttr}
              />
            </div>

            <div className="key">
              Fill{" "}
              <input
                className="value"
                name="fill"
                type="color"
                value={selectedShape.fill}
                onChange={updateAttr}
              />
            </div>
          </>
        ) : (
          <div className="no-data">Nothing is selected</div>
        )}
      </div>
    </aside>
  );
}

Den Zustand im LocalStorage persistieren

Zu guter Letzt bauen wir die Persistenz im LocalStorage. Das ist wahrscheinlich der einfachste Teil von allen.

// state.js
const APP_NAMESPACE = "__integrtr_diagrams__";

const baseState = {
  selected: null,
  shapes: {},
};

export const useShapes = createStore(() => {
  const initialState = JSON.parse(localStorage.getItem(APP_NAMESPACE));

  return { ...baseState, shapes: initialState ?? {} };
});
const setState = (fn) => useShapes.set(produce(fn));

export const saveDiagram = () => {
  const state = useShapes.get();

  localStorage.setItem(APP_NAMESPACE, JSON.stringify(state.shapes));
};

export const reset = () => {
  localStorage.removeItem(APP_NAMESPACE);

  useShapes.set(baseState);
};

Wir müssen den Initialisierungscode für den Zustand unseres Stores anpassen. Statt den Initialzustand direkt zu setzen, übergeben wir der Funktion createStore eine Funktion, die prüft, ob im LocalStorage ein Formzustand gespeichert ist, und den Initialzustand auf dieser Basis verzögert erzeugt.

Außerdem schreiben wir die Funktion saveDiagram, die lediglich die shapes aus dem Zustand im LocalStorage persistiert, sowie die Funktion reset, die sowohl den aktuellen Zustand als auch den LocalStorage leert.

Zum Schluss ergänzen wir zwei Buttons, die die Aktionen Save und Reset auslösen.

// App.js
<div className="buttons">
  <button onClick={saveDiagram}>Save</button>
  <button onClick={reset}>Reset</button>
</div>

Das war alles!

Unser Tool zum Erstellen von Diagrammen ist einsatzbereit. Vom Funktionsumfang her ist es minimal, aber es ist definitiv eine solide Basis, auf der sich aufbauen lässt.

Nachdem Sie nun mit den Grundlagen von HTML5 Canvas vertraut sind, bauen Sie gerne darauf auf und teilen Sie Ihre coolen Projekte mit uns.

Ähnliche Beiträge

Das könnte Sie auch interessieren

Aktualisieren Sie Ihre Unternehmensintegration.

Mit zunehmender Effizienz bei Digitalisierung und Datenfluss erwarten Sie einen höheren Unternehmenswert und eine bessere Wettbewerbsfähigkeit.