Image Cropping: react-image-crop in Applikationen einbinden

Image Cropping: react-image-crop in Applikationen einbinden

Dieser Post behandelt das Einbinden des npm Moduls react-image-crop in eine React/Next.js Applikation.

Im Verlaufe der Einbindung des Moduls in eines unserer Kundenprojekte sind einige unerwartete Probleme aufgetreten, weshalb dies für mich Anlass genug ist, diesen Post zu verfassen.

Welche Probleme sind aufgetreten?

Die Doku des Moduls verspricht eine schnelle & einfache Einbindung in die eigene App, trotz der vielen und ausführlichen Code-Beispiele wollte die Einbindung aber auf Anhieb nicht klappen. Häufig aufgetretene Fehler waren beispielsweise falsche Seitenverhältnisse im Ausgabebild oder gänzlich falsch ausgeschnittene Bereiche von Bildern. Auch die Initialisierung des Image Croppers war mit Fehlern verbunden, da laut Doku zwar die Einheiten '%' und 'px' akzeptiert werden sollen, reell aber nur prozentuale Werte unter der Haube verwendet werden, weshalb die Pixelwerte des Ausgabebildes eigentlich die prozentualen Werte des Croppings widerspiegelten und die Bilder daher stark gestreckt oder gestaucht waren.

Die Lösung

Nach einer Weile des Testens und Ausprobierens, sowie der Evaluation lag die Lösung des Problems vor: Es mussten fälschlicherweise prozentual berechnete Werte des Moduls durch die eigentlichen Pixelwerte ersetzt werden. Hierzu habe ich eine Hilfsfunktion geschrieben:

const backToPixel = (value, dimensionSize) => {
    return Math.round((value / 100) * dimensionSize);
};

Diese Funktion habe ich im vorgegebenen Codebeispiel an den nötigen Stellen eingesetzt. Das folgende Beispiel enthält neben dem Modul react-image-crop außerdem die Module antd und @antd-design/icons:

import { Button, Row, Col, Card } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
import React, { useState, useCallback, useRef, useEffect } from "react";
import ReactCrop from "react-image-crop";
import "react-image-crop/dist/ReactCrop.css";

function getImageDimensions(file) {
  return new Promise(function (resolved, rejected) {
    var i = new Image();
    i.onload = function () {
      resolved({ w: i.width, h: i.height });
    };
    i.src = file;
  });
}

export default function ImageCrop({
  base64,
  setBase64,
}) {
  const [isLoading, setIsLoading] = useState(true);
  const [imageWidth, setImageWidth] = useState(0);
  const [imageHeight, setImageHeight] = useState(0);
  const [crop, setCrop] = useState({});
  const [completedCrop, setCompletedCrop] = useState(null);

  const imgRef = useRef(null);
  const previewCanvasRef = useRef(null);

  const onLoad = useCallback((img) => {
    imgRef.current = img;
  }, []);

  useEffect(() => {
    const setImageCropDimensions = async () => {
      const { w, h } = await getImageDimensions(base64);
      const c = { width: 100, height: 100, x: 0, y: 0 };
      setImageWidth(w);
      setImageHeight(h);
      setCrop(c);
      setIsLoading(false);
    };
    setImageCropDimensions();
  }, [base64]);

  useEffect(() => {
    if (!completedCrop || !previewCanvasRef.current || !imgRef.current) {
      return;
    }

    const image = imgRef.current;
    const canvas = previewCanvasRef.current;
    const crop = completedCrop;

    const scaleX = image.naturalWidth / image.width;
    const scaleY = image.naturalHeight / image.height;
    const ctx = canvas.getContext("2d");
    const pixelRatio = window.devicePixelRatio;

    canvas.width = backToPixel(crop.width * pixelRatio, imageWidth);
    canvas.height = backToPixel(crop.height * pixelRatio, imageHeight);

    ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
    ctx.imageSmoothingQuality = "high";

    ctx.drawImage(
      image,
      backToPixel(crop.x * scaleX, imageWidth),
      backToPixel(crop.y * scaleY, imageHeight),
      backToPixel(crop.width * scaleX, imageWidth),
      backToPixel(crop.height * scaleY, imageHeight),
      0,
      0,
      backToPixel(crop.width, imageWidth),
      backToPixel(crop.height, imageHeight)
    );
  }, [completedCrop]);

  const backToPixel = (value, dimension) => {
    return Math.round((value / 100) * dimension);
  };

  const toBase64 = (file) =>
    new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.readAsDataURL(file);
      reader.onload = () => resolve(reader.result);
      reader.onerror = (error) => reject(error);
    });

  function generateBase64(canvas, crop, setBase64) {
    if (!crop || !canvas) {
      return;
    }

    canvas.toBlob(
      async (blob) => {
        const base64 = await toBase64(blob);
        setBase64(base64);
        setIsLoading(true);
        setImageWidth(0);
        setImageHeight(0);
        setCrop({});
        setCompletedCrop(null);
      },
      "image/png",
      1
    );
  }

  return isLoading ? (
    <LoadingOutlined />
  ) : (
    <div className="app" style={{ height: "100%" }}>
      <Row style={{ height: "100%" }} gutter={[16, 16]}>
        <Col xs={12} style={{ height: "100%" }}>
          <ReactCrop
            src={base64}
            onImageLoaded={onLoad}
            crop={crop}
            onChange={(c) => setCrop(c)}
            onComplete={(c) => setCompletedCrop(c)}
            style={{ height: "100%" }}
          />
        </Col>
        <Col xs={12}>
          <Card title="Vorschau">
            <canvas
              ref={previewCanvasRef}
              style={{
                width: `${Math.round(
                  backToPixel(completedCrop?.width, imageWidth) ?? 0
                )}px`,
                height: `${Math.round(
                  backToPixel(completedCrop?.height, imageHeight) ?? 0
                )}px`,
              }}
            />
          </Card>
        </Col>
        <Col xs={24} className="d-flex justify-content-end">
          <Button
            onClick={() =>
              generateBase64(previewCanvasRef.current, completedCrop, setBase64)
            }
            type="primary"
          >
            Bild zuschneiden
          </Button>
        </Col>
      </Row>
    </div>
  );
}

Entdecken Sie die Vorteile­ unserer Individual­entwicklung

In einem kostenlosen Erstgespräch vor Ort - wahlweise auch am Telefon oder über Microsoft Teams - lernen wir uns unverbindlich kennen und erarbeiten gemeinsam mit Ihnen ein Lösungskonzept für Ihre Ziele. Wir freuen uns auf Sie!