350 lines
10 KiB
TypeScript
350 lines
10 KiB
TypeScript
// oxlint-disable next/no-img-element
|
|
"use client";
|
|
|
|
import {
|
|
ArrowLeftIcon,
|
|
ImageUpIcon,
|
|
XIcon,
|
|
ZoomInIcon,
|
|
ZoomOutIcon,
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Cropper,
|
|
CropperCropArea,
|
|
CropperDescription,
|
|
CropperImage,
|
|
} from "@/components/ui/cropper";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { useFileUpload } from "@/hooks/use-file-upload";
|
|
|
|
// Define type for pixel crop area
|
|
type Area = { x: number; y: number; width: number; height: number };
|
|
|
|
// Helper function to create a cropped image blob
|
|
const createImage = (url: string): Promise<HTMLImageElement> =>
|
|
new Promise((resolve, reject) => {
|
|
const image = new Image();
|
|
image.addEventListener("load", () => resolve(image));
|
|
image.addEventListener("error", (error) => reject(error));
|
|
image.setAttribute("crossOrigin", "anonymous"); // Needed for canvas Tainted check
|
|
image.src = url;
|
|
});
|
|
|
|
async function getCroppedImg(
|
|
imageSrc: string,
|
|
pixelCrop: Area,
|
|
outputWidth: number = pixelCrop.width, // Optional: specify output size
|
|
outputHeight: number = pixelCrop.height,
|
|
): Promise<Blob | null> {
|
|
try {
|
|
const image = await createImage(imageSrc);
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
if (!ctx) {
|
|
return null;
|
|
}
|
|
|
|
// Set canvas size to desired output size
|
|
canvas.width = outputWidth;
|
|
canvas.height = outputHeight;
|
|
|
|
// Draw the cropped image onto the canvas
|
|
ctx.drawImage(
|
|
image,
|
|
pixelCrop.x,
|
|
pixelCrop.y,
|
|
pixelCrop.width,
|
|
pixelCrop.height,
|
|
0,
|
|
0,
|
|
outputWidth, // Draw onto the output size
|
|
outputHeight,
|
|
);
|
|
|
|
// Convert canvas to blob
|
|
return new Promise((resolve) => {
|
|
canvas.toBlob((blob) => {
|
|
resolve(blob);
|
|
}, "image/jpeg"); // Specify format and quality if needed
|
|
});
|
|
} catch (error) {
|
|
console.error("Error in getCroppedImg:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export default function Component({
|
|
value,
|
|
onValueChange,
|
|
}: {
|
|
value?: File | undefined;
|
|
onValueChange?: (value: File | undefined) => void;
|
|
}) {
|
|
const [
|
|
{ files, isDragging },
|
|
{
|
|
handleDragEnter,
|
|
handleDragLeave,
|
|
handleDragOver,
|
|
handleDrop,
|
|
openFileDialog,
|
|
removeFile,
|
|
getInputProps,
|
|
},
|
|
] = useFileUpload({
|
|
accept: "image/*",
|
|
});
|
|
|
|
const previewUrl = files[0]?.preview || null;
|
|
const fileId = files[0]?.id;
|
|
|
|
const [finalImageUrl, setFinalImageUrl] = useState<string | null>(null);
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
|
|
// Ref to track the previous file ID to detect new uploads
|
|
const previousFileIdRef = useRef<string | undefined | null>(null);
|
|
|
|
// State to store the desired crop area in pixels
|
|
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
|
|
|
// State for zoom level
|
|
const [zoom, setZoom] = useState(1);
|
|
|
|
// Callback for Cropper to provide crop data - Wrap with useCallback
|
|
const handleCropChange = useCallback((pixels: Area | null) => {
|
|
setCroppedAreaPixels(pixels);
|
|
}, []);
|
|
|
|
const handleApply = async () => {
|
|
// Check if we have the necessary data
|
|
if (!previewUrl || !fileId || !croppedAreaPixels) {
|
|
console.error("Missing data for apply:", {
|
|
croppedAreaPixels,
|
|
fileId,
|
|
previewUrl,
|
|
});
|
|
// Remove file if apply is clicked without crop data?
|
|
if (fileId) {
|
|
removeFile(fileId);
|
|
setCroppedAreaPixels(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 1. Get the cropped image blob using the helper
|
|
const croppedBlob = await getCroppedImg(previewUrl, croppedAreaPixels);
|
|
|
|
if (!croppedBlob) {
|
|
throw new Error("Failed to generate cropped image blob.");
|
|
}
|
|
|
|
// 2. Create a NEW object URL from the cropped blob
|
|
const newFinalUrl = URL.createObjectURL(croppedBlob);
|
|
|
|
// 3. Revoke the OLD finalImageUrl if it exists
|
|
if (finalImageUrl) {
|
|
URL.revokeObjectURL(finalImageUrl);
|
|
}
|
|
|
|
// 4. Set the final avatar state to the NEW URL
|
|
setFinalImageUrl(newFinalUrl);
|
|
|
|
// 5. Create a File from the blob and pass it to the form
|
|
const croppedFile = new File(
|
|
[croppedBlob],
|
|
files[0]?.file.name || "cropped-image.jpg",
|
|
{ type: croppedBlob.type },
|
|
);
|
|
onValueChange?.(croppedFile);
|
|
|
|
// 6. Close the dialog (don't remove the file yet)
|
|
setIsDialogOpen(false);
|
|
} catch (error) {
|
|
console.error("Error during apply:", error);
|
|
// Close the dialog even if cropping fails
|
|
setIsDialogOpen(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveFinalImage = () => {
|
|
if (finalImageUrl) {
|
|
URL.revokeObjectURL(finalImageUrl);
|
|
}
|
|
setFinalImageUrl(null);
|
|
onValueChange?.(undefined);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const currentFinalUrl = finalImageUrl;
|
|
// Cleanup function
|
|
return () => {
|
|
if (currentFinalUrl?.startsWith("blob:")) {
|
|
URL.revokeObjectURL(currentFinalUrl);
|
|
}
|
|
};
|
|
}, [finalImageUrl]);
|
|
|
|
// Effect to open dialog when a *new* file is ready
|
|
useEffect(() => {
|
|
// Check if fileId exists and is different from the previous one
|
|
if (fileId && fileId !== previousFileIdRef.current) {
|
|
setIsDialogOpen(true); // Open dialog for the new file
|
|
setCroppedAreaPixels(null); // Reset crop area for the new file
|
|
setZoom(1); // Reset zoom for the new file
|
|
}
|
|
// Update the ref to the current fileId for the next render
|
|
previousFileIdRef.current = fileId;
|
|
}, [fileId]); // Depend only on fileId
|
|
|
|
// Reset cropper when value is cleared externally (e.g., form reset)
|
|
const previousValueRef = useRef(value);
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: only fire on value change
|
|
useEffect(() => {
|
|
const wasSet = !!previousValueRef.current;
|
|
const isBlank = !value;
|
|
if (wasSet && isBlank) {
|
|
if (finalImageUrl) {
|
|
URL.revokeObjectURL(finalImageUrl);
|
|
}
|
|
setFinalImageUrl(null);
|
|
setCroppedAreaPixels(null);
|
|
setZoom(1);
|
|
}
|
|
previousValueRef.current = value;
|
|
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [value]);
|
|
|
|
return (
|
|
<>
|
|
<div className="relative inline-flex">
|
|
{/* Drop area - uses finalImageUrl */}
|
|
<button
|
|
aria-label={finalImageUrl ? "Change image" : "Upload image"}
|
|
className="relative flex size-24 items-center justify-center overflow-hidden rounded-lg border border-dashed border-input transition-colors outline-none hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-disabled:pointer-events-none has-disabled:opacity-50 has-[img]:border-none data-[dragging=true]:bg-accent/50 sm:size-32"
|
|
data-dragging={isDragging || undefined}
|
|
onClick={openFileDialog}
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
type="button"
|
|
>
|
|
{finalImageUrl ? (
|
|
<img
|
|
alt="User avatar"
|
|
className="size-full object-cover"
|
|
height={128}
|
|
src={finalImageUrl}
|
|
style={{ objectFit: "cover" }}
|
|
width={128}
|
|
/>
|
|
) : (
|
|
<div aria-hidden="true">
|
|
<ImageUpIcon className="size-8 opacity-60" />
|
|
</div>
|
|
)}
|
|
</button>
|
|
{/* Remove button - depends on finalImageUrl */}
|
|
{finalImageUrl && (
|
|
<Button
|
|
aria-label="Remove image"
|
|
className="absolute -top-1 -right-1 size-6 rounded-full border-2 border-background shadow-none focus-visible:border-background"
|
|
onClick={handleRemoveFinalImage}
|
|
size="icon"
|
|
>
|
|
<XIcon className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
<input
|
|
{...getInputProps()}
|
|
aria-label="Upload image file"
|
|
className="sr-only"
|
|
tabIndex={-1}
|
|
/>
|
|
</div>
|
|
|
|
{/* Cropper Dialog - Use isDialogOpen for open prop */}
|
|
<Dialog onOpenChange={setIsDialogOpen} open={isDialogOpen}>
|
|
<DialogContent className="gap-0 p-0 sm:max-w-140 *:[button]:hidden">
|
|
<DialogDescription className="sr-only">
|
|
Crop image dialog
|
|
</DialogDescription>
|
|
<DialogHeader className="contents space-y-0 text-left">
|
|
<DialogTitle className="flex items-center justify-between border-b p-4 text-base">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
aria-label="Cancel"
|
|
className="-my-1 opacity-60"
|
|
onClick={() => setIsDialogOpen(false)}
|
|
size="icon"
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
<ArrowLeftIcon aria-hidden="true" />
|
|
</Button>
|
|
<span>Crop image</span>
|
|
</div>
|
|
<Button
|
|
autoFocus
|
|
className="-my-1"
|
|
disabled={!previewUrl}
|
|
onClick={handleApply}
|
|
>
|
|
Apply
|
|
</Button>
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
{previewUrl && (
|
|
<Cropper
|
|
className="h-96 sm:h-120"
|
|
image={previewUrl}
|
|
onCropChange={handleCropChange}
|
|
onZoomChange={setZoom}
|
|
zoom={zoom}
|
|
>
|
|
<CropperDescription />
|
|
<CropperImage />
|
|
<CropperCropArea />
|
|
</Cropper>
|
|
)}
|
|
<DialogFooter className="border-t px-4 py-6">
|
|
<div className="mx-auto flex w-full max-w-80 items-center gap-4">
|
|
<ZoomOutIcon
|
|
aria-hidden="true"
|
|
className="shrink-0 opacity-60"
|
|
size={16}
|
|
/>
|
|
<Slider
|
|
aria-label="Zoom slider"
|
|
defaultValue={[1]}
|
|
max={3}
|
|
min={1}
|
|
onValueChange={(value) => setZoom(value[0])}
|
|
step={0.1}
|
|
value={[zoom]}
|
|
/>
|
|
<ZoomInIcon
|
|
aria-hidden="true"
|
|
className="shrink-0 opacity-60"
|
|
size={16}
|
|
/>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|