返回 presentation-ai
ImagePreview.tsx
1 "use client";
2
3 import { Download, Image as ImageIcon, Minus, Plus, Scan } from "lucide-react";
4 import { type TElement } from "platejs";
5 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
7 import { Button } from "@/components/ui/button";
8 import { Slider } from "@/components/ui/slider";
9 import { cn } from "@/lib/utils";
10 import { type RootImage as RootImageType } from "../../../utils/parser";
11 import { type ImageCropSettings } from "../../../utils/types";
12 import { type ImageDimensions } from "./useImageDimensions";
13
14 // Local definition since this component is deprecated/unused but kept for reference
15 type EditorMode = "generate" | "crop" | "embed" | "search" | "your-images";
16
17 interface ImagePreviewProps {
18 element: TElement & RootImageType;
19 currentMode: EditorMode;
20 localCropSettings: ImageCropSettings;
21 imageDimensions: ImageDimensions;
22 onCropSettingsChange: (settings: ImageCropSettings) => void;
23 onUnsavedChanges?: (hasChanges: boolean) => void;
24 hideControls?: boolean;
25 }
26
27 export function ImagePreview({
28 element,
29 currentMode,
30 localCropSettings,
31 imageDimensions,
32 onCropSettingsChange,
33 hideControls = false,
34 }: ImagePreviewProps) {
35 const zoom = useMemo(() => {
36 const currentZoom = localCropSettings.zoom ?? 1;
37 return Math.max(1, Math.min(2, currentZoom));
38 }, [localCropSettings]);
39
40 const setZoom = useCallback(
41 (zoom: number) => {
42 const clampedZoom = Math.max(1, Math.min(2, zoom));
43 onCropSettingsChange({
44 ...localCropSettings,
45 zoom: clampedZoom,
46 });
47 },
48 [localCropSettings, onCropSettingsChange],
49 );
50
51 // Custom crop state for panning
52 const [isDragging, setIsDragging] = useState(false);
53 const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
54 const [lastObjectPosition, setLastObjectPosition] = useState({
55 x: localCropSettings.objectPosition.x,
56 y: localCropSettings.objectPosition.y,
57 });
58 const containerRef = useRef<HTMLDivElement>(null);
59
60 const handleMouseDown = useCallback(
61 (e: React.MouseEvent) => {
62 e.preventDefault(); // Prevent default behavior
63 setIsDragging(true);
64 setDragStart({ x: e.clientX, y: e.clientY });
65 setLastObjectPosition({
66 x: localCropSettings.objectPosition.x,
67 y: localCropSettings.objectPosition.y,
68 });
69 },
70 [localCropSettings.objectPosition],
71 );
72
73 const handleMouseMove = useCallback(
74 (e: MouseEvent) => {
75 if (!isDragging || !containerRef.current) return;
76
77 e.preventDefault(); // Prevent text selection
78
79 const deltaX = e.clientX - dragStart.x;
80 const deltaY = e.clientY - dragStart.y;
81
82 // Get container dimensions
83 const containerRect = containerRef.current.getBoundingClientRect();
84 const containerWidth = containerRect.width;
85 const containerHeight = containerRect.height;
86
87 // Convert pixel movement to percentage with increased sensitivity (3x faster)
88 const deltaXPercent = (deltaX / containerWidth) * 100 * 3;
89 const deltaYPercent = (deltaY / containerHeight) * 100 * 3;
90
91 // Calculate new object position
92 const newX = Math.max(
93 0,
94 Math.min(100, lastObjectPosition.x + deltaXPercent),
95 );
96 const newY = Math.max(
97 0,
98 Math.min(100, lastObjectPosition.y + deltaYPercent),
99 );
100
101 onCropSettingsChange({
102 ...localCropSettings,
103 objectPosition: { x: newX, y: newY },
104 });
105 },
106 [
107 isDragging,
108 dragStart,
109 lastObjectPosition,
110 localCropSettings,
111 onCropSettingsChange,
112 ],
113 );
114
115 const handleMouseUp = useCallback(() => {
116 if (isDragging) {
117 setIsDragging(false);
118 }
119 }, [isDragging]);
120
121 const handleWheel = useCallback(
122 (e: React.WheelEvent) => {
123 // Only zoom on wheel if we are in crop mode
124 if (currentMode !== "crop") return;
125
126 e.preventDefault();
127
128 const delta = e.deltaY > 0 ? -0.05 : 0.05;
129 const newZoom = Math.max(1, Math.min(2, zoom + delta));
130
131 setZoom(newZoom);
132 },
133 [zoom, setZoom, currentMode],
134 );
135
136 // Add global mouse event listeners for dragging
137 useEffect(() => {
138 if (isDragging) {
139 const preventSelection = (e: Event) => e.preventDefault();
140
141 // Add global event listeners
142 document.addEventListener("mousemove", handleMouseMove);
143 document.addEventListener("mouseup", handleMouseUp);
144 document.addEventListener("selectstart", preventSelection);
145 document.addEventListener("dragstart", preventSelection);
146
147 return () => {
148 document.removeEventListener("mousemove", handleMouseMove);
149 document.removeEventListener("mouseup", handleMouseUp);
150 document.removeEventListener("selectstart", preventSelection);
151 document.removeEventListener("dragstart", preventSelection);
152 };
153 }
154 }, [isDragging, handleMouseMove, handleMouseUp]);
155
156 const handleDownload = useCallback(async () => {
157 if (!element.url) return;
158 try {
159 const response = await fetch(element.url);
160 const blob = await response.blob();
161 const url = window.URL.createObjectURL(blob);
162 const a = document.createElement("a");
163 a.href = url;
164 a.download = `image-${Date.now()}.png`;
165 document.body.appendChild(a);
166 a.click();
167 document.body.removeChild(a);
168 window.URL.revokeObjectURL(url);
169 } catch (err) {
170 console.error("Failed to download image:", err);
171 }
172 }, [element.url]);
173
174 if (!element.url) {
175 return (
176 <div className="flex h-full min-h-75 w-full animate-in flex-col items-center justify-center gap-4 rounded-lg border border-dashed bg-muted/30 p-8 text-center duration-500 fade-in">
177 <div className="rounded-full bg-muted p-4">
178 <ImageIcon className="h-8 w-8 text-muted-foreground/50" />
179 </div>
180 <div className="space-y-1">
181 <h3 className="font-medium">No image selected</h3>
182 <p className="mx-auto max-w-xs text-sm text-muted-foreground">
183 Generate a new image or search for one to get started.
184 </p>
185 </div>
186 </div>
187 );
188 }
189
190 return (
191 <div className="group relative flex h-full w-full flex-col items-center justify-center">
192 {/* Image Preview Area */}
193 <div
194 className={cn(
195 "relative overflow-hidden rounded-md shadow transition-all duration-300",
196 currentMode === "crop" && "ring-2 ring-primary ring-offset-2",
197 )}
198 style={{
199 width: imageDimensions.width * imageDimensions.scale,
200 height: imageDimensions.height * imageDimensions.scale,
201 }}
202 >
203 <div
204 ref={containerRef}
205 className={cn(
206 "h-full w-full bg-muted",
207 currentMode === "crop"
208 ? "cursor-grab active:cursor-grabbing"
209 : "cursor-default",
210 )}
211 onMouseDown={currentMode === "crop" ? handleMouseDown : undefined}
212 onWheel={handleWheel}
213 onDragStart={(e) => e.preventDefault()}
214 >
215 {/** biome-ignore lint/performance/noImgElement: This is a valid use case */}
216 <img
217 src={element.url}
218 alt={element.query ?? "Presentation image"}
219 loading="lazy"
220 decoding="async"
221 className="h-full w-full transition-transform duration-75"
222 style={{
223 objectFit: localCropSettings.objectFit,
224 objectPosition: `${localCropSettings.objectPosition.x}% ${localCropSettings.objectPosition.y}%`,
225 transform: `scale(${localCropSettings.zoom ?? 1})`,
226 transformOrigin: `${localCropSettings.objectPosition.x}% ${localCropSettings.objectPosition.y}%`,
227 pointerEvents: "none",
228 }}
229 draggable={false}
230 />
231
232 {/* Crop Grid Overlay */}
233 {currentMode === "crop" && (
234 <div className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
235 <div className="absolute inset-0 border border-white/30" />
236 <div className="absolute top-0 bottom-0 left-1/3 border-l border-white/20" />
237 <div className="absolute top-0 right-1/3 bottom-0 border-l border-white/20" />
238 <div className="absolute top-1/3 right-0 left-0 border-t border-white/20" />
239 <div className="absolute right-0 bottom-1/3 left-0 border-t border-white/20" />
240 </div>
241 )}
242 </div>
243 </div>
244
245 {/* Floating Controls Toolbar */}
246 {!hideControls && (
247 <div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 translate-y-2 items-center gap-2 rounded-full border bg-background/80 p-1.5 px-3 opacity-0 shadow-lg backdrop-blur-sm transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
248 <Button
249 variant="ghost"
250 size="icon"
251 className="h-8 w-8 rounded-full"
252 onClick={handleDownload}
253 title="Download Image"
254 >
255 <Download className="h-4 w-4" />
256 </Button>
257
258 {currentMode === "crop" && (
259 <>
260 <div className="mx-1 h-4 w-px bg-border" />
261 <Button
262 variant="ghost"
263 size="icon"
264 className="h-8 w-8 rounded-full"
265 onClick={() => setZoom(Math.max(1, zoom - 0.1))}
266 disabled={zoom <= 1}
267 >
268 <Minus className="h-3 w-3" />
269 </Button>
270 <div className="w-24 px-2">
271 <Slider
272 value={[zoom]}
273 onValueChange={([value]) => setZoom(value ?? 1)}
274 min={1}
275 max={2}
276 step={0.01}
277 className="cursor-pointer"
278 />
279 </div>
280 <Button
281 variant="ghost"
282 size="icon"
283 className="h-8 w-8 rounded-full"
284 onClick={() => setZoom(Math.min(2, zoom + 0.1))}
285 disabled={zoom >= 2}
286 >
287 <Plus className="h-3 w-3" />
288 </Button>
289 <div className="mx-1 h-4 w-px bg-border" />
290 <Button
291 variant="ghost"
292 size="icon"
293 className={cn(
294 "h-8 w-8 rounded-full",
295 localCropSettings.objectFit === "contain" &&
296 "bg-muted text-primary",
297 )}
298 onClick={() => {
299 const newFit =
300 localCropSettings.objectFit === "cover"
301 ? "contain"
302 : "cover";
303 onCropSettingsChange({
304 ...localCropSettings,
305 objectFit: newFit,
306 });
307 }}
308 title="Toggle Fit/Cover"
309 >
310 <Scan className="h-4 w-4" />
311 </Button>
312 </>
313 )}
314 </div>
315 )}
316 </div>
317 );
318 }
319
319 lines Plain Text