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