Files

187 lines
4.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
import Cropper, { type Area } from "react-easy-crop";
import { getCroppedImageFile } from "@/lib/cropImage";
import styles from "./ImageCropDialog.module.css";
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.05;
type Props = {
open: boolean;
imageSrc: string | null;
title: string;
aspect: number;
/** Prefer PNG for logos with transparency */
outputType?: "image/jpeg" | "image/png";
fileName?: string;
onCancel: () => void;
onComplete: (file: File, previewUrl: string) => void;
};
export default function ImageCropDialog({
open,
imageSrc,
title,
aspect,
outputType = "image/jpeg",
fileName = "image",
onCancel,
onComplete,
}: Props) {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const transparentOutput = outputType === "image/png";
useEffect(() => {
if (!open) return;
setCrop({ x: 0, y: 0 });
setZoom(1);
setCroppedAreaPixels(null);
setBusy(false);
setError("");
}, [open, imageSrc]);
const onCropComplete = useCallback((_: Area, pixels: Area) => {
setCroppedAreaPixels(pixels);
}, []);
function clampZoom(value: number) {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2))));
}
if (!open || !imageSrc) return null;
async function apply() {
if (!croppedAreaPixels || !imageSrc) return;
setBusy(true);
setError("");
try {
const file = await getCroppedImageFile(
imageSrc,
croppedAreaPixels,
fileName,
outputType,
);
const previewUrl = URL.createObjectURL(file);
onComplete(file, previewUrl);
setCrop({ x: 0, y: 0 });
setZoom(1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not crop image.");
} finally {
setBusy(false);
}
}
return (
<div className={styles.overlay} role="dialog" aria-modal aria-label={title}>
<div className={styles.dialog}>
<div className={styles.head}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.hint}>
Drag to move · zoom out to add padding around the image
</p>
</div>
<div
className={
transparentOutput
? `${styles.stage} ${styles.stageLight}`
: styles.stage
}
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
aspect={aspect}
onCropChange={setCrop}
onZoomChange={(value) => setZoom(clampZoom(value))}
onCropComplete={onCropComplete}
showGrid={false}
objectFit="contain"
restrictPosition={false}
style={{
containerStyle: {
background: "transparent",
},
}}
/>
</div>
<div className={styles.zoomRow}>
<button
type="button"
className={styles.zoomBtn}
disabled={busy || zoom <= MIN_ZOOM}
aria-label="Zoom out"
onClick={() => setZoom((value) => clampZoom(value - ZOOM_STEP))}
>
−
</button>
<input
className={styles.zoomRange}
type="range"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={0.01}
value={zoom}
disabled={busy}
aria-label="Zoom"
onChange={(event) => setZoom(clampZoom(Number(event.target.value)))}
/>
<button
type="button"
className={styles.zoomBtn}
disabled={busy || zoom >= MAX_ZOOM}
aria-label="Zoom in"
onClick={() => setZoom((value) => clampZoom(value + ZOOM_STEP))}
>
+
</button>
<span className={styles.zoomValue}>{Math.round(zoom * 100)}%</span>
</div>
{error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={`outline-btn ${styles.cancelBtn}`}
disabled={busy}
onClick={() => {
setCrop({ x: 0, y: 0 });
setZoom(1);
setError("");
onCancel();
}}
>
Cancel
</button>
<button
type="button"
className={`outline-btn ${styles.applyBtn}`}
disabled={busy || !croppedAreaPixels}
onClick={() => void apply()}
>
{busy ? "Applying…" : "Apply crop"}
</button>
</div>
</div>
</div>
);
}