From 3b6d68ff50535daa047ce9f3ea43560dc6ee4f40 Mon Sep 17 00:00:00 2001 From: Narendra Pal Date: Mon, 10 Nov 2025 21:54:36 +0530 Subject: [PATCH 1/2] text-overlay rotation support added --- app/home/page.tsx | 2 +- app/layout.tsx | 29 +- components/canvas/ClientCanvas.tsx | 741 ++++++++++-------- components/editor/editor-left-panel.tsx | 78 +- components/editor/editor-right-panel.tsx | 610 ++++++++------ components/overlays/overlay-controls.tsx | 173 ++-- .../text-overlay/text-overlay-controls.tsx | 498 ++++++------ lib/image-storage.ts | 34 +- lib/store/index.ts | 698 +++++++++-------- package-lock.json | 12 +- 10 files changed, 1627 insertions(+), 1248 deletions(-) diff --git a/app/home/page.tsx b/app/home/page.tsx index 685ebd5..8afd5e3 100644 --- a/app/home/page.tsx +++ b/app/home/page.tsx @@ -3,7 +3,7 @@ import { ErrorBoundary } from "@/components/ErrorBoundary"; /** * Editor Page - Public Access - * + * * This page is now publicly accessible without authentication. */ export default async function EditorPage() { diff --git a/app/layout.tsx b/app/layout.tsx index 8c594cf..13c64da 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -17,19 +17,31 @@ export const metadata: Metadata = { default: "Stage - Image Showcase Builder", template: "%s | Stage", }, - description: "Create stunning showcase images for your projects with customizable templates and layouts. A fully in-browser canvas editor for adding images, text, and backgrounds—no external services required.", - keywords: ["image editor", "canvas editor", "design tool", "image showcase", "template builder", "in-browser editor", "client-side export"], + description: + "Create stunning showcase images for your projects with customizable templates and layouts. A fully in-browser canvas editor for adding images, text, and backgrounds—no external services required.", + keywords: [ + "image editor", + "canvas editor", + "design tool", + "image showcase", + "template builder", + "in-browser editor", + "client-side export", + ], authors: [{ name: "Stage" }], creator: "Stage", publisher: "Stage", - metadataBase: new URL(process.env.BETTER_AUTH_URL || "https://stage-psi-one.vercel.app"), + metadataBase: new URL( + process.env.BETTER_AUTH_URL || "https://stage-psi-one.vercel.app" + ), openGraph: { type: "website", locale: "en_US", url: "/", siteName: "Stage", title: "Stage - Image Showcase Builder", - description: "Create stunning showcase images for your projects with customizable templates and layouts", + description: + "Create stunning showcase images for your projects with customizable templates and layouts", images: [ { url: "https://stage-psi-one.vercel.app/og.png", @@ -42,7 +54,8 @@ export const metadata: Metadata = { twitter: { card: "summary_large_image", title: "Stage - Image Showcase Builder", - description: "Create stunning showcase images for your projects with customizable templates and layouts", + description: + "Create stunning showcase images for your projects with customizable templates and layouts", images: ["https://stage-psi-one.vercel.app/og.png"], creator: "@stage", }, @@ -79,7 +92,11 @@ export default function RootLayout({ return ( - + p.trim()); + const parts = match[1].split(",").map((p) => p.trim()); const direction = parts[0]; const colors = parts.slice(1); @@ -33,16 +46,16 @@ function parseLinearGradient(gradientString: string, width: number, height: numb let startPoint = { x: 0, y: 0 }; let endPoint = { x: width, y: 0 }; // Default to horizontal (to right) - if (direction.includes('right')) { + if (direction.includes("right")) { startPoint = { x: 0, y: 0 }; endPoint = { x: width, y: 0 }; - } else if (direction.includes('left')) { + } else if (direction.includes("left")) { startPoint = { x: width, y: 0 }; endPoint = { x: 0, y: 0 }; - } else if (direction.includes('bottom')) { + } else if (direction.includes("bottom")) { startPoint = { x: 0, y: 0 }; endPoint = { x: 0, y: height }; - } else if (direction.includes('top')) { + } else if (direction.includes("top")) { startPoint = { x: 0, y: height }; endPoint = { x: 0, y: 0 }; } @@ -62,7 +75,7 @@ function parseLinearGradient(gradientString: string, width: number, height: numb } function CanvasRenderer({ image }: { image: HTMLImageElement }) { - const stageRef = useRef(null) + const stageRef = useRef(null); // Store stage globally for export useEffect(() => { @@ -82,12 +95,16 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { globalKonvaStage = null; }; }); - const patternRectRef = useRef(null) - const noiseRectRef = useRef(null) - const backgroundRef = useRef(null) - const containerRef = useRef(null) - const [patternImage, setPatternImage] = useState(null) - const [noiseImage, setNoiseImage] = useState(null) + const patternRectRef = useRef(null); + const noiseRectRef = useRef(null); + const backgroundRef = useRef(null); + const containerRef = useRef(null); + const textRef = useRef(null); + + const [patternImage, setPatternImage] = useState( + null + ); + const [noiseImage, setNoiseImage] = useState(null); const { screenshot, @@ -97,7 +114,7 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { frame, canvas, noise, - } = useEditorStore() + } = useEditorStore(); const { backgroundConfig, @@ -110,155 +127,185 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { imageOverlays, updateTextOverlay, updateImageOverlay, - } = useImageStore() + } = useImageStore(); - const responsiveDimensions = useResponsiveCanvasDimensions() - const backgroundStyle = getBackgroundCSS(backgroundConfig) + const responsiveDimensions = useResponsiveCanvasDimensions(); + const backgroundStyle = getBackgroundCSS(backgroundConfig); // Track viewport size for responsive canvas sizing - const [viewportSize, setViewportSize] = useState({ width: 1920, height: 1080 }) + const [viewportSize, setViewportSize] = useState({ + width: 1920, + height: 1080, + }); // Load background image if type is 'image' - const [bgImage, setBgImage] = useState(null) + const [bgImage, setBgImage] = useState(null); // Generate noise texture for background noise effect - const [noiseTexture, setNoiseTexture] = useState(null) + const [noiseTexture, setNoiseTexture] = useState( + null + ); // Load overlay images - const [loadedOverlayImages, setLoadedOverlayImages] = useState>({}) + const [loadedOverlayImages, setLoadedOverlayImages] = useState< + Record + >({}); + + useEffect(() => { + if (textRef.current) { + const w = textRef.current.width(); + const h = textRef.current.height(); + textRef.current.offsetX(w / 2); + textRef.current.offsetY(h / 2); + } + }, [textOverlays]); useEffect(() => { if (backgroundNoise > 0) { // Generate noise texture using Gaussian distribution for realistic grain - const intensity = backgroundNoise / 100 // Convert percentage to 0-1 range - const noiseCanvas = generateNoiseTexture(200, 200, intensity) - setNoiseTexture(noiseCanvas) + const intensity = backgroundNoise / 100; // Convert percentage to 0-1 range + const noiseCanvas = generateNoiseTexture(200, 200, intensity); + setNoiseTexture(noiseCanvas); } else { - setNoiseTexture(null) + setNoiseTexture(null); } - }, [backgroundNoise]) + }, [backgroundNoise]); // Get container dimensions early for use in useEffect - const containerWidth = responsiveDimensions.width - const containerHeight = responsiveDimensions.height + const containerWidth = responsiveDimensions.width; + const containerHeight = responsiveDimensions.height; useEffect(() => { - if (backgroundConfig.type === 'image' && backgroundConfig.value) { - const imageValue = backgroundConfig.value as string + 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:') || + 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')) + (typeof imageValue === "string" && !imageValue.includes("_gradient")); if (!isValidImageValue) { - setBgImage(null) - return + setBgImage(null); + return; } - const img = new window.Image() - img.crossOrigin = 'anonymous' - img.onload = () => setBgImage(img) + 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) - } + 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:')) { + 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 { cloudinaryPublicIds } = require('@/lib/cloudinary-backgrounds') + 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', - }) + quality: "auto", + format: "auto", + crop: "fill", + gravity: "auto", + }); } else { // Invalid image value, don't try to load - setBgImage(null) - return + setBgImage(null); + return; } } - img.src = imageUrl + img.src = imageUrl; } else { - setBgImage(null) + setBgImage(null); } - }, [backgroundConfig, containerWidth, containerHeight]) + }, [backgroundConfig, containerWidth, containerHeight]); // Load overlay images useEffect(() => { const loadOverlays = async () => { - const loadedImages: Record = {} + const loadedImages: Record = {}; for (const overlay of imageOverlays) { - if (!overlay.isVisible) continue + if (!overlay.isVisible) continue; try { - const isCloudinaryId = OVERLAY_PUBLIC_IDS.includes(overlay.src as any) || - (typeof overlay.src === 'string' && overlay.src.startsWith('overlays/')) - - const imageUrl = isCloudinaryId && !overlay.isCustom - ? getCldImageUrl({ - src: overlay.src, - width: overlay.size * 2, - height: overlay.size * 2, - quality: 'auto', - format: 'auto', - crop: 'fit', - }) - : overlay.src - - const img = new window.Image() - img.crossOrigin = 'anonymous' + const isCloudinaryId = + OVERLAY_PUBLIC_IDS.includes(overlay.src as any) || + (typeof overlay.src === "string" && + overlay.src.startsWith("overlays/")); + + const imageUrl = + isCloudinaryId && !overlay.isCustom + ? getCldImageUrl({ + src: overlay.src, + width: overlay.size * 2, + height: overlay.size * 2, + quality: "auto", + format: "auto", + crop: "fit", + }) + : overlay.src; + + const img = new window.Image(); + img.crossOrigin = "anonymous"; await new Promise((resolve, reject) => { img.onload = () => { - loadedImages[overlay.id] = img - resolve() - } - img.onerror = reject - img.src = imageUrl - }) + loadedImages[overlay.id] = img; + resolve(); + }; + img.onerror = reject; + img.src = imageUrl; + }); } catch (error) { - console.error(`Failed to load overlay image for ${overlay.id}:`, error) + console.error( + `Failed to load overlay image for ${overlay.id}:`, + error + ); } } - setLoadedOverlayImages(loadedImages) - } + setLoadedOverlayImages(loadedImages); + }; - loadOverlays() - }, [imageOverlays]) + loadOverlays(); + }, [imageOverlays]); useEffect(() => { const updateViewportSize = () => { setViewportSize({ width: window.innerWidth, height: window.innerHeight, - }) - } + }); + }; - updateViewportSize() - window.addEventListener('resize', updateViewportSize) - return () => window.removeEventListener('resize', updateViewportSize) - }, []) + updateViewportSize(); + window.addEventListener("resize", updateViewportSize); + return () => window.removeEventListener("resize", updateViewportSize); + }, []); useEffect(() => { if (!patternStyle.enabled) { - setPatternImage(null) - return + setPatternImage(null); + return; } const newPattern = generatePattern( @@ -268,8 +315,8 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { patternStyle.color, patternStyle.rotation, patternStyle.blur - ) - setPatternImage(newPattern) + ); + setPatternImage(newPattern); }, [ patternStyle.enabled, patternStyle.type, @@ -278,70 +325,64 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { patternStyle.color, patternStyle.rotation, patternStyle.blur, - ]) + ]); useEffect(() => { - if (!noise.enabled || noise.type === 'none') { - setNoiseImage(null) - return + 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]) + 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 + const imageAspect = image.naturalWidth / image.naturalHeight; // Calculate canvas aspect ratio from selected aspect ratio using responsive dimensions - const canvasAspect = containerWidth / containerHeight + const canvasAspect = containerWidth / containerHeight; // Calculate content area (image area without padding) // Use viewport-aware dimensions, respecting the selected aspect ratio - const availableWidth = Math.min(viewportSize.width * 1.1, containerWidth) - const availableHeight = Math.min(viewportSize.height * 1.1, containerHeight) + const availableWidth = Math.min(viewportSize.width * 1.1, containerWidth); + const availableHeight = Math.min(viewportSize.height * 1.1, containerHeight); // Calculate canvas dimensions that maintain the selected aspect ratio - let canvasW, canvasH + let canvasW, canvasH; if (availableWidth / availableHeight > canvasAspect) { // Height is the limiting factor - canvasH = availableHeight - canvas.padding * 2 - canvasW = canvasH * canvasAspect + canvasH = availableHeight - canvas.padding * 2; + canvasW = canvasH * canvasAspect; } else { // Width is the limiting factor - canvasW = availableWidth - canvas.padding * 2 - canvasH = canvasW / canvasAspect + canvasW = availableWidth - canvas.padding * 2; + canvasH = canvasW / canvasAspect; } // Ensure reasonable dimensions - const minContentSize = 300 - canvasW = Math.max(canvasW, minContentSize) - canvasH = Math.max(canvasH, minContentSize) + const minContentSize = 300; + canvasW = Math.max(canvasW, minContentSize); + canvasH = Math.max(canvasH, minContentSize); // Content dimensions (without padding) - const contentW = canvasW - canvas.padding * 2 - const contentH = canvasH - canvas.padding * 2 + const contentW = canvasW - canvas.padding * 2; + const contentH = canvasH - canvas.padding * 2; useEffect(() => { if (patternRectRef.current) { - patternRectRef.current.cache() + patternRectRef.current.cache(); } - }, [ - patternImage, - canvasW, - canvasH, - patternStyle.opacity, - patternStyle.blur, - ]) + }, [patternImage, canvasW, canvasH, patternStyle.opacity, patternStyle.blur]); // Cache background when blur is active useEffect(() => { if (backgroundRef.current && backgroundBlur > 0) { - backgroundRef.current.cache() - backgroundRef.current.getLayer()?.batchDraw() + backgroundRef.current.cache(); + backgroundRef.current.getLayer()?.batchDraw(); } }, [ backgroundBlur, @@ -350,43 +391,51 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { canvasW, canvasH, bgImage, - ]) + ]); - let imageScaledW, imageScaledH + let imageScaledW, imageScaledH; if (contentW / contentH > imageAspect) { - imageScaledH = contentH * screenshot.scale - imageScaledW = imageScaledH * imageAspect + imageScaledH = contentH * screenshot.scale; + imageScaledW = imageScaledH * imageAspect; } else { - imageScaledW = contentW * screenshot.scale - imageScaledH = imageScaledW / imageAspect + imageScaledW = contentW * screenshot.scale; + imageScaledH = imageScaledW / imageAspect; } /* ─────────────────── frame helpers ─────────────────── */ - const showFrame = frame.enabled && frame.type !== 'none' + const showFrame = frame.enabled && frame.type !== "none"; const frameOffset = - showFrame && frame.type === 'solid' + showFrame && frame.type === "solid" ? frame.width - : showFrame && frame.type === 'ruler' + : 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 + : 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 { elevation, side, softness, color, intensity } = shadow; + const diag = elevation * 0.707; const offset = - side === 'bottom' + side === "bottom" ? { x: 0, y: elevation } - : side === 'right' + : side === "right" ? { x: elevation, y: 0 } - : side === 'bottom-right' + : side === "bottom-right" ? { x: diag, y: diag } - : { x: 0, y: 0 } + : { x: 0, y: 0 }; return { shadowColor: color, @@ -394,9 +443,9 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { shadowOffsetX: offset.x, shadowOffsetY: offset.y, shadowOpacity: intensity, - } + }; })() - : {} + : {}; // Build CSS 3D transform string for image only // Include screenshot.rotation to match Konva Group rotation @@ -406,7 +455,9 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { rotateX(${perspective3D.rotateX}deg) rotateY(${perspective3D.rotateY}deg) rotateZ(${perspective3D.rotateZ + screenshot.rotation}deg) - `.replace(/\s+/g, ' ').trim() + ` + .replace(/\s+/g, " ") + .trim(); // Check if 3D transforms are active (any non-default value) const has3DTransform = @@ -415,23 +466,28 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { perspective3D.rotateZ !== 0 || perspective3D.translateX !== 0 || perspective3D.translateY !== 0 || - perspective3D.scale !== 1 + perspective3D.scale !== 1; // Calculate image position relative to canvas // Account for Group position and offset - const groupCenterX = canvasW / 2 + screenshot.offsetX - const groupCenterY = canvasH / 2 + screenshot.offsetY - const imageX = groupCenterX + frameOffset + windowPadding - imageScaledW / 2 - const imageY = groupCenterY + frameOffset + windowPadding + windowHeader - imageScaledH / 2 + const groupCenterX = canvasW / 2 + screenshot.offsetX; + const groupCenterY = canvasH / 2 + screenshot.offsetY; + const imageX = groupCenterX + frameOffset + windowPadding - imageScaledW / 2; + const imageY = + groupCenterY + + frameOffset + + windowPadding + + windowHeader - + imageScaledH / 2; // Helper function to parse gradient from CSS string const parseGradient = (bgStyle: React.CSSProperties) => { - if (backgroundConfig.type === 'gradient' && bgStyle.background) { - const gradientStr = bgStyle.background as string - return gradientStr + if (backgroundConfig.type === "gradient" && bgStyle.background) { + const gradientStr = bgStyle.background as string; + return gradientStr; } - return null - } + return null; + }; /* ─────────────────── render ─────────────────── */ return ( @@ -440,22 +496,22 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { id="image-render-card" className="flex items-center justify-center relative" style={{ - width: '100%', + width: "100%", maxWidth: `${containerWidth}px`, aspectRatio: responsiveDimensions.aspectRatio, - maxHeight: 'calc(100vh - 200px)', - backgroundColor: 'transparent', - padding: '24px', + maxHeight: "calc(100vh - 200px)", + backgroundColor: "transparent", + padding: "24px", }} >
{/* 3D Transformed Image Overlay - Only when 3D transforms are active (HTML fallback for 3D) */} @@ -463,34 +519,35 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) {
3D transformed
@@ -503,16 +560,16 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { ref={stageRef} className="hires-stage" style={{ - display: 'block', - backgroundColor: 'transparent', - overflow: 'hidden', - position: 'relative', + display: "block", + backgroundColor: "transparent", + overflow: "hidden", + position: "relative", borderRadius: `${backgroundBorderRadius}px`, }} > {/* Background Layer - Canvas based */} - {backgroundConfig.type === 'image' && bgImage ? ( + {backgroundConfig.type === "image" && bgImage ? ( 0 ? [Konva.Filters.Blur] : []} blurRadius={backgroundBlur} /> - ) : backgroundConfig.type === 'gradient' && backgroundStyle.background ? ( + ) : backgroundConfig.type === "gradient" && + backgroundStyle.background ? ( (() => { - const gradientProps = parseLinearGradient(backgroundStyle.background as string, canvasW, canvasH); + const gradientProps = parseLinearGradient( + backgroundStyle.background as string, + canvasW, + canvasH + ); return gradientProps ? ( {/* Solid Frame */} - {showFrame && frame.type === 'solid' && ( + {showFrame && frame.type === "solid" && ( {/* Top ruler marks */} - {Array.from({ length: Math.floor(framedW / 10) - 1 }).map((_, i) => ( - - ))} + {Array.from({ length: Math.floor(framedW / 10) - 1 }).map( + (_, i) => ( + + ) + )} {/* Left ruler marks */} - {Array.from({ length: Math.floor(framedH / 10) - 1 }).map((_, i) => ( - - ))} + {Array.from({ length: Math.floor(framedH / 10) - 1 }).map( + (_, i) => ( + + ) + )} {/* Right ruler marks */} - {Array.from({ length: Math.floor(framedH / 10) - 1 }).map((_, i) => ( - - ))} + {Array.from({ length: Math.floor(framedH / 10) - 1 }).map( + (_, i) => ( + + ) + )} {/* Bottom ruler marks */} - {Array.from({ length: Math.floor(framedW / 10) - 1 }).map((_, i) => ( - - ))} + {Array.from({ length: Math.floor(framedW / 10) - 1 }).map( + (_, i) => ( + + ) + )} @@ -737,7 +807,7 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { )} {/* Infinite Mirror Frame */} - {showFrame && frame.type === 'infinite-mirror' && ( + {showFrame && frame.type === "infinite-mirror" && ( <> {Array.from({ length: 4 }).map((_, i) => ( ))} )} {/* Eclipse Frame */} - {showFrame && frame.type === 'eclipse' && ( + {showFrame && frame.type === "eclipse" && ( {/* Bottom layer - darkest */} @@ -806,7 +878,7 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { @@ -814,19 +886,24 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { )} {/* Window Frame */} - {showFrame && frame.type === 'window' && ( + {showFrame && frame.type === "window" && ( <> {/* Window control buttons (red, yellow, green) */} @@ -834,21 +911,21 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { )} {/* Dotted Frame */} - {showFrame && frame.type === 'dotted' && ( + {showFrame && frame.type === "dotted" && ( @@ -916,13 +1019,14 @@ function CanvasRenderer({ image }: { image: HTMLImageElement }) { {/* Text Overlays Layer - Canvas based */} {textOverlays.map((overlay) => { - if (!overlay.isVisible) return null + if (!overlay.isVisible) return null; - const textX = (overlay.position.x / 100) * canvasW - const textY = (overlay.position.y / 100) * canvasH + const textX = (overlay.position.x / 100) * canvasW; + const textY = (overlay.position.y / 100) * canvasH; return ( { - const newX = (e.target.x() / canvasW) * 100 - const newY = (e.target.y() / canvasH) * 100 + const newX = (e.target.x() / canvasW) * 100; + const newY = (e.target.y() / canvasH) * 100; updateTextOverlay(overlay.id, { - position: { x: newX, y: newY } - }) + position: { x: newX, y: newY }, + }); }} onMouseEnter={(e) => { - const container = e.target.getStage()?.container() + const container = e.target.getStage()?.container(); if (container) { - container.style.cursor = 'move' + container.style.cursor = "move"; } }} onMouseLeave={(e) => { - const container = e.target.getStage()?.container() + const container = e.target.getStage()?.container(); if (container) { - container.style.cursor = 'default' + container.style.cursor = "default"; } }} /> - ) + ); })} {/* Image Overlays Layer - Canvas based */} {imageOverlays.map((overlay) => { - if (!overlay.isVisible) return null + if (!overlay.isVisible) return null; - const overlayImg = loadedOverlayImages[overlay.id] - if (!overlayImg) return null + const overlayImg = loadedOverlayImages[overlay.id]; + if (!overlayImg) return null; return ( { updateImageOverlay(overlay.id, { - position: { x: e.target.x(), y: e.target.y() } - }) + position: { x: e.target.x(), y: e.target.y() }, + }); }} onMouseEnter={(e) => { - const container = e.target.getStage()?.container() + const container = e.target.getStage()?.container(); if (container) { - container.style.cursor = 'move' + container.style.cursor = "move"; } }} onMouseLeave={(e) => { - const container = e.target.getStage()?.container() + const container = e.target.getStage()?.container(); if (container) { - container.style.cursor = 'default' + container.style.cursor = "default"; } }} /> - ) + ); })}
- ) + ); } // Export function to get the Konva stage @@ -1022,29 +1139,29 @@ export function getKonvaStage(): any { } export default function ClientCanvas() { - const [image, setImage] = useState(null) - const { screenshot, setScreenshot } = useEditorStore() + const [image, setImage] = useState(null); + const { screenshot, setScreenshot } = useEditorStore(); useEffect(() => { if (!screenshot.src) { - setImage(null) - return + setImage(null); + return; } - const img = new window.Image() - img.crossOrigin = 'anonymous' - img.onload = () => setImage(img) - img.onerror = () => setScreenshot({ src: null }) - img.src = screenshot.src - }, [screenshot.src, setScreenshot]) + const img = new window.Image(); + img.crossOrigin = "anonymous"; + img.onload = () => setImage(img); + img.onerror = () => setScreenshot({ src: null }); + img.src = screenshot.src; + }, [screenshot.src, setScreenshot]); if (!image) { return (
- ) + ); } - return + return ; } diff --git a/components/editor/editor-left-panel.tsx b/components/editor/editor-left-panel.tsx index 51c9f86..864886c 100644 --- a/components/editor/editor-left-panel.tsx +++ b/components/editor/editor-left-panel.tsx @@ -1,30 +1,26 @@ -'use client'; +"use client"; -import * as React from 'react'; -import Link from 'next/link'; -import Image from 'next/image'; -import { TextOverlayControls } from '@/components/text-overlay/text-overlay-controls'; -import { OverlayGallery, OverlayControls } from '@/components/overlays'; -import { StyleTabs } from './style-tabs'; -import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Download, Trash2, Copy, ImageIcon, Type, Sticker } from 'lucide-react'; -import { useImageStore } from '@/lib/store'; -import { ExportDialog } from '@/components/canvas/dialogs/ExportDialog'; -import { useExport } from '@/hooks/useExport'; -import { PresetSelector } from '@/components/presets/PresetSelector'; -import { FaXTwitter } from 'react-icons/fa6'; +import * as React from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { TextOverlayControls } from "@/components/text-overlay/text-overlay-controls"; +import { OverlayGallery, OverlayControls } from "@/components/overlays"; +import { StyleTabs } from "./style-tabs"; +import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Download, Trash2, Copy, ImageIcon, Type, Sticker } from "lucide-react"; +import { useImageStore } from "@/lib/store"; +import { ExportDialog } from "@/components/canvas/dialogs/ExportDialog"; +import { useExport } from "@/hooks/useExport"; +import { PresetSelector } from "@/components/presets/PresetSelector"; +import { FaXTwitter } from "react-icons/fa6"; export function EditorLeftPanel() { - const { - uploadedImageUrl, - selectedAspectRatio, - clearImage, - } = useImageStore(); - + const { uploadedImageUrl, selectedAspectRatio, clearImage } = useImageStore(); + const [exportDialogOpen, setExportDialogOpen] = React.useState(false); const [copySuccess, setCopySuccess] = React.useState(false); - const [activeTab, setActiveTab] = React.useState('image'); + const [activeTab, setActiveTab] = React.useState("image"); const { settings: exportSettings, @@ -40,11 +36,14 @@ export function EditorLeftPanel() { {/* Header */}
- - Stage + Stage @@ -65,23 +64,27 @@ export function EditorLeftPanel() {
{/* Tabs Navigation */} - + - Image - Text - @@ -105,7 +108,7 @@ export function EditorLeftPanel() { {/* Overlay Gallery */} - + {/* Image Overlays Section */} @@ -131,15 +134,17 @@ export function EditorLeftPanel() { setTimeout(() => setCopySuccess(false), 2000); }) .catch((error) => { - console.error('Failed to copy:', error); - alert('Failed to copy image to clipboard. Please try again.'); + console.error("Failed to copy:", error); + alert( + "Failed to copy image to clipboard. Please try again." + ); }); }} disabled={!uploadedImageUrl || isExporting} className="flex-1 h-11 justify-center gap-2 rounded-xl bg-muted hover:bg-muted/80 text-foreground shadow-sm hover:shadow-md transition-all font-medium border border-border" > - {copySuccess ? 'Copied!' : 'Copy'} + {copySuccess ? "Copied!" : "Copy"}
- + {expanded && ( <> {/* Aspect Ratio */}
- Aspect Ratio + + Aspect Ratio +
{selectedRatio && (
- {selectedRatio.width}:{selectedRatio.height} • {selectedRatio.width}x{selectedRatio.height} + {selectedRatio.width}:{selectedRatio.height} •{" "} + {selectedRatio.width}x{selectedRatio.height}
)} @@ -108,260 +134,330 @@ export function EditorRightPanel() {
{/* Background Section */}
-

Background

- +

+ Background +

+ {/* Background Type Selector */}
- -
- - + - + + backgroundConfig.type === "gradient" + ? "bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm border-primary" + : "border-border/50 hover:bg-accent text-foreground bg-background hover:border-border" + }`} + > + Gradient +
- + {/* Gradient Selector */} - {backgroundConfig.type === 'gradient' && ( + {backgroundConfig.type === "gradient" && (
- +
- {(Object.keys(gradientColors) as GradientKey[]).map((key) => ( -
)} {/* Solid Color Selector */} - {backgroundConfig.type === 'solid' && ( -
- + {backgroundConfig.type === "solid" && ( +
+
- {(Object.keys(solidColors) as SolidColorKey[]).map((key) => ( -
+ {(Object.keys(solidColors) as SolidColorKey[]).map( + (key) => ( +
)} {/* Image Background Selector */} - {backgroundConfig.type === 'image' && ( + {backgroundConfig.type === "image" && (
{/* Current Background Preview */} - {backgroundConfig.value && - (backgroundConfig.value.startsWith('blob:') || - backgroundConfig.value.startsWith('http') || - backgroundConfig.value.startsWith('data:') || - cloudinaryPublicIds.includes(backgroundConfig.value)) && ( -
- -
- {(() => { - // Check if it's a Cloudinary public ID - const isCloudinaryPublicId = typeof backgroundConfig.value === 'string' && - !backgroundConfig.value.startsWith('blob:') && - !backgroundConfig.value.startsWith('http') && - !backgroundConfig.value.startsWith('data:') && - cloudinaryPublicIds.includes(backgroundConfig.value); - - let imageUrl = backgroundConfig.value as string; - - // If it's a Cloudinary public ID, get the optimized URL - if (isCloudinaryPublicId) { - imageUrl = getCldImageUrl({ - src: backgroundConfig.value as string, - width: 600, - height: 400, - quality: 'auto', - format: 'auto', - crop: 'fill', - gravity: 'auto', - }); - } - - return ( - <> - Current background - - - ); - })()} + {backgroundConfig.value && + (backgroundConfig.value.startsWith("blob:") || + backgroundConfig.value.startsWith("http") || + backgroundConfig.value.startsWith("data:") || + cloudinaryPublicIds.includes(backgroundConfig.value)) && ( +
+ +
+ {(() => { + // Check if it's a Cloudinary public ID + const isCloudinaryPublicId = + typeof backgroundConfig.value === "string" && + !backgroundConfig.value.startsWith("blob:") && + !backgroundConfig.value.startsWith("http") && + !backgroundConfig.value.startsWith("data:") && + cloudinaryPublicIds.includes( + backgroundConfig.value + ); + + let imageUrl = backgroundConfig.value as string; + + // If it's a Cloudinary public ID, get the optimized URL + if (isCloudinaryPublicId) { + imageUrl = getCldImageUrl({ + src: backgroundConfig.value as string, + width: 600, + height: 400, + quality: "auto", + format: "auto", + crop: "fill", + gravity: "auto", + }); + } + + return ( + <> + Current background + + + ); + })()} +
-
- )} + )} {/* Preset Backgrounds */} - {backgroundCategories && Object.keys(backgroundCategories).length > 0 && ( -
- -
- {getAvailableCategories() - .filter((category: string) => category !== 'demo' && category !== 'nature') - .map((category: string) => { - const categoryBackgrounds = backgroundCategories[category]; - if (!categoryBackgrounds || categoryBackgrounds.length === 0) return null; + {backgroundCategories && + Object.keys(backgroundCategories).length > 0 && ( +
+ +
+ {getAvailableCategories() + .filter( + (category: string) => + category !== "demo" && category !== "nature" + ) + .map((category: string) => { + const categoryBackgrounds = + backgroundCategories[category]; + if ( + !categoryBackgrounds || + categoryBackgrounds.length === 0 + ) + return null; - const categoryDisplayName = category.charAt(0).toUpperCase() + category.slice(1); + const categoryDisplayName = + category.charAt(0).toUpperCase() + + category.slice(1); - return ( -
- -
- {categoryBackgrounds.map((publicId: string, idx: number) => { - const thumbnailUrl = getCldImageUrl({ - src: publicId, - width: 300, - height: 200, - quality: 'auto', - format: 'auto', - crop: 'fill', - gravity: 'auto', - }); + return ( +
+ +
+ {categoryBackgrounds.map( + (publicId: string, idx: number) => { + const thumbnailUrl = getCldImageUrl({ + src: publicId, + width: 300, + height: 200, + quality: "auto", + format: "auto", + crop: "fill", + gravity: "auto", + }); - return ( - - ); - })} + return ( + + ); + } + )} +
-
- ); - })} + ); + })} +
-
- )} + )} {/* Upload Background Image */}
- +
-
+
{isBgDragActive ? ( -

Drop the image here...

+

+ Drop the image here... +

) : (

Drag & drop an image here

- or click to browse • PNG, JPG, WEBP up to {MAX_IMAGE_SIZE / 1024 / 1024}MB + or click to browse • PNG, JPG, WEBP up to{" "} + {MAX_IMAGE_SIZE / 1024 / 1024}MB

)} @@ -372,41 +468,49 @@ export function EditorRightPanel() {
)}
-
- )} +
+ )} {/* Border Radius */}
- +
- - {backgroundBorderRadius}px + + + {backgroundBorderRadius}px +
- + - {Math.round((backgroundConfig.opacity !== undefined ? backgroundConfig.opacity : 1) * 100)}% + {Math.round( + (backgroundConfig.opacity !== undefined + ? backgroundConfig.opacity + : 1) * 100 + )} + %
setBackgroundOpacity(value[0])} min={0} max={1} @@ -445,4 +560,3 @@ export function EditorRightPanel() {
); } - diff --git a/components/overlays/overlay-controls.tsx b/components/overlays/overlay-controls.tsx index cb60ec8..3b3ecd8 100644 --- a/components/overlays/overlay-controls.tsx +++ b/components/overlays/overlay-controls.tsx @@ -1,12 +1,12 @@ -'use client' +"use client"; -import { useState } from 'react' -import { Button } from '@/components/ui/button' -import { Slider } from '@/components/ui/slider' -import { useImageStore } from '@/lib/store' -import { Trash2, Eye, EyeOff } from 'lucide-react' -import { getCldImageUrl } from '@/lib/cloudinary' -import { OVERLAY_PUBLIC_IDS } from '@/lib/cloudinary-overlays' +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Slider } from "@/components/ui/slider"; +import { useImageStore } from "@/lib/store"; +import { Trash2, Eye, EyeOff } from "lucide-react"; +import { getCldImageUrl } from "@/lib/cloudinary"; +import { OVERLAY_PUBLIC_IDS } from "@/lib/cloudinary-overlays"; export function OverlayControls() { const { @@ -14,66 +14,70 @@ export function OverlayControls() { updateImageOverlay, removeImageOverlay, clearImageOverlays, - } = useImageStore() + } = useImageStore(); - const [selectedOverlayId, setSelectedOverlayId] = useState(null) + const [selectedOverlayId, setSelectedOverlayId] = useState( + null + ); const selectedOverlay = imageOverlays.find( (overlay) => overlay.id === selectedOverlayId - ) + ); const handleUpdateSize = (value: number[]) => { if (selectedOverlay) { - updateImageOverlay(selectedOverlay.id, { size: value[0] }) + updateImageOverlay(selectedOverlay.id, { size: value[0] }); } - } + }; const handleUpdateRotation = (value: number[]) => { if (selectedOverlay) { - updateImageOverlay(selectedOverlay.id, { rotation: value[0] }) + updateImageOverlay(selectedOverlay.id, { rotation: value[0] }); } - } + }; const handleUpdateOpacity = (value: number[]) => { if (selectedOverlay) { - updateImageOverlay(selectedOverlay.id, { opacity: value[0] }) + updateImageOverlay(selectedOverlay.id, { opacity: value[0] }); } - } + }; const handleToggleFlipX = () => { if (selectedOverlay) { - updateImageOverlay(selectedOverlay.id, { flipX: !selectedOverlay.flipX }) + updateImageOverlay(selectedOverlay.id, { flipX: !selectedOverlay.flipX }); } - } + }; const handleToggleFlipY = () => { if (selectedOverlay) { - updateImageOverlay(selectedOverlay.id, { flipY: !selectedOverlay.flipY }) + updateImageOverlay(selectedOverlay.id, { flipY: !selectedOverlay.flipY }); } - } + }; const handleToggleVisibility = (id: string) => { - const overlay = imageOverlays.find((o) => o.id === id) + const overlay = imageOverlays.find((o) => o.id === id); if (overlay) { - updateImageOverlay(id, { isVisible: !overlay.isVisible }) + updateImageOverlay(id, { isVisible: !overlay.isVisible }); } - } + }; - const handleUpdatePosition = (axis: 'x' | 'y', value: number[]) => { + const handleUpdatePosition = (axis: "x" | "y", value: number[]) => { if (selectedOverlay) { updateImageOverlay(selectedOverlay.id, { position: { ...selectedOverlay.position, [axis]: value[0], }, - }) + }); } - } + }; return (
-

Image Overlays

+

+ Image Overlays +

@@ -277,8 +309,8 @@ export function OverlayControls() { variant="destructive" size="sm" onClick={() => { - removeImageOverlay(selectedOverlay.id) - setSelectedOverlayId(null) + removeImageOverlay(selectedOverlay.id); + setSelectedOverlayId(null); }} className="w-full h-10 rounded-xl" > @@ -289,6 +321,5 @@ export function OverlayControls() {
)}
- ) + ); } - diff --git a/components/text-overlay/text-overlay-controls.tsx b/components/text-overlay/text-overlay-controls.tsx index 574f42c..18ea964 100644 --- a/components/text-overlay/text-overlay-controls.tsx +++ b/components/text-overlay/text-overlay-controls.tsx @@ -1,20 +1,20 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Slider } from '@/components/ui/slider'; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Slider } from "@/components/ui/slider"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select'; -import { GlassInputWrapper } from '@/components/ui/glass-input-wrapper'; -import { useImageStore } from '@/lib/store'; -import { Trash2, Eye, EyeOff } from 'lucide-react'; -import { fontFamilies, getAvailableFontWeights } from '@/lib/constants/fonts'; +} from "@/components/ui/select"; +import { GlassInputWrapper } from "@/components/ui/glass-input-wrapper"; +import { useImageStore } from "@/lib/store"; +import { Trash2, Eye, EyeOff } from "lucide-react"; +import { fontFamilies, getAvailableFontWeights } from "@/lib/constants/fonts"; export const TextOverlayControls = () => { const { @@ -25,7 +25,7 @@ export const TextOverlayControls = () => { clearTextOverlays, } = useImageStore(); - const [newText, setNewText] = useState(''); + const [newText, setNewText] = useState(""); const [selectedOverlayId, setSelectedOverlayId] = useState( null ); @@ -36,31 +36,38 @@ export const TextOverlayControls = () => { const handleAddText = () => { if (newText.trim()) { - const defaultFont = 'system'; + const defaultFont = "system"; const availableWeights = getAvailableFontWeights(defaultFont); addTextOverlay({ text: newText.trim(), position: { x: 50, y: 50 }, + rotation: 0, fontSize: 24, - fontWeight: availableWeights[0] || 'normal', + fontWeight: availableWeights[0] || "normal", fontFamily: defaultFont, - color: '#ffffff', + color: "#ffffff", opacity: 1, isVisible: true, - orientation: 'horizontal', + orientation: "horizontal", textShadow: { enabled: true, - color: 'rgba(0, 0, 0, 0.5)', + color: "rgba(0, 0, 0, 0.5)", blur: 4, offsetX: 2, offsetY: 2, }, }); - setNewText(''); + setNewText(""); } }; - const handleUpdatePosition = (axis: 'x' | 'y', value: number[]) => { + const handleUpdateTextRotation = (value: number[]) => { + if (selectedOverlay) { + updateTextOverlay(selectedOverlay.id, { rotation: value[0] }); + } + }; + + const handleUpdatePosition = (axis: "x" | "y", value: number[]) => { if (selectedOverlay) { updateTextOverlay(selectedOverlay.id, { position: { @@ -112,7 +119,7 @@ export const TextOverlayControls = () => { // If the current weight is not available for the new font, default to the first available weight const newWeight = availableWeights.includes(currentWeight) ? currentWeight - : availableWeights[0] || 'normal'; + : availableWeights[0] || "normal"; updateTextOverlay(selectedOverlay.id, { fontFamily, @@ -121,7 +128,7 @@ export const TextOverlayControls = () => { } }; - const handleUpdateOrientation = (orientation: 'horizontal' | 'vertical') => { + const handleUpdateOrientation = (orientation: "horizontal" | "vertical") => { if (selectedOverlay) { updateTextOverlay(selectedOverlay.id, { orientation }); } @@ -173,7 +180,7 @@ export const TextOverlayControls = () => { placeholder="Enter text..." value={newText} onChange={(e) => setNewText(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddText()} + onKeyDown={(e) => e.key === "Enter" && handleAddText()} className="h-11 rounded-xl border-border focus:border-primary focus:ring-2 focus:ring-ring" />
@@ -189,8 +196,8 @@ export const TextOverlayControls = () => { key={overlay.id} className={`flex items-center gap-2 p-2 rounded-xl border cursor-pointer transition-colors ${ selectedOverlayId === overlay.id - ? 'bg-accent border-primary' - : 'bg-background hover:bg-accent border-border' + ? "bg-accent border-primary" + : "bg-background hover:bg-accent border-border" }`} onClick={() => setSelectedOverlayId(overlay.id)} > @@ -265,238 +272,281 @@ export const TextOverlayControls = () => { > - - - {fontFamilies.map((font) => ( - - {font.name} - - ))} - - -
- - +
-

- {getAvailableFontWeights(selectedOverlay.fontFamily).length}{' '} - weight - {getAvailableFontWeights(selectedOverlay.fontFamily).length !== 1 - ? 's' - : ''}{' '} - available -

+ + +

+ {getAvailableFontWeights(selectedOverlay.fontFamily).length} weight + {getAvailableFontWeights(selectedOverlay.fontFamily).length !== 1 + ? "s" + : ""}{" "} + available +

- - -
- Font Size -
- - {selectedOverlay.fontSize}px -
+ + +
+ + Font Size + +
+ + + {selectedOverlay.fontSize}px +
+
-
- Opacity -
- - {Math.round(selectedOverlay.opacity * 100)}% -
+
+ + Opacity + +
+ + + {Math.round(selectedOverlay.opacity * 100)}% +
+
- {/* Text Shadow Controls */} -
-
-

- Text Shadow -

- -
+ {/* Rotation */} +
+ + Rotation + +
+ + + {selectedOverlay.rotation}° + +
+
- {selectedOverlay.textShadow.enabled && ( -
- {/* Shadow Color */} -
+ {/* Text Shadow Controls */} +
+
+

+ Text Shadow +

+ +
+ + {selectedOverlay.textShadow.enabled && ( +
+ {/* Shadow Color */} +
+ + handleUpdateTextShadow({ color: e.target.value }) + } + className="w-12 h-10 p-1 rounded-lg border border-border" + /> + handleUpdateTextShadow({ color: e.target.value }) } - className="w-12 h-10 p-1 rounded-lg border border-border" + className="border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0" /> - - - handleUpdateTextShadow({ color: e.target.value }) - } - className="border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0" - /> - -
+ +
- {/* Shadow Blur */} -
- Blur -
- - handleUpdateTextShadow({ blur: value[0] }) - } - max={20} - min={0} - step={1} - /> - {selectedOverlay.textShadow.blur}px -
+ {/* Shadow Blur */} +
+ + Blur + +
+ + handleUpdateTextShadow({ blur: value[0] }) + } + max={20} + min={0} + step={1} + /> + + {selectedOverlay.textShadow.blur}px +
+
- {/* Shadow Offset X */} -
- Offset X -
- - handleUpdateTextShadow({ offsetX: value[0] }) - } - max={20} - min={-20} - step={1} - /> - {selectedOverlay.textShadow.offsetX}px -
+ {/* Shadow Offset X */} +
+ + Offset X + +
+ + handleUpdateTextShadow({ offsetX: value[0] }) + } + max={20} + min={-20} + step={1} + /> + + {selectedOverlay.textShadow.offsetX}px +
+
- {/* Shadow Offset Y */} -
- Offset Y -
- - handleUpdateTextShadow({ offsetY: value[0] }) - } - max={20} - min={-20} - step={1} - /> - {selectedOverlay.textShadow.offsetY}px -
+ {/* Shadow Offset Y */} +
+ + Offset Y + +
+ + handleUpdateTextShadow({ offsetY: value[0] }) + } + max={20} + min={-20} + step={1} + /> + + {selectedOverlay.textShadow.offsetY}px +
- )} -
+
+ )} +
-
-

- Position -

- {/* X position */} -
- X Position -
- handleUpdatePosition('x', value)} - max={100} - min={0} - step={1} - /> - {Math.round(selectedOverlay.position.x)}% -
+
+

Position

+ {/* X position */} +
+ + X Position + +
+ handleUpdatePosition("x", value)} + max={100} + min={0} + step={1} + /> + + {Math.round(selectedOverlay.position.x)}% +
+
- {/* Y position */} -
- Y Position -
- handleUpdatePosition('y', value)} - max={100} - min={0} - step={1} - /> - {Math.round(selectedOverlay.position.y)}% -
+ {/* Y position */} +
+ + Y Position + +
+ handleUpdatePosition("y", value)} + max={100} + min={0} + step={1} + /> + + {Math.round(selectedOverlay.position.y)}% +
+
)}
); }; - diff --git a/lib/image-storage.ts b/lib/image-storage.ts index 16a31e9..93c3fae 100644 --- a/lib/image-storage.ts +++ b/lib/image-storage.ts @@ -37,22 +37,25 @@ async function openDB(): Promise { * Save an image blob to IndexedDB * Returns a unique ID for the image */ -export async function saveImageBlob(blob: Blob, imageId: string): Promise { +export async function saveImageBlob( + blob: Blob, + imageId: string +): Promise { const db = await openDB(); - + return new Promise((resolve, reject) => { const transaction = db.transaction([STORE_NAME], "readwrite"); const store = transaction.objectStore(STORE_NAME); - + const entry: ImageStorageEntry = { id: imageId, blob: blob, type: blob.type, timestamp: Date.now(), }; - + const request = store.put(entry); - + request.onsuccess = () => resolve(imageId); request.onerror = () => reject(request.error); }); @@ -63,17 +66,17 @@ export async function saveImageBlob(blob: Blob, imageId: string): Promise { const db = await openDB(); - + return new Promise((resolve, reject) => { const transaction = db.transaction([STORE_NAME], "readonly"); const store = transaction.objectStore(STORE_NAME); const request = store.get(imageId); - + request.onsuccess = () => { const entry = request.result as ImageStorageEntry | undefined; resolve(entry?.blob || null); }; - + request.onerror = () => reject(request.error); }); } @@ -83,12 +86,12 @@ export async function getImageBlob(imageId: string): Promise { */ export async function deleteImageBlob(imageId: string): Promise { const db = await openDB(); - + return new Promise((resolve, reject) => { const transaction = db.transaction([STORE_NAME], "readwrite"); const store = transaction.objectStore(STORE_NAME); const request = store.delete(imageId); - + request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); @@ -106,7 +109,9 @@ export async function hasImageBlob(imageId: string): Promise { * Generate a blob URL from a stored image ID * This recreates the blob URL from the stored blob */ -export async function getBlobUrlFromStored(imageId: string): Promise { +export async function getBlobUrlFromStored( + imageId: string +): Promise { const blob = await getImageBlob(imageId); if (!blob) return null; return URL.createObjectURL(blob); @@ -117,18 +122,17 @@ export async function getBlobUrlFromStored(imageId: string): Promise { const db = await openDB(); - + return new Promise((resolve, reject) => { const transaction = db.transaction([STORE_NAME], "readonly"); const store = transaction.objectStore(STORE_NAME); const request = store.getAllKeys(); - + request.onsuccess = () => { const keys = request.result as string[]; resolve(keys); }; - + request.onerror = () => reject(request.error); }); } - diff --git a/lib/store/index.ts b/lib/store/index.ts index 2b0ef18..0696a1e 100644 --- a/lib/store/index.ts +++ b/lib/store/index.ts @@ -1,182 +1,214 @@ -'use client' +"use client"; -import React from 'react' -import { create } from 'zustand' -import { exportImageWithGradient } from './export-utils' -import { GradientKey } from '@/lib/constants/gradient-colors' -import { AspectRatioKey } from '@/lib/constants/aspect-ratios' -import { BackgroundConfig, BackgroundType } from '@/lib/constants/backgrounds' -import { gradientColors } from '@/lib/constants/gradient-colors' -import { solidColors } from '@/lib/constants/solid-colors' +import React from "react"; +import { create } from "zustand"; +import { exportImageWithGradient } from "./export-utils"; +import { GradientKey } from "@/lib/constants/gradient-colors"; +import { AspectRatioKey } from "@/lib/constants/aspect-ratios"; +import { BackgroundConfig, BackgroundType } from "@/lib/constants/backgrounds"; +import { gradientColors } from "@/lib/constants/gradient-colors"; +import { solidColors } from "@/lib/constants/solid-colors"; interface TextShadow { - enabled: boolean - color: string - blur: number - offsetX: number - offsetY: number + enabled: boolean; + color: string; + blur: number; + offsetX: number; + offsetY: number; } export interface TextOverlay { - id: string - text: string - position: { x: number; y: number } - fontSize: number - fontWeight: string - fontFamily: string - color: string - opacity: number - isVisible: boolean - orientation: 'horizontal' | 'vertical' - textShadow: TextShadow + id: string; + text: string; + position: { x: number; y: number }; + rotation: number; + fontSize: number; + fontWeight: string; + fontFamily: string; + color: string; + opacity: number; + isVisible: boolean; + orientation: "horizontal" | "vertical"; + textShadow: TextShadow; } export interface ImageOverlay { - id: string - src: string - position: { x: number; y: number } // Position in pixels relative to canvas - size: number // Size in pixels - rotation: number // Rotation in degrees - opacity: number - flipX: boolean - flipY: boolean - isVisible: boolean - isCustom?: boolean // Whether it's a custom uploaded overlay + id: string; + src: string; + position: { x: number; y: number }; // Position in pixels relative to canvas + size: number; // Size in pixels + rotation: number; // Rotation in degrees + opacity: number; + flipX: boolean; + flipY: boolean; + isVisible: boolean; + isCustom?: boolean; // Whether it's a custom uploaded overlay } export interface ImageBorder { - enabled: boolean - width: number - color: string - type: 'none' | 'solid' | 'glassy' | 'infinite-mirror' | 'window' | 'stack' | 'ruler' | 'eclipse' | 'dotted' | 'focus' - theme?: 'light' | 'dark' - padding?: number - title?: string - style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'default' | 'outline' | 'border' - top?: boolean - right?: boolean - bottom?: boolean - left?: boolean - borderRadius?: number - inset?: boolean + enabled: boolean; + width: number; + color: string; + type: + | "none" + | "solid" + | "glassy" + | "infinite-mirror" + | "window" + | "stack" + | "ruler" + | "eclipse" + | "dotted" + | "focus"; + theme?: "light" | "dark"; + padding?: number; + title?: string; + style?: + | "solid" + | "dashed" + | "dotted" + | "double" + | "default" + | "outline" + | "border"; + top?: boolean; + right?: boolean; + bottom?: boolean; + left?: boolean; + borderRadius?: number; + inset?: boolean; } export interface ImageShadow { - enabled: boolean - blur: number - offsetX: number - offsetY: number - spread: number - color: string + enabled: boolean; + blur: number; + offsetX: number; + offsetY: number; + spread: number; + color: string; } // Helper function to parse gradient string and extract colors -function parseGradientColors(gradientStr: string): { colorA: string; colorB: string; direction: number } { +function parseGradientColors(gradientStr: string): { + colorA: string; + colorB: string; + direction: number; +} { // Default fallback - let colorA = '#4168d0' - let colorB = '#c850c0' - let direction = 43 + let colorA = "#4168d0"; + let colorB = "#c850c0"; + let direction = 43; try { // Extract angle from linear-gradient(angle, ...) - const angleMatch = gradientStr.match(/linear-gradient\((\d+)deg/) + const angleMatch = gradientStr.match(/linear-gradient\((\d+)deg/); if (angleMatch) { - direction = parseInt(angleMatch[1], 10) + direction = parseInt(angleMatch[1], 10); } // Extract RGB colors - const rgbMatches = gradientStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g) + const rgbMatches = gradientStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g); if (rgbMatches && rgbMatches.length >= 2) { - colorA = rgbMatches[0] - colorB = rgbMatches[rgbMatches.length - 1] + colorA = rgbMatches[0]; + colorB = rgbMatches[rgbMatches.length - 1]; } else { // Try hex colors - const hexMatches = gradientStr.match(/#[0-9A-Fa-f]{6}/g) + const hexMatches = gradientStr.match(/#[0-9A-Fa-f]{6}/g); if (hexMatches && hexMatches.length >= 2) { - colorA = hexMatches[0] - colorB = hexMatches[hexMatches.length - 1] + colorA = hexMatches[0]; + colorB = hexMatches[hexMatches.length - 1]; } } } catch (e) { // Use defaults } - return { colorA, colorB, direction } + return { colorA, colorB, direction }; } interface EditorState { // Screenshot/image state screenshot: { - src: string | null - scale: number - offsetX: number - offsetY: number - rotation: number - radius: number - } - + src: string | null; + scale: number; + offsetX: number; + offsetY: number; + rotation: number; + radius: number; + }; + // Background state (for Konva) background: { - mode: 'solid' | 'gradient' - colorA: string - colorB: string - gradientDirection: number - } - + mode: "solid" | "gradient"; + colorA: string; + colorB: string; + gradientDirection: number; + }; + // Shadow state (for Konva) shadow: { - enabled: boolean - elevation: number - side: 'bottom' | 'right' | 'bottom-right' - softness: number - color: string - intensity: number - } - + enabled: boolean; + elevation: number; + side: "bottom" | "right" | "bottom-right"; + softness: number; + color: string; + intensity: number; + }; + // Pattern state pattern: { - enabled: boolean - type: string - scale: number - spacing: number - color: string - rotation: number - blur: number - opacity: number - } - + enabled: boolean; + type: string; + scale: number; + spacing: number; + color: string; + rotation: number; + blur: number; + opacity: number; + }; + // Frame state (same as imageBorder) frame: { - enabled: boolean - type: 'none' | 'solid' | 'glassy' | 'infinite-mirror' | 'window' | 'stack' | 'ruler' | 'eclipse' | 'dotted' | 'focus' - width: number - color: string - theme?: 'light' | 'dark' - padding?: number - title?: string - } - + enabled: boolean; + type: + | "none" + | "solid" + | "glassy" + | "infinite-mirror" + | "window" + | "stack" + | "ruler" + | "eclipse" + | "dotted" + | "focus"; + width: number; + color: string; + theme?: "light" | "dark"; + padding?: number; + title?: string; + }; + // Canvas state canvas: { - aspectRatio: 'square' | '4:3' | '2:1' | '3:2' | 'free' - padding: number - } - + aspectRatio: "square" | "4:3" | "2:1" | "3:2" | "free"; + padding: number; + }; + // Noise state noise: { - enabled: boolean - type: string - opacity: number - } - + enabled: boolean; + type: string; + opacity: number; + }; + // Setters - setScreenshot: (screenshot: Partial) => void - setBackground: (background: Partial) => void - setShadow: (shadow: Partial) => void - setPattern: (pattern: Partial) => void - setFrame: (frame: Partial) => void - setCanvas: (canvas: Partial) => void - setNoise: (noise: Partial) => void + setScreenshot: (screenshot: Partial) => void; + setBackground: (background: Partial) => void; + setShadow: (shadow: Partial) => void; + setPattern: (pattern: Partial) => void; + setFrame: (frame: Partial) => void; + setCanvas: (canvas: Partial) => void; + setNoise: (noise: Partial) => void; } // Create editor store @@ -189,151 +221,158 @@ export const useEditorStore = create((set, get) => ({ rotation: 0, radius: 0, }, - + background: { - mode: 'gradient', - colorA: '#4168d0', - colorB: '#c850c0', + mode: "gradient", + colorA: "#4168d0", + colorB: "#c850c0", gradientDirection: 43, }, - + shadow: { enabled: false, elevation: 10, - side: 'bottom', + side: "bottom", softness: 10, - color: 'rgba(0, 0, 0, 0.3)', + color: "rgba(0, 0, 0, 0.3)", intensity: 1, }, - + pattern: { enabled: false, - type: 'grid', + type: "grid", scale: 1, spacing: 20, - color: '#000000', + color: "#000000", rotation: 0, blur: 0, opacity: 0.5, }, - + frame: { enabled: false, - type: 'none', + type: "none", width: 2, - color: '#000000', - theme: 'light', + color: "#000000", + theme: "light", padding: 20, - title: '', + title: "", }, - + canvas: { - aspectRatio: 'free', + aspectRatio: "free", padding: 40, }, - + noise: { enabled: false, - type: 'none', + type: "none", opacity: 0.5, }, - + setScreenshot: (screenshot) => { set((state) => ({ screenshot: { ...state.screenshot, ...screenshot }, - })) + })); }, - + setBackground: (background) => { set((state) => ({ background: { ...state.background, ...background }, - })) + })); }, - + setShadow: (shadow) => { set((state) => ({ shadow: { ...state.shadow, ...shadow }, - })) + })); }, - + setPattern: (pattern) => { set((state) => ({ pattern: { ...state.pattern, ...pattern }, - })) + })); }, - + setFrame: (frame) => { set((state) => ({ frame: { ...state.frame, ...frame }, - })) + })); }, - + setCanvas: (canvas) => { set((state) => ({ canvas: { ...state.canvas, ...canvas }, - })) + })); }, - + setNoise: (noise) => { set((state) => ({ noise: { ...state.noise, ...noise }, - })) + })); }, -})) +})); // Sync hook to keep editor store in sync with image store export function useEditorStoreSync() { - const imageStore = useImageStore() - const editorStore = useEditorStore() + const imageStore = useImageStore(); + const editorStore = useEditorStore(); // Sync when image store changes React.useEffect(() => { // Sync screenshot src if (imageStore.uploadedImageUrl !== editorStore.screenshot.src) { - editorStore.setScreenshot({ src: imageStore.uploadedImageUrl }) + editorStore.setScreenshot({ src: imageStore.uploadedImageUrl }); } - + // Sync screenshot scale if (imageStore.imageScale / 100 !== editorStore.screenshot.scale) { - editorStore.setScreenshot({ scale: imageStore.imageScale / 100 }) + editorStore.setScreenshot({ scale: imageStore.imageScale / 100 }); } - + // Sync screenshot radius if (imageStore.borderRadius !== editorStore.screenshot.radius) { - editorStore.setScreenshot({ radius: imageStore.borderRadius }) + editorStore.setScreenshot({ radius: imageStore.borderRadius }); } - + // Sync background - const bgConfig = imageStore.backgroundConfig - if (bgConfig.type === 'gradient') { - const gradientStr = gradientColors[bgConfig.value as GradientKey] || gradientColors.sunset_vibes - const { colorA, colorB, direction } = parseGradientColors(gradientStr) + const bgConfig = imageStore.backgroundConfig; + if (bgConfig.type === "gradient") { + const gradientStr = + gradientColors[bgConfig.value as GradientKey] || + gradientColors.sunset_vibes; + const { colorA, colorB, direction } = parseGradientColors(gradientStr); if ( - editorStore.background.mode !== 'gradient' || + editorStore.background.mode !== "gradient" || editorStore.background.colorA !== colorA || editorStore.background.colorB !== colorB || editorStore.background.gradientDirection !== direction ) { editorStore.setBackground({ - mode: 'gradient', + mode: "gradient", colorA, colorB, gradientDirection: direction, - }) + }); } - } else if (bgConfig.type === 'solid') { - const color = (solidColors as Record)[bgConfig.value as string] || '#ffffff' - if (editorStore.background.mode !== 'solid' || editorStore.background.colorA !== color) { + } else if (bgConfig.type === "solid") { + const color = + (solidColors as Record)[bgConfig.value as string] || + "#ffffff"; + if ( + editorStore.background.mode !== "solid" || + editorStore.background.colorA !== color + ) { editorStore.setBackground({ - mode: 'solid', + mode: "solid", colorA: color, colorB: color, - }) + }); } } - + // Sync frame - const frame = imageStore.imageBorder + const frame = imageStore.imageBorder; if ( editorStore.frame.enabled !== frame.enabled || editorStore.frame.type !== frame.type || @@ -351,11 +390,11 @@ export function useEditorStoreSync() { theme: frame.theme, padding: frame.padding, title: frame.title, - }) + }); } - + // Sync shadow - const shadow = imageStore.imageShadow + const shadow = imageStore.imageShadow; if ( editorStore.shadow.enabled !== shadow.enabled || editorStore.shadow.softness !== shadow.blur || @@ -366,28 +405,37 @@ export function useEditorStoreSync() { softness: shadow.blur, color: shadow.color, elevation: Math.max(Math.abs(shadow.offsetX), Math.abs(shadow.offsetY)), - side: shadow.offsetX > 0 ? 'right' : shadow.offsetY > 0 ? 'bottom' : 'bottom', + side: + shadow.offsetX > 0 + ? "right" + : shadow.offsetY > 0 + ? "bottom" + : "bottom", intensity: 1, - }) + }); } - + // Sync canvas aspect ratio - const aspectRatioMap: Record = { - '1_1': 'square', - '4_3': '4:3', - '2_1': '2:1', - '3_2': '3:2', - '16_9': 'free', - '9_16': 'free', - '4_5': 'free', - '3_4': 'free', - '2_3': 'free', - '5_4': 'free', - '16_10': 'free', - } - const canvasAspectRatio = aspectRatioMap[imageStore.selectedAspectRatio] || 'free' + const aspectRatioMap: Record< + AspectRatioKey, + "square" | "4:3" | "2:1" | "3:2" | "free" + > = { + "1_1": "square", + "4_3": "4:3", + "2_1": "2:1", + "3_2": "3:2", + "16_9": "free", + "9_16": "free", + "4_5": "free", + "3_4": "free", + "2_3": "free", + "5_4": "free", + "16_10": "free", + }; + const canvasAspectRatio = + aspectRatioMap[imageStore.selectedAspectRatio] || "free"; if (editorStore.canvas.aspectRatio !== canvasAspectRatio) { - editorStore.setCanvas({ aspectRatio: canvasAspectRatio }) + editorStore.setCanvas({ aspectRatio: canvasAspectRatio }); } }, [ imageStore.uploadedImageUrl, @@ -397,73 +445,73 @@ export function useEditorStoreSync() { imageStore.imageBorder, imageStore.imageShadow, imageStore.selectedAspectRatio, - ]) + ]); } // Re-export existing ImageState interface and store interface ImageState { - uploadedImageUrl: string | null - imageName: string | null - selectedGradient: GradientKey - borderRadius: number - backgroundBorderRadius: number - selectedAspectRatio: AspectRatioKey - backgroundConfig: BackgroundConfig - backgroundBlur: number - backgroundNoise: number - textOverlays: TextOverlay[] - imageOverlays: ImageOverlay[] - imageOpacity: number - imageScale: number - imageBorder: ImageBorder - imageShadow: ImageShadow + uploadedImageUrl: string | null; + imageName: string | null; + selectedGradient: GradientKey; + borderRadius: number; + backgroundBorderRadius: number; + selectedAspectRatio: AspectRatioKey; + backgroundConfig: BackgroundConfig; + backgroundBlur: number; + backgroundNoise: number; + textOverlays: TextOverlay[]; + imageOverlays: ImageOverlay[]; + imageOpacity: number; + imageScale: number; + imageBorder: ImageBorder; + imageShadow: ImageShadow; perspective3D: { - perspective: number - rotateX: number - rotateY: number - rotateZ: number - translateX: number - translateY: number - scale: number - } - setImage: (file: File) => void - clearImage: () => void - setGradient: (gradient: GradientKey) => void - setBorderRadius: (radius: number) => void - setBackgroundBorderRadius: (radius: number) => void - setAspectRatio: (aspectRatio: AspectRatioKey) => void - setBackgroundConfig: (config: BackgroundConfig) => void - setBackgroundType: (type: BackgroundType) => void - setBackgroundValue: (value: string) => void - setBackgroundOpacity: (opacity: number) => void - setBackgroundBlur: (blur: number) => void - setBackgroundNoise: (noise: number) => void - addTextOverlay: (overlay: Omit) => void - updateTextOverlay: (id: string, updates: Partial) => void - removeTextOverlay: (id: string) => void - clearTextOverlays: () => void - addImageOverlay: (overlay: Omit) => void - updateImageOverlay: (id: string, updates: Partial) => void - removeImageOverlay: (id: string) => void - clearImageOverlays: () => void - setImageOpacity: (opacity: number) => void - setImageScale: (scale: number) => void - setImageBorder: (border: ImageBorder | Partial) => void - setImageShadow: (shadow: ImageShadow | Partial) => void - setPerspective3D: (perspective: Partial) => void - exportImage: () => Promise + perspective: number; + rotateX: number; + rotateY: number; + rotateZ: number; + translateX: number; + translateY: number; + scale: number; + }; + setImage: (file: File) => void; + clearImage: () => void; + setGradient: (gradient: GradientKey) => void; + setBorderRadius: (radius: number) => void; + setBackgroundBorderRadius: (radius: number) => void; + setAspectRatio: (aspectRatio: AspectRatioKey) => void; + setBackgroundConfig: (config: BackgroundConfig) => void; + setBackgroundType: (type: BackgroundType) => void; + setBackgroundValue: (value: string) => void; + setBackgroundOpacity: (opacity: number) => void; + setBackgroundBlur: (blur: number) => void; + setBackgroundNoise: (noise: number) => void; + addTextOverlay: (overlay: Omit) => void; + updateTextOverlay: (id: string, updates: Partial) => void; + removeTextOverlay: (id: string) => void; + clearTextOverlays: () => void; + addImageOverlay: (overlay: Omit) => void; + updateImageOverlay: (id: string, updates: Partial) => void; + removeImageOverlay: (id: string) => void; + clearImageOverlays: () => void; + setImageOpacity: (opacity: number) => void; + setImageScale: (scale: number) => void; + setImageBorder: (border: ImageBorder | Partial) => void; + setImageShadow: (shadow: ImageShadow | Partial) => void; + setPerspective3D: (perspective: Partial) => void; + exportImage: () => Promise; } export const useImageStore = create((set, get) => ({ uploadedImageUrl: null, imageName: null, - selectedGradient: 'sunset_vibes', + selectedGradient: "sunset_vibes", borderRadius: 10, backgroundBorderRadius: 10, - selectedAspectRatio: '16_9', + selectedAspectRatio: "16_9", backgroundConfig: { - type: 'image', - value: 'backgrounds/backgrounds/assets/asset-20', + type: "image", + value: "backgrounds/backgrounds/assets/asset-20", opacity: 1, }, backgroundBlur: 0, @@ -475,11 +523,11 @@ export const useImageStore = create((set, get) => ({ imageBorder: { enabled: false, width: 2, - color: '#000000', - type: 'none', - theme: 'light', + color: "#000000", + type: "none", + theme: "light", padding: 20, - title: '', + title: "", }, imageShadow: { enabled: false, @@ -487,7 +535,7 @@ export const useImageStore = create((set, get) => ({ offsetX: 0, offsetY: 4, spread: 0, - color: 'rgba(0, 0, 0, 0.3)', + color: "rgba(0, 0, 0, 0.3)", }, perspective3D: { perspective: 200, // em units, converted to px @@ -500,19 +548,18 @@ export const useImageStore = create((set, get) => ({ }, setImage: (file: File) => { - const imageUrl = URL.createObjectURL(file) + const imageUrl = URL.createObjectURL(file); set({ uploadedImageUrl: imageUrl, imageName: file.name, imageScale: 100, borderRadius: 10, backgroundConfig: { - type: 'image', - value: 'backgrounds/backgrounds/assets/asset-20', + type: "image", + value: "backgrounds/backgrounds/assets/asset-20", opacity: 1, - }, - selectedGradient: 'orange_fire', + selectedGradient: "orange_fire", perspective3D: { perspective: 200, rotateX: 0, @@ -522,111 +569,112 @@ export const useImageStore = create((set, get) => ({ translateY: 0, scale: 1, }, - }) + }); }, clearImage: () => { - const { uploadedImageUrl } = get() + const { uploadedImageUrl } = get(); if (uploadedImageUrl) { - URL.revokeObjectURL(uploadedImageUrl) + URL.revokeObjectURL(uploadedImageUrl); } set({ uploadedImageUrl: null, imageName: null, - }) + }); }, setGradient: (gradient: GradientKey) => { - set({ selectedGradient: gradient }) + set({ selectedGradient: gradient }); }, setBorderRadius: (radius: number) => { - set({ borderRadius: radius }) + set({ borderRadius: radius }); }, setBackgroundBorderRadius: (radius: number) => { - set({ backgroundBorderRadius: radius }) + set({ backgroundBorderRadius: radius }); }, setAspectRatio: (aspectRatio: AspectRatioKey) => { - set({ selectedAspectRatio: aspectRatio }) + set({ selectedAspectRatio: aspectRatio }); }, setBackgroundConfig: (config: BackgroundConfig) => { - set({ backgroundConfig: config }) + set({ backgroundConfig: config }); }, setBackgroundType: (type: BackgroundType) => { - const { backgroundConfig } = get() - + const { backgroundConfig } = get(); + // If switching to 'image' type and current value is not a valid image, set default to radiant9 - if (type === 'image') { - const currentValue = backgroundConfig.value - const isGradientKey = currentValue in gradientColors - const isSolidColorKey = currentValue in solidColors - const isValidImage = - typeof currentValue === 'string' && - (currentValue.startsWith('blob:') || - currentValue.startsWith('http') || - currentValue.startsWith('data:') || - // Check if it's a Cloudinary public ID (contains '/' but not a gradient/solid key) - (currentValue.includes('/') && !isGradientKey && !isSolidColorKey)) - + if (type === "image") { + const currentValue = backgroundConfig.value; + const isGradientKey = currentValue in gradientColors; + const isSolidColorKey = currentValue in solidColors; + const isValidImage = + typeof currentValue === "string" && + (currentValue.startsWith("blob:") || + currentValue.startsWith("http") || + currentValue.startsWith("data:") || + // Check if it's a Cloudinary public ID (contains '/' but not a gradient/solid key) + (currentValue.includes("/") && !isGradientKey && !isSolidColorKey)); + // If current value is a gradient or solid color key, or not a valid image, set default to radiant9 - const newValue = (isGradientKey || isSolidColorKey || !isValidImage) - ? 'backgrounds/backgrounds/assets/asset-20' - : currentValue - + const newValue = + isGradientKey || isSolidColorKey || !isValidImage + ? "backgrounds/backgrounds/assets/asset-20" + : currentValue; + set({ backgroundConfig: { ...backgroundConfig, type, value: newValue, }, - }) + }); } else { set({ backgroundConfig: { ...backgroundConfig, type, }, - }) + }); } }, setBackgroundValue: (value: string) => { - const { backgroundConfig } = get() + const { backgroundConfig } = get(); set({ backgroundConfig: { ...backgroundConfig, value, }, - }) + }); }, setBackgroundOpacity: (opacity: number) => { - const { backgroundConfig } = get() + const { backgroundConfig } = get(); set({ backgroundConfig: { ...backgroundConfig, opacity, }, - }) + }); }, setBackgroundBlur: (blur: number) => { - set({ backgroundBlur: blur }) + set({ backgroundBlur: blur }); }, setBackgroundNoise: (noise: number) => { - set({ backgroundNoise: noise }) + set({ backgroundNoise: noise }); }, addTextOverlay: (overlay) => { - const id = `text-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const id = `text-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; set((state) => ({ textOverlays: [...state.textOverlays, { ...overlay, id }], - })) + })); }, updateTextOverlay: (id, updates) => { @@ -634,24 +682,26 @@ export const useImageStore = create((set, get) => ({ textOverlays: state.textOverlays.map((overlay) => overlay.id === id ? { ...overlay, ...updates } : overlay ), - })) + })); }, removeTextOverlay: (id) => { set((state) => ({ textOverlays: state.textOverlays.filter((overlay) => overlay.id !== id), - })) + })); }, clearTextOverlays: () => { - set({ textOverlays: [] }) + set({ textOverlays: [] }); }, addImageOverlay: (overlay) => { - const id = `overlay-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const id = `overlay-${Date.now()}-${Math.random() + .toString(36) + .substr(2, 9)}`; set((state) => ({ imageOverlays: [...state.imageOverlays, { ...overlay, id }], - })) + })); }, updateImageOverlay: (id, updates) => { @@ -659,62 +709,62 @@ export const useImageStore = create((set, get) => ({ imageOverlays: state.imageOverlays.map((overlay) => overlay.id === id ? { ...overlay, ...updates } : overlay ), - })) + })); }, removeImageOverlay: (id) => { set((state) => ({ imageOverlays: state.imageOverlays.filter((overlay) => overlay.id !== id), - })) + })); }, clearImageOverlays: () => { - set({ imageOverlays: [] }) + set({ imageOverlays: [] }); }, setImageOpacity: (opacity: number) => { - set({ imageOpacity: opacity }) + set({ imageOpacity: opacity }); }, setImageScale: (scale: number) => { - set({ imageScale: scale }) + set({ imageScale: scale }); }, setImageBorder: (border: ImageBorder | Partial) => { - const currentBorder = get().imageBorder + const currentBorder = get().imageBorder; set({ imageBorder: { ...currentBorder, ...border, }, - }) + }); }, setImageShadow: (shadow: ImageShadow | Partial) => { - const currentShadow = get().imageShadow + const currentShadow = get().imageShadow; set({ imageShadow: { ...currentShadow, ...shadow, }, - }) + }); }, - setPerspective3D: (perspective: Partial) => { - const currentPerspective = get().perspective3D + setPerspective3D: (perspective: Partial) => { + const currentPerspective = get().perspective3D; set({ perspective3D: { ...currentPerspective, ...perspective, }, - }) + }); }, exportImage: async () => { try { - await exportImageWithGradient('image-render-card') + await exportImageWithGradient("image-render-card"); } catch (error) { - console.error('Export failed:', error) - throw error + console.error("Export failed:", error); + throw error; } }, -})) +})); diff --git a/package-lock.json b/package-lock.json index 2a122f0..799d7b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2607,7 +2607,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2618,7 +2617,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2735,7 +2733,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -3084,8 +3081,7 @@ "version": "0.0.1521046", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dotenv": { "version": "17.2.3", @@ -3593,8 +3589,7 @@ "url": "https://github.com/sponsors/lavrton" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lightningcss": { "version": "1.30.2", @@ -4325,7 +4320,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4335,7 +4329,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4819,7 +4812,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From 6cbe9e1cba0e8c4571d5a18d682e718017d6d0cd Mon Sep 17 00:00:00 2001 From: Narendra Pal Date: Wed, 19 Nov 2025 18:56:24 +0530 Subject: [PATCH 2/2] text-rotation offset changed to center --- components/canvas/layers/TextOverlayLayer.tsx | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/components/canvas/layers/TextOverlayLayer.tsx b/components/canvas/layers/TextOverlayLayer.tsx index fce77e0..bff02e0 100644 --- a/components/canvas/layers/TextOverlayLayer.tsx +++ b/components/canvas/layers/TextOverlayLayer.tsx @@ -1,10 +1,10 @@ -'use client'; +"use client"; -import { Layer, Text, Transformer } from 'react-konva'; -import Konva from 'konva'; -import { useRef, useEffect } from 'react'; -import { getFontCSS } from '@/lib/constants/fonts'; -import type { TextOverlay } from '@/lib/store'; +import { Layer, Text, Transformer } from "react-konva"; +import Konva from "konva"; +import { useRef, useEffect } from "react"; +import { getFontCSS } from "@/lib/constants/fonts"; +import type { TextOverlay } from "@/lib/store"; interface TextOverlayLayerProps { textOverlays: TextOverlay[]; @@ -43,6 +43,16 @@ export function TextOverlayLayer({ } }, [selectedTextId, textOverlays]); + useEffect(() => { + Object.values(textRefs.current).forEach((node) => { + if (!node) return; + const w = node.width(); + const h = node.height(); + node.offsetX(w / 2); + node.offsetY(h / 2); + }); + }, [textOverlays]); + return ( {textOverlays.map((overlay) => { @@ -64,18 +74,15 @@ export function TextOverlayLayer({ x={textX} y={textY} text={overlay.text} + rotation={overlay.rotation} fontSize={overlay.fontSize} fontFamily={getFontCSS(overlay.fontFamily)} fill={overlay.color} opacity={overlay.opacity} - offsetX={0} - offsetY={0} align="center" verticalAlign="middle" shadowColor={ - overlay.textShadow.enabled - ? overlay.textShadow.color - : undefined + overlay.textShadow.enabled ? overlay.textShadow.color : undefined } shadowBlur={ overlay.textShadow.enabled ? overlay.textShadow.blur : 0 @@ -87,9 +94,9 @@ export function TextOverlayLayer({ overlay.textShadow.enabled ? overlay.textShadow.offsetY : 0 } fontStyle={ - String(overlay.fontWeight).includes('italic') - ? 'italic' - : 'normal' + String(overlay.fontWeight).includes("italic") + ? "italic" + : "normal" } fontVariant={String(overlay.fontWeight)} draggable={true} @@ -130,18 +137,19 @@ export function TextOverlayLayer({ updateTextOverlay(overlay.id, { position: { x: newX, y: newY }, fontSize: newFontSize, + rotation: node.rotation(), }); }} onMouseEnter={(e) => { const container = e.target.getStage()?.container(); if (container) { - container.style.cursor = 'move'; + container.style.cursor = "move"; } }} onMouseLeave={(e) => { const container = e.target.getStage()?.container(); if (container) { - container.style.cursor = 'default'; + container.style.cursor = "default"; } }} /> @@ -151,16 +159,13 @@ export function TextOverlayLayer({ ref={textTransformerRef} keepRatio={false} enabledAnchors={[ - 'top-left', - 'top-right', - 'bottom-left', - 'bottom-right', + "top-left", + "top-right", + "bottom-left", + "bottom-right", ]} boundBoxFunc={(oldBox, newBox) => { - if ( - Math.abs(newBox.width) < 20 || - Math.abs(newBox.height) < 20 - ) { + if (Math.abs(newBox.width) < 20 || Math.abs(newBox.height) < 20) { return oldBox; } return newBox; @@ -169,4 +174,3 @@ export function TextOverlayLayer({ ); } -