| 1 | "use client"; |
| 2 | |
| 3 | import { Minus, Plus } from "lucide-react"; |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | |
| 6 | import { Button } from "@/components/ui/button"; |
| 7 | import { |
| 8 | Credenza, |
| 9 | CredenzaContent, |
| 10 | CredenzaHeader, |
| 11 | CredenzaTitle, |
| 12 | } from "@/components/ui/credenza"; |
| 13 | import { Slider } from "@/components/ui/slider"; |
| 14 | import { cn } from "@/lib/utils"; |
| 15 | import { type ImageCropSettings } from "../../../utils/types"; |
| 16 | import { type ImageDimensions } from "./useImageDimensions"; |
| 17 | |
| 18 | interface CropModalProps { |
| 19 | open: boolean; |
| 20 | onOpenChange: (open: boolean) => void; |
| 21 | imageUrl: string; |
| 22 | initialCropSettings: ImageCropSettings; |
| 23 | onSave: (settings: ImageCropSettings) => void; |
| 24 | imageDimensions: ImageDimensions; |
| 25 | } |
| 26 | |
| 27 | export function CropModal({ |
| 28 | open, |
| 29 | onOpenChange, |
| 30 | imageUrl, |
| 31 | initialCropSettings, |
| 32 | onSave, |
| 33 | imageDimensions, |
| 34 | }: CropModalProps) { |
| 35 | const [cropSettings, setCropSettings] = |
| 36 | useState<ImageCropSettings>(initialCropSettings); |
| 37 | const [naturalDimensions, setNaturalDimensions] = useState<{ |
| 38 | width: number; |
| 39 | height: number; |
| 40 | } | null>(null); |
| 41 | |
| 42 | const containerRef = useRef<HTMLDivElement>(null); |
| 43 | |
| 44 | // Initialize state when opening |
| 45 | useEffect(() => { |
| 46 | if (open) { |
| 47 | setCropSettings(initialCropSettings); |
| 48 | } |
| 49 | }, [open, initialCropSettings]); |
| 50 | |
| 51 | // Calculate display dimensions to fit in modal while maintaining exact aspect ratio from imageDimensions |
| 52 | const displayDimensions = useMemo(() => { |
| 53 | if ( |
| 54 | !imageDimensions || |
| 55 | imageDimensions.width === 0 || |
| 56 | imageDimensions.height === 0 |
| 57 | ) { |
| 58 | return { width: 0, height: 0 }; |
| 59 | } |
| 60 | |
| 61 | // Maximum available space in the modal (approximate) |
| 62 | const MAX_WIDTH = 900; |
| 63 | const MAX_HEIGHT = 600; |
| 64 | |
| 65 | const width = imageDimensions.width; |
| 66 | const height = imageDimensions.height; |
| 67 | |
| 68 | const fitScale = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height); |
| 69 | |
| 70 | return { |
| 71 | width: width * fitScale, |
| 72 | height: height * fitScale, |
| 73 | }; |
| 74 | }, [imageDimensions]); |
| 75 | |
| 76 | // Load image dimensions |
| 77 | const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => { |
| 78 | const img = e.currentTarget; |
| 79 | setNaturalDimensions({ |
| 80 | width: img.naturalWidth, |
| 81 | height: img.naturalHeight, |
| 82 | }); |
| 83 | }; |
| 84 | |
| 85 | // Dragging Logic |
| 86 | const [isDragging, setIsDragging] = useState(false); |
| 87 | const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); |
| 88 | const [lastObjectPosition, setLastObjectPosition] = useState({ |
| 89 | x: cropSettings.objectPosition.x, |
| 90 | y: cropSettings.objectPosition.y, |
| 91 | }); |
| 92 | |
| 93 | const handleMouseDown = useCallback( |
| 94 | (e: React.MouseEvent) => { |
| 95 | e.preventDefault(); |
| 96 | setIsDragging(true); |
| 97 | setDragStart({ x: e.clientX, y: e.clientY }); |
| 98 | setLastObjectPosition({ |
| 99 | x: cropSettings.objectPosition.x, |
| 100 | y: cropSettings.objectPosition.y, |
| 101 | }); |
| 102 | }, |
| 103 | [cropSettings.objectPosition], |
| 104 | ); |
| 105 | |
| 106 | useEffect(() => { |
| 107 | const handleMouseMove = (e: MouseEvent) => { |
| 108 | if (!isDragging || displayDimensions.width === 0) return; |
| 109 | |
| 110 | e.preventDefault(); |
| 111 | |
| 112 | const deltaX = e.clientX - dragStart.x; |
| 113 | const deltaY = e.clientY - dragStart.y; |
| 114 | |
| 115 | // Convert pixel movement to percentage |
| 116 | // Using displayDimensions as the container size |
| 117 | const deltaXPercent = (deltaX / displayDimensions.width) * 100 * 3; |
| 118 | const deltaYPercent = (deltaY / displayDimensions.height) * 100 * 3; |
| 119 | |
| 120 | const newX = Math.max( |
| 121 | 0, |
| 122 | Math.min(100, lastObjectPosition.x + deltaXPercent), |
| 123 | ); |
| 124 | const newY = Math.max( |
| 125 | 0, |
| 126 | Math.min(100, lastObjectPosition.y + deltaYPercent), |
| 127 | ); |
| 128 | |
| 129 | setCropSettings((prev) => ({ |
| 130 | ...prev, |
| 131 | objectPosition: { x: newX, y: newY }, |
| 132 | })); |
| 133 | }; |
| 134 | |
| 135 | const handleMouseUp = () => { |
| 136 | setIsDragging(false); |
| 137 | }; |
| 138 | |
| 139 | if (isDragging) { |
| 140 | window.addEventListener("mousemove", handleMouseMove); |
| 141 | window.addEventListener("mouseup", handleMouseUp); |
| 142 | } |
| 143 | |
| 144 | return () => { |
| 145 | window.removeEventListener("mousemove", handleMouseMove); |
| 146 | window.removeEventListener("mouseup", handleMouseUp); |
| 147 | }; |
| 148 | }, [isDragging, dragStart, lastObjectPosition, displayDimensions]); |
| 149 | |
| 150 | // Calculate Background Image Style |
| 151 | const getBackgroundStyle = () => { |
| 152 | if (!naturalDimensions || displayDimensions.width === 0) return {}; |
| 153 | |
| 154 | const { width: naturalW, height: naturalH } = naturalDimensions; |
| 155 | const { width: boxW, height: boxH } = displayDimensions; |
| 156 | |
| 157 | // Calculate cover scale (what object-fit: cover does) |
| 158 | const scaleX = boxW / naturalW; |
| 159 | const scaleY = boxH / naturalH; |
| 160 | const coverScale = Math.max(scaleX, scaleY); |
| 161 | |
| 162 | // Apply Zoom |
| 163 | const zoom = cropSettings.zoom || 1; |
| 164 | |
| 165 | // Target dimensions of the image as rendered in the foreground |
| 166 | const renderedW = naturalW * coverScale * zoom; |
| 167 | const renderedH = naturalH * coverScale * zoom; |
| 168 | |
| 169 | // Calculate Offset based on objectPosition |
| 170 | const pX = cropSettings.objectPosition.x / 100; |
| 171 | const pY = cropSettings.objectPosition.y / 100; |
| 172 | |
| 173 | const offsetX = pX * (boxW - renderedW); |
| 174 | const offsetY = pY * (boxH - renderedH); |
| 175 | |
| 176 | return { |
| 177 | width: renderedW, |
| 178 | height: renderedH, |
| 179 | transform: `translate(${offsetX}px, ${offsetY}px)`, |
| 180 | }; |
| 181 | }; |
| 182 | |
| 183 | const bgStyle = getBackgroundStyle(); |
| 184 | |
| 185 | return ( |
| 186 | <Credenza open={open} onOpenChange={onOpenChange}> |
| 187 | <CredenzaContent className="flex h-[85dvh] w-full max-w-5xl flex-col gap-0 overflow-hidden border-zinc-800 bg-zinc-950 p-0 text-foreground"> |
| 188 | <CredenzaHeader className="z-10 border-b border-zinc-800 bg-zinc-950 px-6 py-4"> |
| 189 | <CredenzaTitle>Crop Image</CredenzaTitle> |
| 190 | </CredenzaHeader> |
| 191 | |
| 192 | {/* Main Canvas */} |
| 193 | <div className="relative flex w-full flex-1 items-center justify-center overflow-hidden bg-zinc-900 p-12"> |
| 194 | {/* The Crop Box Container */} |
| 195 | <div |
| 196 | className="relative shadow-2xl" |
| 197 | style={{ |
| 198 | width: displayDimensions.width, |
| 199 | height: displayDimensions.height, |
| 200 | }} |
| 201 | > |
| 202 | {/* Background Layer (Full Image Preview) */} |
| 203 | <div className="pointer-events-none absolute inset-0 overflow-visible"> |
| 204 | {naturalDimensions && ( |
| 205 | <div |
| 206 | className="absolute top-0 left-0 origin-top-left" |
| 207 | style={bgStyle} |
| 208 | > |
| 209 | {/** biome-ignore lint/performance/noImgElement: Necessary for user provided links */} |
| 210 | <img |
| 211 | src={imageUrl} |
| 212 | alt="" |
| 213 | className="h-full w-full object-fill opacity-40 grayscale-30" |
| 214 | /> |
| 215 | {/* Overlay to dim it further */} |
| 216 | <div className="absolute inset-0 bg-black/70" /> |
| 217 | </div> |
| 218 | )} |
| 219 | </div> |
| 220 | |
| 221 | {/* Foreground Layer (The Crop Box) */} |
| 222 | <div |
| 223 | ref={containerRef} |
| 224 | className={cn( |
| 225 | "absolute inset-0 z-10 overflow-hidden border-2 border-primary bg-black/20 ring ring-white/20", |
| 226 | isDragging ? "cursor-grabbing" : "cursor-grab", |
| 227 | )} |
| 228 | onMouseDown={handleMouseDown} |
| 229 | > |
| 230 | {/* Grid Overlay */} |
| 231 | <div className="pointer-events-none absolute inset-0 z-20 opacity-50"> |
| 232 | <div className="absolute top-0 bottom-0 left-1/3 border-l border-white/30" /> |
| 233 | <div className="absolute top-0 right-1/3 bottom-0 border-l border-white/30" /> |
| 234 | <div className="absolute top-1/3 right-0 left-0 border-t border-white/30" /> |
| 235 | <div className="absolute right-0 bottom-1/3 left-0 border-t border-white/30" /> |
| 236 | </div> |
| 237 | |
| 238 | {/* The Actual Cropped Image */} |
| 239 | {/** biome-ignore lint/performance/noImgElement: Necessary for user provided links */} |
| 240 | <img |
| 241 | src={imageUrl} |
| 242 | onLoad={onImageLoad} |
| 243 | alt="Crop preview" |
| 244 | className="h-full w-full transition-transform duration-75 select-none" |
| 245 | draggable={false} |
| 246 | style={{ |
| 247 | objectFit: cropSettings.objectFit, |
| 248 | objectPosition: `${cropSettings.objectPosition.x}% ${cropSettings.objectPosition.y}%`, |
| 249 | transform: `scale(${cropSettings.zoom ?? 1})`, |
| 250 | transformOrigin: `${cropSettings.objectPosition.x}% ${cropSettings.objectPosition.y}%`, |
| 251 | pointerEvents: "none", |
| 252 | }} |
| 253 | /> |
| 254 | </div> |
| 255 | </div> |
| 256 | </div> |
| 257 | |
| 258 | {/* Controls */} |
| 259 | <div className="z-10 flex flex-col gap-4 border-t border-zinc-800 bg-zinc-950 px-6 py-4"> |
| 260 | <div className="mx-auto flex w-full max-w-md items-center gap-4"> |
| 261 | <Minus |
| 262 | className="h-4 w-4 cursor-pointer text-muted-foreground hover:text-white" |
| 263 | onClick={() => |
| 264 | setCropSettings((s) => ({ |
| 265 | ...s, |
| 266 | zoom: Math.max(1, (s.zoom || 1) - 0.1), |
| 267 | })) |
| 268 | } |
| 269 | /> |
| 270 | <Slider |
| 271 | value={[cropSettings.zoom || 1]} |
| 272 | min={1} |
| 273 | max={3} |
| 274 | step={0.01} |
| 275 | onValueChange={(v) => |
| 276 | setCropSettings((s) => ({ ...s, zoom: v[0] })) |
| 277 | } |
| 278 | className="flex-1" |
| 279 | /> |
| 280 | <Plus |
| 281 | className="h-4 w-4 cursor-pointer text-muted-foreground hover:text-white" |
| 282 | onClick={() => |
| 283 | setCropSettings((s) => ({ |
| 284 | ...s, |
| 285 | zoom: Math.min(3, (s.zoom || 1) + 0.1), |
| 286 | })) |
| 287 | } |
| 288 | /> |
| 289 | </div> |
| 290 | |
| 291 | <div className="flex items-center justify-between"> |
| 292 | <div className="text-xs text-muted-foreground"> |
| 293 | Drag to pan • Scroll to zoom |
| 294 | </div> |
| 295 | <div className="flex gap-2"> |
| 296 | <Button variant="ghost" onClick={() => onOpenChange(false)}> |
| 297 | Cancel |
| 298 | </Button> |
| 299 | <Button onClick={() => onSave(cropSettings)}>Apply Crop</Button> |
| 300 | </div> |
| 301 | </div> |
| 302 | </div> |
| 303 | </CredenzaContent> |
| 304 | </Credenza> |
| 305 | ); |
| 306 | } |
| 307 |