diff --git a/components/canvas/BackgroundCanvas.tsx b/components/canvas/BackgroundCanvas.tsx new file mode 100644 index 0000000..2d33cc0 --- /dev/null +++ b/components/canvas/BackgroundCanvas.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useImageStore } from '@/lib/store' +import { getBackgroundCSS } from '@/lib/constants/backgrounds' + +interface BackgroundCanvasProps { + width: number + height: number + borderRadius: number +} + +/** + * Separate canvas component for rendering only the background + * This ensures clean separation between background and user image + */ +export function BackgroundCanvas({ width, height, borderRadius }: BackgroundCanvasProps) { + const { backgroundConfig } = useImageStore() + const backgroundStyle = getBackgroundCSS(backgroundConfig) + + // Load background image if type is 'image' + const [bgImage, setBgImage] = useState(null) + + useEffect(() => { + if (backgroundConfig.type === 'image' && backgroundConfig.value) { + const imageValue = backgroundConfig.value as string + + // Check if it's a valid image URL/blob/data URI or Cloudinary public ID + const isValidImageValue = + imageValue.startsWith('http') || + imageValue.startsWith('blob:') || + imageValue.startsWith('data:') || + (typeof imageValue === 'string' && !imageValue.includes('_gradient')) + + if (!isValidImageValue) { + setBgImage(null) + return + } + + const img = new window.Image() + img.crossOrigin = 'anonymous' + img.onload = () => setBgImage(img) + img.onerror = () => { + console.error('Failed to load background image:', backgroundConfig.value) + setBgImage(null) + } + + // Check if it's a Cloudinary public ID or URL + let imageUrl = imageValue + if (typeof imageUrl === 'string' && !imageUrl.startsWith('http') && !imageUrl.startsWith('blob:') && !imageUrl.startsWith('data:')) { + const { getCldImageUrl } = require('@/lib/cloudinary') + const { cloudinaryPublicIds } = require('@/lib/cloudinary-backgrounds') + if (cloudinaryPublicIds.includes(imageUrl)) { + imageUrl = getCldImageUrl({ + src: imageUrl, + width: Math.max(width, 1920), + height: Math.max(height, 1080), + quality: 'auto', + format: 'auto', + crop: 'fill', + gravity: 'auto', + }) + } else { + setBgImage(null) + return + } + } + + img.src = imageUrl + } else { + setBgImage(null) + } + }, [backgroundConfig, width, height]) + + // If background is an image, render it + if (backgroundConfig.type === 'image' && bgImage) { + return ( +
+ Background +
+ ) + } + + // Otherwise render CSS background + return ( +
+ ) +} + diff --git a/components/canvas/ClientCanvas.tsx b/components/canvas/ClientCanvas.tsx index 18cebc0..1cde5d3 100644 --- a/components/canvas/ClientCanvas.tsx +++ b/components/canvas/ClientCanvas.tsx @@ -1,123 +1,50 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Stage, Layer, Rect, Image as KonvaImage, Group, Circle, Text, Path } from 'react-konva' import { useEditorStore } from '@/lib/store' import { useImageStore } from '@/lib/store' -import { generatePattern } from '@/lib/patterns' import { useResponsiveCanvasDimensions } from '@/hooks/useAspectRatioDimensions' -import { getBackgroundCSS } from '@/lib/constants/backgrounds' import { TextOverlayRenderer } from '@/components/image-render/text-overlay-renderer' +import { BackgroundCanvas } from './BackgroundCanvas' +import { UserImageCanvas, getUserImageKonvaStage } from './UserImageCanvas' -// Global ref to store the Konva stage for export +// Global ref to store the Konva stage for export (for backward compatibility) let globalKonvaStage: any = null; function CanvasRenderer({ image }: { image: HTMLImageElement }) { - const stageRef = useRef(null) - - // Store stage globally for export - useEffect(() => { - const updateStage = () => { - if (stageRef.current) { - // react-konva Stage ref gives us the Stage instance directly - globalKonvaStage = stageRef.current; - } - }; - - updateStage(); - // Also check after a short delay to ensure ref is set - const timeout = setTimeout(updateStage, 100); - - return () => { - clearTimeout(timeout); - globalKonvaStage = null; - }; - }); - const patternRectRef = useRef(null) - const noiseRectRef = useRef(null) const containerRef = useRef(null) - const [patternImage, setPatternImage] = useState(null) - const [noiseImage, setNoiseImage] = useState(null) const { screenshot, - background, - shadow, - pattern: patternStyle, - frame, canvas, - noise, } = useEditorStore() - const { backgroundConfig, backgroundBorderRadius, perspective3D, imageOpacity } = useImageStore() + const { backgroundBorderRadius, perspective3D } = useImageStore() const responsiveDimensions = useResponsiveCanvasDimensions() - const backgroundStyle = getBackgroundCSS(backgroundConfig) // Track viewport size for responsive canvas sizing const [viewportSize, setViewportSize] = useState({ width: 1920, height: 1080 }) - // Load background image if type is 'image' - const [bgImage, setBgImage] = useState(null) - - // Get container dimensions early for use in useEffect + // Get container dimensions const containerWidth = responsiveDimensions.width const containerHeight = responsiveDimensions.height + // Store stage globally for export (sync with UserImageCanvas) useEffect(() => { - if (backgroundConfig.type === 'image' && backgroundConfig.value) { - const imageValue = backgroundConfig.value as string - - // Check if it's a valid image URL/blob/data URI or Cloudinary public ID - // Skip if it looks like a gradient key (e.g., "primary_gradient") - const isValidImageValue = - imageValue.startsWith('http') || - imageValue.startsWith('blob:') || - imageValue.startsWith('data:') || - // Check if it might be a Cloudinary public ID (not a gradient key) - (typeof imageValue === 'string' && !imageValue.includes('_gradient')) - - if (!isValidImageValue) { - setBgImage(null) - return - } - - const img = new window.Image() - img.crossOrigin = 'anonymous' - img.onload = () => setBgImage(img) - img.onerror = () => { - console.error('Failed to load background image:', backgroundConfig.value) - setBgImage(null) - } - - // Check if it's a Cloudinary public ID or URL - let imageUrl = imageValue - if (typeof imageUrl === 'string' && !imageUrl.startsWith('http') && !imageUrl.startsWith('blob:') && !imageUrl.startsWith('data:')) { - // It might be a Cloudinary public ID, construct URL - const { getCldImageUrl } = require('@/lib/cloudinary') - const { cloudinaryPublicIds } = require('@/lib/cloudinary-backgrounds') - if (cloudinaryPublicIds.includes(imageUrl)) { - // Use container dimensions for better quality - imageUrl = getCldImageUrl({ - src: imageUrl, - width: Math.max(containerWidth, 1920), - height: Math.max(containerHeight, 1080), - quality: 'auto', - format: 'auto', - crop: 'fill', - gravity: 'auto', - }) - } else { - // Invalid image value, don't try to load - setBgImage(null) - return - } + const updateStage = () => { + const stage = getUserImageKonvaStage() + if (stage) { + globalKonvaStage = stage } - - img.src = imageUrl - } else { - setBgImage(null) } - }, [backgroundConfig, containerWidth, containerHeight]) + + updateStage() + const interval = setInterval(updateStage, 100) + + return () => { + clearInterval(interval) + } + }, []) useEffect(() => { const updateViewportSize = () => { @@ -132,44 +59,6 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { return () => window.removeEventListener('resize', updateViewportSize) }, []) - useEffect(() => { - if (!patternStyle.enabled) { - setPatternImage(null) - return - } - - const newPattern = generatePattern( - patternStyle.type, - patternStyle.scale, - patternStyle.spacing, - patternStyle.color, - patternStyle.rotation, - patternStyle.blur - ) - setPatternImage(newPattern) - }, [ - patternStyle.enabled, - patternStyle.type, - patternStyle.scale, - patternStyle.spacing, - patternStyle.color, - patternStyle.rotation, - patternStyle.blur, - ]) - - useEffect(() => { - if (!noise.enabled || noise.type === 'none') { - setNoiseImage(null) - return - } - - const img = new window.Image() - img.crossOrigin = 'anonymous' - img.onload = () => setNoiseImage(img) - img.onerror = () => setNoiseImage(null) - img.src = `/${noise.type}.jpg` - }, [noise.enabled, noise.type]) - /* ─────────────────── layout helpers ─────────────────── */ const imageAspect = image.naturalWidth / image.naturalHeight @@ -202,18 +91,7 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { const contentW = canvasW - canvas.padding * 2 const contentH = canvasH - canvas.padding * 2 - useEffect(() => { - if (patternRectRef.current) { - patternRectRef.current.cache() - } - }, [ - patternImage, - canvasW, - canvasH, - patternStyle.opacity, - patternStyle.blur, - ]) - + // Calculate image dimensions for 3D overlay positioning let imageScaledW, imageScaledH if (contentW / contentH > imageAspect) { imageScaledH = contentH * screenshot.scale @@ -224,41 +102,15 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { } /* ─────────────────── frame helpers ─────────────────── */ - const showFrame = frame.enabled && frame.type !== 'none' + const showFrame = useEditorStore.getState().frame.enabled && useEditorStore.getState().frame.type !== 'none' const frameOffset = - showFrame && frame.type === 'solid' - ? frame.width - : showFrame && frame.type === 'ruler' - ? frame.width + 2 + showFrame && useEditorStore.getState().frame.type === 'solid' + ? useEditorStore.getState().frame.width + : showFrame && useEditorStore.getState().frame.type === 'ruler' + ? useEditorStore.getState().frame.width + 2 : 0 - const windowPadding = showFrame && frame.type === 'window' ? (frame.padding || 20) : 0 - const windowHeader = showFrame && frame.type === 'window' ? 40 : 0 - const eclipseBorder = showFrame && frame.type === 'eclipse' ? frame.width + 2 : 0 - const framedW = imageScaledW + frameOffset * 2 + windowPadding * 2 + eclipseBorder - const framedH = imageScaledH + frameOffset * 2 + windowPadding * 2 + windowHeader + eclipseBorder - - const shadowProps = shadow.enabled - ? (() => { - const { elevation, side, softness, color, intensity } = shadow - const diag = elevation * 0.707 - const offset = - side === 'bottom' - ? { x: 0, y: elevation } - : side === 'right' - ? { x: elevation, y: 0 } - : side === 'bottom-right' - ? { x: diag, y: diag } - : { x: 0, y: 0 } - - return { - shadowColor: color, - shadowBlur: softness, - shadowOffsetX: offset.x, - shadowOffsetY: offset.y, - shadowOpacity: intensity, - } - })() - : {} + const windowPadding = showFrame && useEditorStore.getState().frame.type === 'window' ? (useEditorStore.getState().frame.padding || 20) : 0 + const windowHeader = showFrame && useEditorStore.getState().frame.type === 'window' ? 40 : 0 // Build CSS 3D transform string for image only // Include screenshot.rotation to match Konva Group rotation @@ -279,7 +131,7 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { perspective3D.translateY !== 0 || perspective3D.scale !== 1 - // Calculate image position relative to canvas + // Calculate image position relative to canvas - always centered // Account for Group position and offset const groupCenterX = canvasW / 2 + screenshot.offsetX const groupCenterY = canvasH / 2 + screenshot.offsetY @@ -311,19 +163,18 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { overflow: 'hidden', }} > - {/* Background layer - DOM element for html2canvas compatibility */} -
+ + {/* User Image Canvas - Separate layer for user image, always centered */} + {/* Text overlays */} @@ -362,10 +213,10 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { width: '100%', height: '100%', objectFit: 'cover', - opacity: imageOpacity, - borderRadius: showFrame && frame.type === 'window' + opacity: useImageStore.getState().imageOpacity, + borderRadius: showFrame && useEditorStore.getState().frame.type === 'window' ? '0 0 12px 12px' - : showFrame && frame.type === 'ruler' + : showFrame && useEditorStore.getState().frame.type === 'ruler' ? `${screenshot.radius * 0.8}px` : `${screenshot.radius}px`, transform: perspective3DTransform, @@ -376,352 +227,6 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { />
)} - - {/* Konva Stage - only for user images, frames, patterns, noise */} - - {/* Remove background layer - now handled by DOM element above */} - - - {patternImage && ( - - )} - - - - {noiseImage && ( - - )} - - - - - {/* Solid Frame */} - {showFrame && frame.type === 'solid' && ( - - )} - - {/* Glassy Frame */} - {showFrame && frame.type === 'glassy' && ( - - )} - - {/* Ruler Frame */} - {showFrame && frame.type === 'ruler' && ( - - - - - - - - - {/* Top ruler marks */} - {Array.from({ length: Math.floor(framedW / 10) - 1 }).map((_, i) => ( - - ))} - {/* Left ruler marks */} - {Array.from({ length: Math.floor(framedH / 10) - 1 }).map((_, i) => ( - - ))} - {/* Right ruler marks */} - {Array.from({ length: Math.floor(framedH / 10) - 1 }).map((_, i) => ( - - ))} - {/* Bottom ruler marks */} - {Array.from({ length: Math.floor(framedW / 10) - 1 }).map((_, i) => ( - - ))} - - - - - - )} - - {/* Infinite Mirror Frame */} - {showFrame && frame.type === 'infinite-mirror' && ( - <> - {Array.from({ length: 4 }).map((_, i) => ( - - ))} - - )} - - {/* Eclipse Frame */} - {showFrame && frame.type === 'eclipse' && ( - - - - - )} - - {/* Stack Frame */} - {showFrame && frame.type === 'stack' && ( - <> - {/* Bottom layer - darkest */} - - {/* Middle layer */} - - {/* Top layer - lightest, will have image on top */} - - - )} - - {/* Window Frame */} - {showFrame && frame.type === 'window' && ( - <> - - - {/* Window control buttons (red, yellow, green) */} - - - - - - )} - - {/* Dotted Frame */} - {showFrame && frame.type === 'dotted' && ( - - )} - - {/* Focus Frame */} - {showFrame && frame.type === 'focus' && ( - - - - - - - )} - - - - -
) diff --git a/components/canvas/UserImageCanvas.tsx b/components/canvas/UserImageCanvas.tsx new file mode 100644 index 0000000..113be05 --- /dev/null +++ b/components/canvas/UserImageCanvas.tsx @@ -0,0 +1,523 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { Stage, Layer, Rect, Image as KonvaImage, Group, Circle, Text, Path } from 'react-konva' +import { useEditorStore } from '@/lib/store' +import { useImageStore } from '@/lib/store' +import { generatePattern } from '@/lib/patterns' + +// Global ref to store the Konva stage for export +let globalKonvaStage: any = null + +interface UserImageCanvasProps { + image: HTMLImageElement + canvasWidth: number + canvasHeight: number +} + +/** + * Separate canvas component for rendering only the user image + * The image is always centered on this canvas + */ +export function UserImageCanvas({ image, canvasWidth, canvasHeight }: UserImageCanvasProps) { + const stageRef = useRef(null) + + // Store stage globally for export + useEffect(() => { + const updateStage = () => { + if (stageRef.current) { + globalKonvaStage = stageRef.current + } + } + + updateStage() + const timeout = setTimeout(updateStage, 100) + + return () => { + clearTimeout(timeout) + globalKonvaStage = null + } + }) + + const patternRectRef = useRef(null) + const noiseRectRef = useRef(null) + const [patternImage, setPatternImage] = useState(null) + const [noiseImage, setNoiseImage] = useState(null) + + const { + screenshot, + shadow, + pattern: patternStyle, + frame, + canvas, + noise, + } = useEditorStore() + + const { imageOpacity, perspective3D } = useImageStore() + + // Load pattern + useEffect(() => { + if (!patternStyle.enabled) { + setPatternImage(null) + return + } + + const newPattern = generatePattern( + patternStyle.type, + patternStyle.scale, + patternStyle.spacing, + patternStyle.color, + patternStyle.rotation, + patternStyle.blur + ) + setPatternImage(newPattern) + }, [ + patternStyle.enabled, + patternStyle.type, + patternStyle.scale, + patternStyle.spacing, + patternStyle.color, + patternStyle.rotation, + patternStyle.blur, + ]) + + // Load noise + useEffect(() => { + if (!noise.enabled || noise.type === 'none') { + setNoiseImage(null) + return + } + + const img = new window.Image() + img.crossOrigin = 'anonymous' + img.onload = () => setNoiseImage(img) + img.onerror = () => setNoiseImage(null) + img.src = `/${noise.type}.jpg` + }, [noise.enabled, noise.type]) + + // Calculate image dimensions and centering + const imageAspect = image.naturalWidth / image.naturalHeight + const contentW = canvasWidth - canvas.padding * 2 + const contentH = canvasHeight - canvas.padding * 2 + + // Calculate scaled image dimensions + let imageScaledW, imageScaledH + if (contentW / contentH > imageAspect) { + imageScaledH = contentH * screenshot.scale + imageScaledW = imageScaledH * imageAspect + } else { + imageScaledW = contentW * screenshot.scale + imageScaledH = imageScaledW / imageAspect + } + + // Frame calculations + const showFrame = frame.enabled && frame.type !== 'none' + const frameOffset = + showFrame && frame.type === 'solid' + ? frame.width + : showFrame && frame.type === 'ruler' + ? frame.width + 2 + : 0 + const windowPadding = showFrame && frame.type === 'window' ? (frame.padding || 20) : 0 + const windowHeader = showFrame && frame.type === 'window' ? 40 : 0 + const eclipseBorder = showFrame && frame.type === 'eclipse' ? frame.width + 2 : 0 + const framedW = imageScaledW + frameOffset * 2 + windowPadding * 2 + eclipseBorder + const framedH = imageScaledH + frameOffset * 2 + windowPadding * 2 + windowHeader + eclipseBorder + + // Shadow properties + const shadowProps = shadow.enabled + ? (() => { + const { elevation, side, softness, color, intensity } = shadow + const diag = elevation * 0.707 + const offset = + side === 'bottom' + ? { x: 0, y: elevation } + : side === 'right' + ? { x: elevation, y: 0 } + : side === 'bottom-right' + ? { x: diag, y: diag } + : { x: 0, y: 0 } + + return { + shadowColor: color, + shadowBlur: softness, + shadowOffsetX: offset.x, + shadowOffsetY: offset.y, + shadowOpacity: intensity, + } + })() + : {} + + // Check if 3D transforms are active + const has3DTransform = + perspective3D.rotateX !== 0 || + perspective3D.rotateY !== 0 || + perspective3D.rotateZ !== 0 || + perspective3D.translateX !== 0 || + perspective3D.translateY !== 0 || + perspective3D.scale !== 1 + + // Calculate centered position - image is always centered + // offsetX and offsetY are applied relative to center + const centerX = canvasWidth / 2 + screenshot.offsetX + const centerY = canvasHeight / 2 + screenshot.offsetY + + useEffect(() => { + if (patternRectRef.current) { + patternRectRef.current.cache() + } + }, [ + patternImage, + canvasWidth, + canvasHeight, + patternStyle.opacity, + patternStyle.blur, + ]) + + return ( + + {/* Pattern layer */} + + {patternImage && ( + + )} + + + {/* Noise layer */} + + {noiseImage && ( + + )} + + + {/* User image layer - always centered */} + + + {/* Solid Frame */} + {showFrame && frame.type === 'solid' && ( + + )} + + {/* Glassy Frame */} + {showFrame && frame.type === 'glassy' && ( + + )} + + {/* Ruler Frame */} + {showFrame && frame.type === 'ruler' && ( + + + + + + + + + {Array.from({ length: Math.floor(framedW / 10) - 1 }).map((_, i) => ( + + ))} + {Array.from({ length: Math.floor(framedH / 10) - 1 }).map((_, i) => ( + + ))} + {Array.from({ length: Math.floor(framedH / 10) - 1 }).map((_, i) => ( + + ))} + {Array.from({ length: Math.floor(framedW / 10) - 1 }).map((_, i) => ( + + ))} + + + + + + )} + + {/* Infinite Mirror Frame */} + {showFrame && frame.type === 'infinite-mirror' && ( + <> + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + )} + + {/* Eclipse Frame */} + {showFrame && frame.type === 'eclipse' && ( + + + + + )} + + {/* Stack Frame */} + {showFrame && frame.type === 'stack' && ( + <> + + + + + )} + + {/* Window Frame */} + {showFrame && frame.type === 'window' && ( + <> + + + + + + + + )} + + {/* Dotted Frame */} + {showFrame && frame.type === 'dotted' && ( + + )} + + {/* Focus Frame */} + {showFrame && frame.type === 'focus' && ( + + + + + + + )} + + {/* User Image - Always centered */} + + + + + ) +} + +// Export function to get the Konva stage +export function getUserImageKonvaStage(): any { + return globalKonvaStage +} +