| 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 |