返回 presentation-ai
CropModal.tsx
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
307 lines Plain Text