stunning screenshots in seconds https://moocup.jaydip.me
at from-github 536 lines 20 kB view raw
1import React, { useRef, useState, useEffect } from 'react'; 2import { toast } from 'sonner'; 3import { extractDominantColor } from '@/utils/colorExtractor'; 4import { ImageIcon, Clipboard } from 'lucide-react'; 5import { useMockupStore } from '@/contexts/MockupContext'; 6import { Button } from '@/components/ui/button'; 7 8// Responsive configuration 9const getResponsiveConfig = () => { 10 const viewportWidth = window.innerWidth; 11 const viewportHeight = window.innerHeight; 12 const isMobile = viewportWidth < 768; 13 const isTablet = viewportWidth >= 768 && viewportWidth < 1024; 14 const basePadding = isMobile ? 40 : isTablet ? 100 : 200; 15 const scaleMultiplier = isMobile ? 0.95 : isTablet ? 0.85 : 0.8; 16 const maxWidth = isMobile ? 1000 : isTablet ? 1100 : 1200; 17 const maxHeight = isMobile ? 700 : isTablet ? 750 : 800; 18 const availableWidth = viewportWidth - basePadding; 19 const availableHeight = viewportHeight - basePadding; 20 const maxContainerWidth = Math.min(availableWidth * scaleMultiplier, maxWidth); 21 const maxContainerHeight = Math.min(availableHeight * scaleMultiplier, maxHeight); 22 return { 23 isMobile, 24 isTablet, 25 basePadding, 26 scaleMultiplier, 27 maxContainerWidth, 28 maxContainerHeight, 29 dropZoneWidth: isMobile ? '95%' : isTablet ? '70%' : '60%', 30 dropZoneHeight: isMobile ? '60%' : '50%', 31 }; 32}; 33 34// Smart image optimization utility - only compress if needed 35const optimizeImage = (file: File): Promise<string> => { 36 return new Promise((resolve, reject) => { 37 if (file.size < 2 * 1024 * 1024) { 38 const reader = new FileReader(); 39 reader.onload = (e) => resolve(e.target?.result as string); 40 reader.onerror = reject; 41 reader.readAsDataURL(file); 42 return; 43 } 44 const canvas = document.createElement('canvas'); 45 const ctx = canvas.getContext('2d'); 46 const img = new Image(); 47 img.onload = () => { 48 const MAX_WIDTH = 2400; 49 const MAX_HEIGHT = 1800; 50 let { width, height } = img; 51 if (width > MAX_WIDTH || height > MAX_HEIGHT) { 52 const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height); 53 width = Math.round(width * ratio); 54 height = Math.round(height * ratio); 55 } 56 canvas.width = width; 57 canvas.height = height; 58 ctx?.drawImage(img, 0, 0, width, height); 59 let optimizedDataUrl; 60 try { 61 optimizedDataUrl = canvas.toDataURL('image/png'); 62 if (optimizedDataUrl.length > 5 * 1024 * 1024) { 63 optimizedDataUrl = canvas.toDataURL('image/jpeg', 0.92); 64 } 65 } catch (e) { 66 optimizedDataUrl = canvas.toDataURL('image/jpeg', 0.92); 67 } 68 resolve(optimizedDataUrl); 69 }; 70 img.onerror = reject; 71 img.src = URL.createObjectURL(file); 72 }); 73}; 74 75// Fetch demo image as a File object 76const fetchDemoImage = async (): Promise<File> => { 77 try { 78 const response = await fetch('/assets/demo.webp'); 79 if (!response.ok) throw new Error('Failed to fetch demo image'); 80 const blob = await response.blob(); 81 return new File([blob], 'demo.webp', { type: 'image/webp' }); 82 } catch (error) { 83 throw new Error('Error fetching demo image: ' + error); 84 } 85}; 86 87export const Canvas: React.FC = () => { 88 const { 89 uploadedImage, 90 backgroundType, 91 backgroundImage, 92 backgroundColor, 93 gradientDirection, 94 gradientColors, 95 devicePosition, 96 rotation3D, 97 imageBorder, 98 margin, 99 fixedMargin, 100 setUploadedImage, 101 setImageBorder, 102 } = useMockupStore(); 103 104 const fileInputRef = useRef<HTMLInputElement>(null); 105 const [isDragOver, setIsDragOver] = useState(false); 106 const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null); 107 const [responsiveConfig, setResponsiveConfig] = useState(getResponsiveConfig()); 108 const [showPasteHint, setShowPasteHint] = useState(false); 109 110 // Update responsive config on window resize 111 useEffect(() => { 112 const handleResize = () => { 113 setResponsiveConfig(getResponsiveConfig()); 114 }; 115 window.addEventListener('resize', handleResize); 116 return () => window.removeEventListener('resize', handleResize); 117 }, []); 118 119 // Clipboard paste functionality 120 useEffect(() => { 121 const handlePaste = async (e: ClipboardEvent) => { 122 const items = e.clipboardData?.items; 123 if (!items) return; 124 for (const item of Array.from(items)) { 125 if (item.type.startsWith('image/')) { 126 const file = item.getAsFile(); 127 if (file) { 128 if (uploadedImage) { 129 setShowPasteHint(true); 130 toast('Image in clipboard detected! Clear current image to paste new one.', { 131 duration: 3000, 132 action: { 133 label: 'Clear & Paste', 134 onClick: () => { 135 setUploadedImage(null); 136 localStorage.removeItem('demoImage'); 137 setTimeout(() => handleImageUpload(file), 100); 138 }, 139 }, 140 }); 141 setTimeout(() => setShowPasteHint(false), 3000); 142 } else { 143 await handleImageUpload(file); 144 localStorage.removeItem('demoImage'); 145 toast('Image pasted successfully!'); 146 } 147 } 148 break; 149 } 150 } 151 }; 152 document.addEventListener('paste', handlePaste); 153 return () => document.removeEventListener('paste', handlePaste); 154 }, [uploadedImage, setUploadedImage]); 155 156 // Load demo image from local storage on mount 157 // useEffect(() => { 158 // const savedDemoImage = localStorage.getItem('demoImage'); 159 // if (savedDemoImage === '/assets/demo.webp') { 160 // handleDemoImage(); 161 // } 162 // }, [setUploadedImage]); 163 164 const handleDrop = (e: React.DragEvent) => { 165 e.preventDefault(); 166 e.stopPropagation(); 167 setIsDragOver(false); 168 const files = Array.from(e.dataTransfer.files); 169 const imageFile = files.find((file) => file.type.startsWith('image/')); 170 if (imageFile) { 171 localStorage.removeItem('demoImage'); 172 handleImageUpload(imageFile); 173 } 174 }; 175 176 const handleDragOver = (e: React.DragEvent) => { 177 e.preventDefault(); 178 e.stopPropagation(); 179 setIsDragOver(true); 180 }; 181 182 const hexToRgb = (hex: string): { r: number; g: number; b: number } => { 183 const r = parseInt(hex.slice(1, 3), 16); 184 const g = parseInt(hex.slice(3, 5), 16); 185 const b = parseInt(hex.slice(5, 7), 16); 186 return { r, g, b }; 187 }; 188 189 const handleImageUpload = async (file: File) => { 190 let loadingToast: string | number | undefined; 191 if (file.size > 1024 * 1024) { 192 loadingToast = toast('Processing image...', { duration: Infinity }); 193 } 194 try { 195 const optimizedDataUrl = await optimizeImage(file); 196 setUploadedImage(optimizedDataUrl); 197 requestAnimationFrame(async () => { 198 try { 199 const dominantColor = await extractDominantColor(optimizedDataUrl); 200 const validHex = /^#[0-9A-Fa-f]{6}$/.test(dominantColor) ? dominantColor : '#9CA389'; 201 const { r, g, b } = hexToRgb(validHex); 202 const borderWidth = responsiveConfig.isMobile ? 4 : 8; 203 const initialOpacity = 0.5; 204 setImageBorder({ 205 enabled: true, 206 width: borderWidth, 207 color: `rgba(${r}, ${g}, ${b}, ${initialOpacity})`, 208 shadow: `rgba(0, 0, 0, 0.16) 0px 3px 6px, rgba(0, 0, 0, 0.23) 0px 3px 6px`, 209 }); 210 if (loadingToast) { 211 toast.dismiss(loadingToast); 212 } 213 toast('Image uploaded with transparent border!'); 214 } catch (error) { 215 const borderWidth = responsiveConfig.isMobile ? 4 : 8; 216 const initialOpacity = 0.5; 217 setImageBorder({ 218 enabled: true, 219 width: borderWidth, 220 color: `rgba(156, 163, 137, ${initialOpacity})`, 221 shadow: `rgba(0, 0, 0, 0.16) 0px 3px 6px, rgba(0, 0, 0, 0.23) 0px 3px 6px`, 222 }); 223 if (loadingToast) { 224 toast.dismiss(loadingToast); 225 } 226 toast('Image uploaded with default transparent border!'); 227 } 228 }); 229 } catch (error) { 230 const reader = new FileReader(); 231 reader.onload = async (e) => { 232 const result = e.target?.result as string; 233 setUploadedImage(result); 234 try { 235 const dominantColor = await extractDominantColor(result); 236 const validHex = /^#[0-9A-Fa-f]{6}$/.test(dominantColor) ? dominantColor : '#9CA389'; 237 const { r, g, b } = hexToRgb(validHex); 238 const borderWidth = responsiveConfig.isMobile ? 4 : 8; 239 const initialOpacity = 0.5; 240 setImageBorder({ 241 enabled: true, 242 width: borderWidth, 243 color: `rgba(${r}, ${g}, ${b}, ${initialOpacity})`, 244 shadow: `rgba(0, 0, 0, 0.16) 0px 3px 6px, rgba(0, 0, 0, 0.23) 0px 3px 6px`, 245 }); 246 toast('Image uploaded with transparent border!'); 247 } catch (error) { 248 const borderWidth = responsiveConfig.isMobile ? 4 : 8; 249 const initialOpacity = 0.5; 250 setImageBorder({ 251 enabled: true, 252 width: borderWidth, 253 color: `rgba(156, 163, 137, ${initialOpacity})`, 254 shadow: `rgba(0, 0, 0, 0.16) 0px 3px 6px, rgba(0, 0, 0, 0.23) 0px 3px 6px`, 255 }); 256 toast('Image uploaded with default transparent border!'); 257 } 258 }; 259 reader.readAsDataURL(file); 260 if (loadingToast) { 261 toast.dismiss(loadingToast); 262 } 263 console.error('Image optimization failed, using fallback:', error); 264 } 265 }; 266 267 const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 268 const file = e.target.files?.[0]; 269 if (file) { 270 localStorage.removeItem('demoImage'); 271 handleImageUpload(file); 272 } 273 }; 274 275 const handleDemoImage = async (e?: React.MouseEvent<HTMLButtonElement>) => { 276 try { 277 e?.stopPropagation(); 278 const demoFile = await fetchDemoImage(); 279 await handleImageUpload(demoFile); 280 localStorage.setItem('demoImage', '/assets/demo.webp'); 281 toast('Demo image applied successfully!'); 282 } catch (error) { 283 toast.error('Failed to load demo image.'); 284 console.error(error); 285 } 286 }; 287 288 // Calculate image dimensions with responsive scaling 289 useEffect(() => { 290 if (uploadedImage) { 291 const img = new Image(); 292 img.onload = () => { 293 const { maxContainerWidth, maxContainerHeight } = responsiveConfig; 294 const originalWidth = img.width; 295 const originalHeight = img.height; 296 const scaleX = maxContainerWidth / originalWidth; 297 const scaleY = maxContainerHeight / originalHeight; 298 const optimalScale = Math.min(scaleX, scaleY, 1); 299 const scaledWidth = originalWidth * optimalScale; 300 const scaledHeight = originalHeight * optimalScale; 301 setImageDimensions({ 302 width: Math.round(scaledWidth), 303 height: Math.round(scaledHeight), 304 }); 305 }; 306 img.src = uploadedImage; 307 } else { 308 setImageDimensions(null); 309 } 310 }, [uploadedImage, responsiveConfig]); 311 312 // Calculate canvas style based on fixed margin setting 313 const getCanvasStyle = () => { 314 if (!fixedMargin || !imageDimensions || !uploadedImage) { 315 // Default behavior - canvas takes full flex-1 space 316 return { 317 width: '100%', 318 height: '100%', 319 }; 320 } 321 322 // Fixed margin behavior - shrink canvas to image + margin 323 const totalWidth = imageDimensions.width + margin.left + margin.right; 324 const totalHeight = imageDimensions.height + margin.top + margin.bottom; 325 326 return { 327 width: `${totalWidth}px`, 328 height: `${totalHeight}px`, 329 }; 330 }; 331 332 const getBackgroundStyle = () => { 333 const baseStyle: React.CSSProperties = {}; 334 335 if (backgroundType === 'pattern' && backgroundImage) { 336 baseStyle.backgroundImage = `url(${backgroundImage})`; 337 baseStyle.backgroundSize = 'cover'; 338 baseStyle.backgroundPosition = 'center'; 339 baseStyle.backgroundRepeat = 'no-repeat'; 340 } else if (backgroundType === 'gradient') { 341 const direction = gradientDirection.replace('to-', ''); 342 const degreeMap: { [key: string]: string } = { 343 r: '90deg', 344 br: '135deg', 345 b: '180deg', 346 bl: '225deg', 347 l: '270deg', 348 tl: '315deg', 349 t: '0deg', 350 tr: '45deg', 351 }; 352 const angle = degreeMap[direction] || '135deg'; 353 baseStyle.background = `linear-gradient(${angle}, ${gradientColors.join(', ')})`; 354 } else { 355 baseStyle.backgroundColor = backgroundColor; 356 } 357 358 if (fixedMargin && imageDimensions && uploadedImage) { 359 // When fixed margin is enabled, background takes full canvas size 360 baseStyle.width = '100%'; 361 baseStyle.height = '100%'; 362 } else { 363 // Default behavior - background fills the entire flex space 364 baseStyle.width = '100%'; 365 baseStyle.height = '100%'; 366 } 367 368 return baseStyle; 369 }; 370 371 const getImageContainerStyle = () => { 372 if (!fixedMargin || !imageDimensions || !uploadedImage) { 373 // Default behavior - center the image 374 return { 375 display: 'flex', 376 alignItems: 'center', 377 justifyContent: 'center', 378 width: '100%', 379 height: '100%', 380 }; 381 } 382 383 // Fixed margin behavior - position image with exact margins 384 return { 385 position: 'absolute' as const, 386 top: `${margin.top}px`, 387 right: `${margin.right}px`, 388 bottom: `${margin.bottom}px`, 389 left: `${margin.left}px`, 390 display: 'flex', 391 alignItems: 'center', 392 justifyContent: 'center', 393 }; 394 }; 395 396 const getImageStyle = () => { 397 const dimensions = imageDimensions || { width: 400, height: 300 }; 398 const baseStyles = { 399 width: `${dimensions.width}px`, 400 height: `${dimensions.height}px`, 401 transform: ` 402 translate(${devicePosition.x}px, ${devicePosition.y}px) 403 scale(${devicePosition.scale}) 404 rotate(${devicePosition.rotation}deg) 405 rotateX(${rotation3D.rotateX}deg) 406 rotateY(${rotation3D.rotateY}deg) 407 rotateZ(${rotation3D.rotateZ}deg) 408 skew(${rotation3D.skew}deg) 409 `, 410 transformOrigin: 'center center', 411 transformStyle: 'preserve-3d' as const, 412 }; 413 414 if (imageBorder.enabled) { 415 return { 416 ...baseStyles, 417 border: `${imageBorder.width}px solid ${imageBorder.color}`, 418 borderRadius: `${imageBorder.radius}px`, 419 boxShadow: imageBorder.shadow, 420 }; 421 } 422 return baseStyles; 423 }; 424 425 const getDropZoneClasses = () => { 426 const baseClasses = 'relative transition-all duration-300 cursor-pointer border-2 border-dashed rounded-xl bg-gray-900/50 mx-auto top-[15%] md:top-[25%]'; 427 const hoverClasses = 'hover:bg-gray-900/70'; 428 const dragOverClasses = isDragOver ? 'scale-105 border-primary' : 'border-gray-400'; 429 return `${baseClasses} ${hoverClasses} ${dragOverClasses}`; 430 }; 431 432 const getDropZoneOverlayText = () => { 433 const primaryText = uploadedImage ? 'Drop to replace image' : 'Drop image here'; 434 const iconSize = responsiveConfig.isMobile ? 32 : 48; 435 const textSize = responsiveConfig.isMobile ? 'text-base' : 'text-xl'; 436 return { primaryText, iconSize, textSize }; 437 }; 438 439 return ( 440 <div 441 className="flex-1 flex items-center justify-center relative transition-all duration-300" 442 style={fixedMargin && imageDimensions && uploadedImage ? { backgroundColor: 'black' } : {}} 443 > 444 <div 445 className="transition-all duration-300 relative overflow-hidden" 446 style={getCanvasStyle()} 447 data-mockup-canvas 448 onDrop={uploadedImage ? handleDrop : undefined} 449 onDragOver={uploadedImage ? handleDragOver : undefined} 450 > 451 <div 452 className="transition-all duration-300 relative" 453 style={getBackgroundStyle()} 454 > 455 {uploadedImage ? ( 456 <div style={getImageContainerStyle()}> 457 <div className="relative"> 458 <img 459 src={uploadedImage} 460 alt="Uploaded mockup" 461 className="relative size-full transition-all duration-300 will-transform select-none" 462 style={getImageStyle()} 463 crossOrigin="anonymous" 464 onDragOver={(e) => e.stopPropagation()} 465 /> 466 {isDragOver && ( 467 <div className="absolute inset-0 bg-primary/30 rounded-xl flex items-center justify-center z-10"> 468 <div 469 className={`text-primary font-semibold bg-white/90 px-6 py-3 rounded-lg ${getDropZoneOverlayText().textSize}`} 470 > 471 {getDropZoneOverlayText().primaryText} 472 </div> 473 </div> 474 )} 475 {showPasteHint && ( 476 <div className="absolute inset-0 bg-blue-500/20 rounded-xl flex items-center justify-center z-20 animate-pulse"> 477 <div className="text-blue-600 font-semibold bg-white/95 px-6 py-3 rounded-lg text-center shadow-lg"> 478 <Clipboard className="inline-block mr-2 h-5 w-5" /> 479 Image ready to paste! 480 </div> 481 </div> 482 )} 483 </div> 484 </div> 485 ) : ( 486 <div 487 className={getDropZoneClasses()} 488 style={{ 489 width: responsiveConfig.dropZoneWidth, 490 height: responsiveConfig.dropZoneHeight, 491 }} 492 onDrop={handleDrop} 493 onDragOver={handleDragOver} 494 onClick={() => fileInputRef.current?.click()} 495 > 496 <div className="absolute inset-0 flex flex-col items-center justify-center "> 497 <ImageIcon size={getDropZoneOverlayText().iconSize} className="text-gray-100 mb-4" /> 498 <div className="text-center text-white px-4"> 499 <p className={`font-semibold mb-1 ${responsiveConfig.isMobile ? 'text-base' : 'text-lg'}`}> 500 Drop image here or click to upload 501 </p> 502 <p className={`text-white/70 mb-2 ${responsiveConfig.isMobile ? 'text-xs' : 'text-sm'}`}> 503 Supports JPG, PNG 504 </p> 505 <div className="flex items-center justify-center gap-2 text-white font-semibold"> 506 <Clipboard className="h-4 w-4" strokeWidth={2.5} /> 507 <span className={`${responsiveConfig.isMobile ? 'text-xs' : 'text-sm'} text-white/70`}> 508 Or paste image (Ctrl+V) 509 </span> 510 </div> 511 </div> 512 <Button 513 variant="outline" 514 className="mt-6 z-50 rounded-full bg-gray-900/50 hover:bg-gray-900 border-2 border-primary" 515 onClick={handleDemoImage} 516 > 517 Use Demo Image 518 </Button> 519 </div> 520 {isDragOver && ( 521 <div className="absolute inset-0 bg-primary/20 rounded-xl flex items-center justify-center"> 522 <div 523 className={`text-primary font-semibold bg-white/90 px-6 py-3 rounded-lg ${getDropZoneOverlayText().textSize}`} 524 > 525 Drop image here 526 </div> 527 </div> 528 )} 529 </div> 530 )} 531 </div> 532 <input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileSelect} className="hidden" /> 533 </div> 534 </div> 535 ); 536};