From 1041a29243c4c5e07af76615ac332b1d814538f2 Mon Sep 17 00:00:00 2001 From: amardelta4 Date: Mon, 1 Jun 2026 13:15:23 +0530 Subject: [PATCH] feat(bg-studio): annotation popovers, crop overlay, redesigned controls, themed scrollbars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1-3 additions to the BG Studio editor: - Annotation toolbar: hover popovers above Arrow / Brush / Counter buttons for color/size/style defaults; arrow line + head styles; brush modes (pencil/highlighter/blur); counter formats (number/roman/alpha) + start-at. - Crop tool: inline CleanShot-style overlay (8 handles, dim mask, rule-of-thirds, live W×H, ratio presets, Enter/Esc); SVG-mask renderer composes nested crops correctly. Lives in new CropOverlay.tsx. - Pixelate redaction now reads as mosaic (blur + contrast + grid overlay). - Export: copy-to-clipboard, JPEG/WebP with quality slider, social-media aspect-ratio presets, drag-drop + paste image import, crop/zoom reset during capture so overlay UI never bakes into the output. - Undo/redo: full state snapshot incl. uploadedImages (crop) and brushes; redo stack with Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z; toolbar Redo button. - Viewport: Ctrl+scroll canvas zoom, click-drag pan, scrollbars adapt to zoom; default alignment switched to middle-center. - Controls: tab-based icon nav (Image/Background/Effects/Devices), compact canvas-size pill in header, "More options" toggle for advanced controls, sticky export bar at bottom. - Misc: adjustable canvas corner radius (ring follows curve), themed scrollbars across the app, fix package.json stray '+' typo. --- app/globals.css | 32 + components/image-generator/Controls.tsx | 373 +++++-- components/image-generator/CropOverlay.tsx | 244 +++++ components/image-generator/ImageGenerator.tsx | 948 +++++++++++++++--- components/image-generator/ImagePreview.tsx | 569 +++++++++-- components/image-generator/icons.tsx | 60 ++ components/image-generator/types.ts | 93 +- package-lock.json | 7 +- package.json | 2 +- public/sitemap.xml | 22 +- 10 files changed, 2006 insertions(+), 344 deletions(-) create mode 100644 components/image-generator/CropOverlay.tsx diff --git a/app/globals.css b/app/globals.css index 392ac68..efd8291 100644 --- a/app/globals.css +++ b/app/globals.css @@ -33,6 +33,38 @@ box-sizing: border-box; } +/* Themed scrollbars — neutral-800 track, neutral-600 thumb that lightens on hover. + Applied globally so every scrollable region matches the dark UI. */ +* { + scrollbar-width: thin; + scrollbar-color: rgb(82 82 91) transparent; /* thumb track */ +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: rgb(64 64 64); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: content-box; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: rgb(115 115 115); + background-clip: content-box; +} + +*::-webkit-scrollbar-corner { + background: transparent; +} + /* ---break---*/ @theme inline { diff --git a/components/image-generator/Controls.tsx b/components/image-generator/Controls.tsx index 7b7cf66..9a91649 100644 --- a/components/image-generator/Controls.tsx +++ b/components/image-generator/Controls.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useRef, useState, useEffect } from "react"; import type { ControlsProps, AspectRatio, Alignment, ImageSettings, BackgroundEffects } from "./types"; +import { ASPECT_RATIO_PRESETS } from "./types"; import { DEVICE_MOCKUPS } from "./mockups"; import { AlignTopLeft, @@ -22,7 +23,14 @@ import { Unlock, Monitor, ChevronUp, + ChevronDown, ArrowLeft, + Copy, + Check, + Image as ImageIcon, + Layers, + Sparkles, + Smartphone, } from "./icons"; import Link from "next/link"; @@ -44,21 +52,6 @@ export const PRESET_BACKGROUNDS = [ { name: "Grainy Gradient 2", url: "https://assets.delta4infotech.com/tools/bg-studio/bg-gradient-3.jpg" }, ]; -const AspectRatioButton: React.FC<{ - label: string; - value: AspectRatio; - currentValue: AspectRatio; - onClick: (value: AspectRatio) => void; -}> = ({ label, value, currentValue, onClick }) => ( - -); - const CustomGradientEditor: React.FC<{ gradient: ControlsProps["gradient"]; setGradient: ControlsProps["setGradient"]; @@ -157,6 +150,12 @@ export const Controls: React.FC = (props) => { generateNewGradient, onDownloadSingle, onDownloadZip, + onCopyToClipboard, + copyStatus, + exportFormat, + setExportFormat, + exportQuality, + setExportQuality, isDownloading, setBackgroundImage, onDevModeClick, @@ -164,7 +163,12 @@ export const Controls: React.FC = (props) => { } = props; const fileInputRef = useRef(null); const backgroundInputRef = useRef(null); - const [openAccordionItems, setOpenAccordionItems] = useState(["foreground", "background"]); + const [activeTab, setActiveTab] = useState<"image" | "background" | "effects" | "devices">("image"); + const [showAdvancedImage, setShowAdvancedImage] = useState(false); + const [showAdvancedEffects, setShowAdvancedEffects] = useState(false); + const [isCanvasMenuOpen, setIsCanvasMenuOpen] = useState(false); + const canvasMenuRef = useRef(null); + const [openAccordionItems, setOpenAccordionItems] = useState(["foreground", "background"]); const [activeBackgroundTab, setActiveBackgroundTab] = useState<"color" | "image">("color"); const [isDownloadMenuOpen, setIsDownloadMenuOpen] = useState(false); const downloadMenuRef = useRef(null); @@ -187,6 +191,9 @@ export const Controls: React.FC = (props) => { if (downloadMenuRef.current && !downloadMenuRef.current.contains(event.target as Node)) { setIsDownloadMenuOpen(false); } + if (canvasMenuRef.current && !canvasMenuRef.current.contains(event.target as Node)) { + setIsCanvasMenuOpen(false); + } }; document.addEventListener("mousedown", handleClickOutside); return () => { @@ -194,6 +201,30 @@ export const Controls: React.FC = (props) => { }; }, []); + const TABS: { id: typeof activeTab; label: string; Icon: React.FC> }[] = [ + { id: "image", label: "Image", Icon: ImageIcon }, + { id: "background", label: "Background", Icon: Layers }, + { id: "effects", label: "Effects", Icon: Sparkles }, + { id: "devices", label: "Devices", Icon: Smartphone }, + ]; + + // Sync the new activeTab to the accordion's open-items array so the existing accordion + // markup keeps working without restructuring all of it. Only the active tab's section + // is in the open list; the others stay closed and hidden by their own AccordionContent. + useEffect(() => { + const next = (() => { + switch (activeTab) { + case "image": return ["foreground"]; + case "background": return ["background"]; + case "effects": return ["background-effects"]; + case "devices": return ["devices"]; + } + })(); + setOpenAccordionItems(next); + }, [activeTab]); + + const currentPreset = ASPECT_RATIO_PRESETS.find((p) => p.id === aspectRatio); + const handleUploadClick = () => fileInputRef.current?.click(); const handleBackgroundUploadClick = () => backgroundInputRef.current?.click(); @@ -230,6 +261,10 @@ export const Controls: React.FC = (props) => { stream.getTracks().forEach((track) => track.stop()); }; } catch (err) { + const error = err instanceof DOMException ? err : null; + if (error?.name === "NotAllowedError" || error?.name === "AbortError") { + return; + } console.error("Error taking screenshot:", err); } }; @@ -237,27 +272,86 @@ export const Controls: React.FC = (props) => { const isPaddingDisabled = imageSettings.alignment !== "middle-center"; return ( -
-
- - - Back to Tools - -

BG Studio

-

Create your visual content.

+
+ {/* Compact sticky header with title + canvas-size pill */} +
+
+ + + Back + +

BG Studio

+
+
+ + {isCanvasMenuOpen && ( +
+ {ASPECT_RATIO_PRESETS.map((preset) => { + const isPortrait = preset.height > preset.width; + const isSquare = preset.width === preset.height; + const previewWidth = isSquare ? 16 : isPortrait ? 12 : 20; + const previewHeight = isSquare ? 16 : isPortrait ? 20 : 12; + return ( + + ); + })} +
+ )} +
-
- -
- - -
+ {/* Tab navigation */} +
+ {TABS.map(({ id, label, Icon }) => ( + + ))}
- Foreground + Foreground {uploadedImages.length > 0 ? ( @@ -344,46 +438,59 @@ export const Controls: React.FC = (props) => {
-
- -
-
- - -
- handleImageSettingChange("glassmorphicBorder", { ...imageSettings.glassmorphicBorder, size: v })} - min={1} - max={50} - unit="px" - disabled={!imageSettings.glassmorphicBorder.enabled} - /> - handleImageSettingChange("glassmorphicBorder", { ...imageSettings.glassmorphicBorder, opacity: v })} - min={0} - max={1} - step={0.01} - disabled={!imageSettings.glassmorphicBorder.enabled} - /> -
- - handleImageSettingChange("glassmorphicBorder", { ...imageSettings.glassmorphicBorder, color: c })} /> +
+ + {showAdvancedImage && ( +
+ +
+
+ + +
+ handleImageSettingChange("glassmorphicBorder", { ...imageSettings.glassmorphicBorder, size: v })} + min={1} + max={50} + unit="px" + disabled={!imageSettings.glassmorphicBorder.enabled} + /> + handleImageSettingChange("glassmorphicBorder", { ...imageSettings.glassmorphicBorder, opacity: v })} + min={0} + max={1} + step={0.01} + disabled={!imageSettings.glassmorphicBorder.enabled} + /> +
+ + handleImageSettingChange("glassmorphicBorder", { ...imageSettings.glassmorphicBorder, color: c })} /> +
+
-
+ )}
) : ( @@ -402,7 +509,7 @@ export const Controls: React.FC = (props) => { - Background + Background
+ {showAdvancedEffects && ( +
+ handleBackgroundEffectChange("motionBlur", v)} max={100} unit="px" /> + handleBackgroundEffectChange("watercolor", v)} max={100} /> +
+ + +
+ {backgroundEffects.pattern !== "none" && ( + handleBackgroundEffectChange("patternOpacity", v)} min={0} max={1} step={0.01} /> + )} +
+ )}
- {backgroundEffects.pattern !== "none" && ( - handleBackgroundEffectChange("patternOpacity", v)} min={0} max={1} step={0.01} /> - )}
- Devices + Devices
{/* Device grid */} @@ -585,8 +709,8 @@ export const Controls: React.FC = (props) => { -
-
+
+
- {/* Placeholder for potential other secondary actions */}
-
+
+ Format +
+ {(["png", "jpeg", "webp"] as const).map((fmt) => ( + + ))} +
+
+ + {(exportFormat === "jpeg" || exportFormat === "webp") && ( +
+ +
+ )} + +
+ diff --git a/components/image-generator/CropOverlay.tsx b/components/image-generator/CropOverlay.tsx new file mode 100644 index 0000000..78cb55e --- /dev/null +++ b/components/image-generator/CropOverlay.tsx @@ -0,0 +1,244 @@ +"use client"; +import React, { useState, useRef, useEffect } from "react"; +import type { UploadedImage, CropAspectRatio } from "./types"; + +const CROP_RATIOS: { id: CropAspectRatio; label: string; ratio: number | null }[] = [ + { id: "free", label: "Free", ratio: null }, + { id: "1:1", label: "1:1", ratio: 1 }, + { id: "16:9", label: "16:9", ratio: 16 / 9 }, + { id: "9:16", label: "9:16", ratio: 9 / 16 }, + { id: "4:3", label: "4:3", ratio: 4 / 3 }, + { id: "3:2", label: "3:2", ratio: 3 / 2 }, +]; + +// Inline crop overlay — CleanShot-style. Renders on top of the active image inside the canvas. +// 8 handles (4 corners + 4 edge midpoints), dim mask, rule-of-thirds grid, live W×H readout, +// floating ratio-preset toolbar, Enter applies / Escape cancels. +export const CropOverlay: React.FC<{ + image: UploadedImage; + onApply: (crop: { x: number; y: number; width: number; height: number } | undefined) => void; + onClose: () => void; +}> = ({ image, onApply, onClose }) => { + const [ratio, setRatio] = useState("free"); + const [naturalSize, setNaturalSize] = useState<{ w: number; h: number } | null>(null); + // crop is in % of the visible (already-cropped) image — what the user sees in the overlay. + // The previously-applied crop (image.crop) determines what the overlay is showing. + // On Apply, we compose these to get the final % of the natural source. + const priorCrop = image.crop ?? { x: 0, y: 0, width: 100, height: 100 }; + const [crop, setCrop] = useState({ x: 0, y: 0, width: 100, height: 100 }); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + const ratioValue = CROP_RATIOS.find((r) => r.id === ratio)?.ratio ?? null; + // The overlay's visible area has the aspect of the displayed (previously-cropped) image. + const displayedAspect = naturalSize + ? (naturalSize.w * priorCrop.width) / (naturalSize.h * priorCrop.height) + : 1; + const naturalAspect = displayedAspect; // ratio constraints work in displayed-aspect space + + useEffect(() => { + const img = new window.Image(); + img.onload = () => setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight }); + img.src = image.src; + }, [image.src]); + + useEffect(() => { + if (ratioValue === null) return; + setCrop((prev) => { + let w = prev.width; + let h = (w * naturalAspect) / ratioValue; + if (h > 100) { + h = 100; + w = (h * ratioValue) / naturalAspect; + } + const cx = prev.x + prev.width / 2; + const cy = prev.y + prev.height / 2; + const x = Math.min(Math.max(0, cx - w / 2), 100 - w); + const y = Math.min(Math.max(0, cy - h / 2), 100 - h); + return { x, y, width: w, height: h }; + }); + }, [ratioValue, naturalAspect]); + + // The on-screen W×H readout should reflect natural-source pixels of the final composed crop. + const cropPxW = naturalSize ? Math.round((naturalSize.w * (crop.width / 100) * priorCrop.width) / 100) : 0; + const cropPxH = naturalSize ? Math.round((naturalSize.h * (crop.height / 100) * priorCrop.height) / 100) : 0; + + type HandleKind = "move" | "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se"; + + const startDrag = (e: React.PointerEvent, mode: HandleKind) => { + e.preventDefault(); + e.stopPropagation(); + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + setIsDragging(true); + const startX = e.clientX; + const startY = e.clientY; + const start = { ...crop }; + + const onMove = (ev: PointerEvent) => { + const dxPct = ((ev.clientX - startX) / rect.width) * 100; + const dyPct = ((ev.clientY - startY) / rect.height) * 100; + setCrop(() => { + if (mode === "move") { + return { + x: Math.min(Math.max(0, start.x + dxPct), 100 - start.width), + y: Math.min(Math.max(0, start.y + dyPct), 100 - start.height), + width: start.width, + height: start.height, + }; + } + let newX = start.x; + let newY = start.y; + let newW = start.width; + let newH = start.height; + if (mode.includes("w")) { newX = start.x + dxPct; newW = start.width - dxPct; } + if (mode.includes("e")) { newW = start.width + dxPct; } + if (mode.includes("n")) { newY = start.y + dyPct; newH = start.height - dyPct; } + if (mode.includes("s")) { newH = start.height + dyPct; } + + if (ratioValue !== null) { + if (mode === "n" || mode === "s") { + const targetW = (newH * ratioValue) / naturalAspect; + const cx = start.x + start.width / 2; + newX = cx - targetW / 2; + newW = targetW; + } else if (mode === "e" || mode === "w") { + const targetH = (newW * naturalAspect) / ratioValue; + const cy = start.y + start.height / 2; + newY = cy - targetH / 2; + newH = targetH; + } else { + const targetH = (newW * naturalAspect) / ratioValue; + if (mode.includes("n")) newY = newY + (newH - targetH); + newH = targetH; + } + } + + const MIN = 2; + if (newW < MIN) { if (mode.includes("w")) newX = newX + newW - MIN; newW = MIN; } + if (newH < MIN) { if (mode.includes("n")) newY = newY + newH - MIN; newH = MIN; } + + if (newX < 0) { newW += newX; newX = 0; } + if (newY < 0) { newH += newY; newY = 0; } + if (newX + newW > 100) newW = 100 - newX; + if (newY + newH > 100) newH = 100 - newY; + + return { x: newX, y: newY, width: newW, height: newH }; + }); + }; + const onUp = () => { + setIsDragging(false); + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + }; + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + }; + + // Compose the new crop (in % of the displayed cropped image) with the prior crop + // (in % of natural source) to get the final crop in natural-source coordinates. + const composedCrop = () => ({ + x: priorCrop.x + (crop.x / 100) * priorCrop.width, + y: priorCrop.y + (crop.y / 100) * priorCrop.height, + width: (crop.width / 100) * priorCrop.width, + height: (crop.height / 100) * priorCrop.height, + }); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } else if (e.key === "Enter") { + e.preventDefault(); + onApply(composedCrop()); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [crop, onApply, onClose]); + + if (!naturalSize) return null; + + const handleSize = 10; + const handleStyleBase: React.CSSProperties = { + position: "absolute", + width: handleSize, + height: handleSize, + background: "#fff", + border: "1px solid #1f2937", + boxShadow: "0 1px 3px rgba(0,0,0,0.4)", + zIndex: 10, + }; + + return ( +
+
+
+
+
+ +
startDrag(e, "move")} + > + {isDragging && ( +
+
+
+
+
+
+ )} + +
startDrag(e, "n")} style={{ ...handleStyleBase, top: -handleSize / 2, left: `calc(50% - ${handleSize / 2}px)`, cursor: "ns-resize" }} /> +
startDrag(e, "s")} style={{ ...handleStyleBase, bottom: -handleSize / 2, left: `calc(50% - ${handleSize / 2}px)`, cursor: "ns-resize" }} /> +
startDrag(e, "w")} style={{ ...handleStyleBase, left: -handleSize / 2, top: `calc(50% - ${handleSize / 2}px)`, cursor: "ew-resize" }} /> +
startDrag(e, "e")} style={{ ...handleStyleBase, right: -handleSize / 2, top: `calc(50% - ${handleSize / 2}px)`, cursor: "ew-resize" }} /> + +
startDrag(e, "nw")} style={{ ...handleStyleBase, top: -handleSize / 2, left: -handleSize / 2, cursor: "nwse-resize" }} /> +
startDrag(e, "ne")} style={{ ...handleStyleBase, top: -handleSize / 2, right: -handleSize / 2, cursor: "nesw-resize" }} /> +
startDrag(e, "sw")} style={{ ...handleStyleBase, bottom: -handleSize / 2, left: -handleSize / 2, cursor: "nesw-resize" }} /> +
startDrag(e, "se")} style={{ ...handleStyleBase, bottom: -handleSize / 2, right: -handleSize / 2, cursor: "nwse-resize" }} /> + + {isDragging && ( +
+ {cropPxW} × {cropPxH} +
+ )} +
+ +
e.stopPropagation()} + > + {CROP_RATIOS.map((r) => ( + + ))} +
+ + + +
+
+ ); +}; diff --git a/components/image-generator/ImageGenerator.tsx b/components/image-generator/ImageGenerator.tsx index 5399fa4..55bcbdf 100644 --- a/components/image-generator/ImageGenerator.tsx +++ b/components/image-generator/ImageGenerator.tsx @@ -3,9 +3,9 @@ import React, { useState, useRef, useCallback, useEffect } from "react"; import { Controls, PRESET_BACKGROUNDS } from "./Controls"; import { ImagePreview } from "./ImagePreview"; import { generateRandomGradient, gradientToString } from "./utils/gradient"; -import type { AspectRatio, Gradient, ImageSettings, BackgroundEffects, TextEffects, TextObject, Selection, UploadedImage, DrawingMode, ArrowObject, CounterObject, RedactObject, ShapeObject, CanvasObject } from "./types"; +import type { AspectRatio, Gradient, ImageSettings, BackgroundEffects, TextEffects, TextObject, Selection, UploadedImage, DrawingMode, ArrowObject, ArrowDefaults, ArrowLineStyle, ArrowHeadStyle, CounterObject, CounterDefaults, CounterFormat, RedactObject, ShapeObject, CanvasObject, BrushObject, BrushDefaults } from "./types"; import { FONTS } from "./templates"; -import { Type, Undo, Trash2, ZoomIn, ZoomOut, Wand2, ArrowUpRight, Hash, EyeOff, Square, Circle, Triangle, Paintbrush, CounterIcon, Move } from "./icons"; +import { Type, Undo, Redo, Trash2, ZoomIn, ZoomOut, Wand2, ArrowUpRight, Hash, EyeOff, Square, Circle, Triangle, Paintbrush, CounterIcon, Move, Upload, Pencil, Crop } from "./icons"; import * as htmlToImage from "html-to-image"; import JSZip from "jszip"; import { Slider } from "./ui/Slider"; @@ -20,6 +20,7 @@ const DEFAULT_BACKGROUND_EFFECTS: BackgroundEffects = { watercolor: 0, pattern: "none", patternOpacity: 0.1, + canvasCornerRadius: 16, }; const DEFAULT_TEXT_EFFECTS: TextEffects = { @@ -36,7 +37,7 @@ const DEFAULT_IMAGE_SETTINGS: ImageSettings = { scale: 1, shadow: 20, corners: 6, - alignment: "bottom-center", + alignment: "middle-center", glassmorphicBorder: { enabled: true, opacity: 0.83, @@ -52,7 +53,7 @@ const createInitialText = (): TextObject => ({ xPosition: 50, fontFamily: FONTS[0].family, fontColor: "#ffffff", - fontSizeScale: 1, + fontSizeScale: 0.4, }); const createInitialCounter = (count: number = 1): CounterObject => ({ @@ -100,6 +101,8 @@ interface HistoryState { counters: Record; redactions: Record; shapes: Record; + brushes: Record; + uploadedImages: UploadedImage[]; } const SliderControl: React.FC<{ @@ -126,7 +129,7 @@ const SliderControl: React.FC<{ const StylePopover: React.FC<{ selectedObject: CanvasObject; - selectionType: "text" | "arrow" | "counter" | "redact" | "shape"; + selectionType: "text" | "arrow" | "counter" | "redact" | "shape" | "brush"; textEffects: TextEffects; onUpdateText: (props: Partial>) => void; onUpdateArrow: (props: Partial>) => void; @@ -399,21 +402,270 @@ const StylePopover: React.FC<{ ); }; +const ArrowOptionsPopover: React.FC<{ + arrowDefaults: ArrowDefaults; + setArrowDefaults: React.Dispatch>; +}> = ({ arrowDefaults, setArrowDefaults }) => { + const update = (key: K, value: ArrowDefaults[K]) => + setArrowDefaults((prev) => ({ ...prev, [key]: value })); + + const renderLinePreview = (style: ArrowLineStyle) => { + const dash = style === "dashed" ? "5 3" : style === "dotted" ? "1 3" : undefined; + return ( + + + + ); + }; + + const renderHeadPreview = (head: ArrowHeadStyle) => ( + + + {head === "filled" && } + {head === "hollow" && } + + ); + + return ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > +
+
Arrow Defaults
+ +
+ + update("color", c)} className="h-7 w-12" /> +
+ +
+
+ + {arrowDefaults.strokeWidth}px +
+ update("strokeWidth", vals[0])} min={1} max={20} step={1} className="py-1" /> +
+ +
+ +
+ {(["solid", "dashed", "dotted"] as ArrowLineStyle[]).map((style) => ( + + ))} +
+
+ +
+ +
+ {(["filled", "hollow", "none"] as ArrowHeadStyle[]).map((head) => ( + + ))} +
+
+
+
+ ); +}; + +const BrushOptionsPopover: React.FC<{ + brushDefaults: BrushDefaults; + setBrushDefaults: React.Dispatch>; +}> = ({ brushDefaults, setBrushDefaults }) => { + const update = (key: K, value: BrushDefaults[K]) => + setBrushDefaults((prev) => ({ ...prev, [key]: value })); + + return ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > +
+
Brush
+ +
+ +
+ {(["blur", "highlighter", "pencil"] as const).map((mode) => ( + + ))} +
+
+ + {brushDefaults.mode !== "blur" && ( +
+ + update("color", c)} className="h-7 w-12" /> +
+ )} + +
+
+ + {brushDefaults.size}px +
+ update("size", vals[0])} min={4} max={80} step={1} className="py-1" /> +
+
+
+ ); +}; + +const CounterOptionsPopover: React.FC<{ + counterDefaults: CounterDefaults; + setCounterDefaults: React.Dispatch>; +}> = ({ counterDefaults, setCounterDefaults }) => { + const update = (key: K, value: CounterDefaults[K]) => + setCounterDefaults((prev) => ({ ...prev, [key]: value })); + + const previewLabel = (() => { + const n = counterDefaults.startAt; + if (counterDefaults.format === "roman") { + const map: [number, string][] = [[10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"]]; + let num = n, out = ""; + for (const [v, s] of map) { + while (num >= v) { out += s; num -= v; } + } + return out || String(n); + } + if (counterDefaults.format === "alpha") { + // 1 → A, 26 → Z, 27 → AA + let num = n, out = ""; + while (num > 0) { + const rem = (num - 1) % 26; + out = String.fromCharCode(65 + rem) + out; + num = Math.floor((num - 1) / 26); + } + return out || "A"; + } + return String(n); + })(); + + return ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > +
+
+
Counter
+
+ {previewLabel} +
+
+ +
+ + update("color", c)} className="h-7 w-12" /> +
+ +
+
+ + {counterDefaults.scale.toFixed(2)}× +
+ update("scale", vals[0])} min={0.5} max={3} step={0.1} className="py-1" /> +
+ +
+ +
+ {(["number", "roman", "alpha"] as CounterFormat[]).map((fmt) => ( + + ))} +
+
+ +
+ + update("startAt", Math.max(1, parseInt(e.target.value) || 1))} + className="w-20 bg-neutral-700 text-white text-xs rounded-md p-1.5 border border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500 text-right tabular-nums" + min={1} + /> +
+
+
+ ); +}; + const AnnotationToolbar: React.FC<{ onAddText: () => void; onAddCounter: () => void; onAddShape: () => void; onAddRedact: () => void; onUndo: () => void; + onRedo: () => void; onDeleteSelected: () => void; isObjectSelected: boolean; + isStyleableSelected: boolean; canUndo: boolean; + canRedo: boolean; children: React.ReactNode; isStylePopoverOpen: boolean; onToggleStylePopover: () => void; drawingMode: DrawingMode; setDrawingMode: React.Dispatch>; -}> = ({ onAddText, onAddCounter, onAddShape, onAddRedact, onUndo, onDeleteSelected, isObjectSelected, canUndo, children, isStylePopoverOpen, onToggleStylePopover, drawingMode, setDrawingMode }) => { + arrowDefaults: ArrowDefaults; + setArrowDefaults: React.Dispatch>; + brushDefaults: BrushDefaults; + setBrushDefaults: React.Dispatch>; + counterDefaults: CounterDefaults; + setCounterDefaults: React.Dispatch>; + onCrop: () => void; + canCrop: boolean; +}> = ({ onAddText, onAddCounter, onAddShape, onAddRedact, onUndo, onRedo, onDeleteSelected, isObjectSelected, isStyleableSelected, canUndo, canRedo, children, isStylePopoverOpen, onToggleStylePopover, drawingMode, setDrawingMode, arrowDefaults, setArrowDefaults, brushDefaults, setBrushDefaults, counterDefaults, setCounterDefaults, onCrop, canCrop }) => { const ToolbarButton: React.FC<{ onClick?: () => void; disabled?: boolean; @@ -438,24 +690,42 @@ const AnnotationToolbar: React.FC<{ - setDrawingMode(drawingMode === "counter" ? null : "counter")} isActive={drawingMode === "counter"} title="Add Counter"> - - +
+ + setDrawingMode(drawingMode === "counter" ? null : "counter")} isActive={drawingMode === "counter"} title="Add Counter"> + + +
setDrawingMode(drawingMode === "move" ? null : "move")} isActive={drawingMode === "move"} title="Move Image (Hand Mode)"> - setDrawingMode(drawingMode === "arrow" ? null : "arrow")} isActive={drawingMode === "arrow"} title="Draw Arrow"> - +
+ + setDrawingMode(drawingMode === "arrow" ? null : "arrow")} isActive={drawingMode === "arrow"} title="Draw Arrow"> + + +
+
+ + setDrawingMode(drawingMode === "brush" ? null : "brush")} isActive={drawingMode === "brush"} title="Brush (blur / highlight / pencil)"> + + +
+ + - +
- + + + + @@ -463,6 +733,7 @@ const AnnotationToolbar: React.FC<{ ); }; + const ZoomControl: React.FC<{ zoom: number; setZoom: React.Dispatch> }> = ({ zoom, setZoom }) => { const zoomIn = () => setZoom((z) => Math.min(3, z + 0.1)); const zoomOut = () => setZoom((z) => Math.max(0.2, z - 0.1)); @@ -489,6 +760,12 @@ export default function ImageGenerator() { const [allCounters, setAllCounters] = useState>({}); const [allRedactions, setAllRedactions] = useState>({}); const [allShapes, setAllShapes] = useState>({}); + const [allBrushes, setAllBrushes] = useState>({}); + const [brushDefaults, setBrushDefaults] = useState({ + mode: "blur", + color: "#fde047", + size: 20, + }); const [selection, setSelection] = useState(initialSelectionState); const [editing, setEditing] = useState(null); @@ -513,6 +790,10 @@ export default function ImageGenerator() { if (key === "alignment") { setUploadedImages((prevImages) => prevImages.map(img => ({ ...img, x: undefined, y: undefined }))); setImageSettings((prev) => ({ ...prev, [key]: value, x: undefined, y: undefined })); + } else if (key === "scale" && activeImageIndex !== null) { + setUploadedImages((prevImages) => prevImages.map((img, index) => ( + index === activeImageIndex ? { ...img, scale: value as number } : img + ))); } else { setImageSettings((prev) => ({ ...prev, [key]: value })); } @@ -530,7 +811,30 @@ export default function ImageGenerator() { const [isStylePopoverOpen, setIsStylePopoverOpen] = useState(false); const [drawingMode, setDrawingMode] = useState(null); const [applyToAll, setApplyToAll] = useState(false); - + const [cropImageId, setCropImageId] = useState(null); + const [arrowDefaults, setArrowDefaults] = useState({ + color: "#ef4444", + strokeWidth: 4, + lineStyle: "solid", + headStyle: "filled", + }); + const [counterDefaults, setCounterDefaults] = useState({ + color: "#ef4444", + scale: 1, + format: "number", + startAt: 1, + }); + const [isDraggingFile, setIsDraggingFile] = useState(false); + const dragCounterRef = useRef(0); + const [exportFormat, setExportFormat] = useState<"png" | "jpeg" | "webp">("png"); + const [exportQuality, setExportQuality] = useState(0.95); + const [copyStatus, setCopyStatus] = useState<"idle" | "copying" | "copied" | "error">("idle"); + + const mainRef = useRef(null); + const scrollContainerRef = useRef(null); + const zoomContentRef = useRef(null); + const [isPanning, setIsPanning] = useState(false); + const [zoomContentSize, setZoomContentSize] = useState<{ w: number; h: number } | null>(null); const singlePreviewRef = useRef(null); const previewRefs = useRef<(HTMLDivElement | null)[]>([]); const previewContainerRef = useRef(null); @@ -570,31 +874,73 @@ export default function ImageGenerator() { return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [history.length, uploadedImages, backgroundImage]); + const [redoStack, setRedoStack] = useState([]); + + const snapshot = useCallback((): HistoryState => ({ + texts: allTexts, + arrows: allArrows, + counters: allCounters, + redactions: allRedactions, + shapes: allShapes, + brushes: allBrushes, + uploadedImages, + }), [allTexts, allArrows, allCounters, allRedactions, allShapes, allBrushes, uploadedImages]); + + const applySnapshot = (s: HistoryState) => { + setAllTexts(s.texts); + setAllArrows(s.arrows); + setAllCounters(s.counters || {}); + setAllRedactions(s.redactions || {}); + setAllShapes(s.shapes || {}); + setAllBrushes(s.brushes || {}); + setUploadedImages(s.uploadedImages || []); + setSelection(null); + setEditing(null); + }; + const pushToHistory = useCallback(() => { - setHistory((prev) => [...prev, { texts: allTexts, arrows: allArrows, counters: allCounters, redactions: allRedactions, shapes: allShapes }]); - }, [allTexts, allArrows, allCounters, allRedactions, allShapes]); + setHistory((prev) => [...prev, snapshot()]); + setRedoStack([]); // any new action clears redo + }, [snapshot]); + + const getChunkSize = useCallback(() => { + if (!imageSettings.mockup) return 1; + if (imageSettings.mockupLayout === 'grid-3') return 3; + if (imageSettings.mockupLayout === 'grid-2') return 2; + return 1; + }, [imageSettings.mockup, imageSettings.mockupLayout]); + + const getCanvasKeyForImageIndex = useCallback((imageIndex: number | null) => { + if (imageIndex === null) return -1; + return Math.floor(imageIndex / getChunkSize()); + }, [getChunkSize]); + + const getActiveCanvasKey = useCallback(() => getCanvasKeyForImageIndex(activeImageIndex), [activeImageIndex, getCanvasKeyForImageIndex]); const handleUndo = () => { if (history.length === 0) return; const previousState = history[history.length - 1]; - setAllTexts(previousState.texts); - setAllArrows(previousState.arrows); - setAllCounters(previousState.counters || {}); - setAllRedactions(previousState.redactions || {}); - setAllShapes(previousState.shapes || {}); + setRedoStack((r) => [...r, snapshot()]); setHistory(history.slice(0, -1)); - setSelection(null); - setEditing(null); + applySnapshot(previousState); + }; + + const handleRedo = () => { + if (redoStack.length === 0) return; + const nextState = redoStack[redoStack.length - 1]; + setHistory((h) => [...h, snapshot()]); + setRedoStack(redoStack.slice(0, -1)); + applySnapshot(nextState); }; const handleAddText = useCallback(() => { - const activeCanvasKey = activeImageIndex !== null ? activeImageIndex : -1; + const activeCanvasKey = getActiveCanvasKey(); pushToHistory(); const newText = createInitialText(); setAllTexts((prev) => ({ ...prev, [activeCanvasKey]: [...(prev[activeCanvasKey] || []), newText] })); setSelection({ canvasKey: activeCanvasKey, itemId: newText.id, type: "text" }); setEditing({ canvasKey: activeCanvasKey, itemId: newText.id, type: "text" }); - }, [activeImageIndex, pushToHistory]); + }, [getActiveCanvasKey, pushToHistory]); const handleArrowAdd = useCallback( (canvasKey: number, arrow: Omit) => { @@ -789,12 +1135,19 @@ export default function ImageGenerator() { // Counter Handlers const handleCounterAdd = useCallback( - (coords?: { x: number; y: number }) => { - const activeCanvasKey = activeImageIndex !== null ? activeImageIndex : -1; + (canvasKeyParam?: number, coords?: { x: number; y: number }) => { + const activeCanvasKey = canvasKeyParam ?? getActiveCanvasKey(); pushToHistory(); const existing = allCounters[activeCanvasKey] || []; - const nextCount = existing.length > 0 ? Math.max(...existing.map((c) => c.count)) + 1 : 1; - const newCounter = createInitialCounter(nextCount); + // Next count: continue from highest existing, but at minimum start from `counterDefaults.startAt`. + const highest = existing.length > 0 ? Math.max(...existing.map((c) => c.count)) : counterDefaults.startAt - 1; + const nextCount = Math.max(highest + 1, counterDefaults.startAt); + const newCounter: CounterObject = { + ...createInitialCounter(nextCount), + color: counterDefaults.color, + scale: counterDefaults.scale, + format: counterDefaults.format, + }; if (coords) { newCounter.x = coords.x; @@ -804,7 +1157,7 @@ export default function ImageGenerator() { setAllCounters((prev) => ({ ...prev, [activeCanvasKey]: [...(prev[activeCanvasKey] || []), newCounter] })); setSelection({ canvasKey: activeCanvasKey, itemId: newCounter.id, type: "counter" }); }, - [activeImageIndex, allCounters, pushToHistory] + [getActiveCanvasKey, allCounters, pushToHistory, counterDefaults] ); const handleCounterUpdate = useCallback((canvasKey: number, id: string, props: Partial>) => { @@ -1005,6 +1358,29 @@ export default function ImageGenerator() { [selection, pushToHistory] ); + const handleBrushAdd = useCallback( + (canvasKeyParam: number | undefined, brush: Omit) => { + pushToHistory(); + const activeCanvasKey = canvasKeyParam ?? (activeImageIndex !== null ? activeImageIndex : -1); + const newBrush: BrushObject = { ...brush, id: `brush-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, type: "brush" }; + setAllBrushes((prev) => ({ ...prev, [activeCanvasKey]: [...(prev[activeCanvasKey] || []), newBrush] })); + setSelection({ canvasKey: activeCanvasKey, itemId: newBrush.id, type: "brush" }); + }, + [pushToHistory, activeImageIndex] + ); + + const handleBrushDelete = useCallback( + (canvasKey: number, id: string) => { + pushToHistory(); + setAllBrushes((prev) => ({ + ...prev, + [canvasKey]: (prev[canvasKey] || []).filter((b) => b.id !== id), + })); + if (selection?.itemId === id) setSelection(null); + }, + [selection, pushToHistory] + ); + const handleDeleteSelected = useCallback(() => { if (selection) { if (selection.type === "text") { @@ -1017,9 +1393,11 @@ export default function ImageGenerator() { handleRedactDelete(selection.canvasKey, selection.itemId); } else if (selection.type === "shape") { handleShapeDelete(selection.canvasKey, selection.itemId); + } else if (selection.type === "brush") { + handleBrushDelete(selection.canvasKey, selection.itemId); } } - }, [selection, handleTextDelete, handleArrowDelete, handleCounterDelete, handleRedactDelete, handleShapeDelete]); + }, [selection, handleTextDelete, handleArrowDelete, handleCounterDelete, handleRedactDelete, handleShapeDelete, handleBrushDelete]); const [clipboard, setClipboard] = useState<{ type: string; data: any } | null>(null); @@ -1032,21 +1410,16 @@ export default function ImageGenerator() { else if (type === "counter") data = allCounters[canvasKey]?.find(c => c.id === itemId); else if (type === "redact") data = allRedactions[canvasKey]?.find(r => r.id === itemId); else if (type === "shape") data = allShapes[canvasKey]?.find(s => s.id === itemId); + else if (type === "brush") data = allBrushes[canvasKey]?.find(b => b.id === itemId); if (data) { setClipboard({ type, data }); } - }, [selection, allTexts, allArrows, allCounters, allRedactions, allShapes]); + }, [selection, allTexts, allArrows, allCounters, allRedactions, allShapes, allBrushes]); const handlePaste = useCallback(() => { if (!clipboard) return; - const getChunkSize = () => { - if (!imageSettings.mockup) return 1; - if (imageSettings.mockupLayout === 'grid-3') return 3; - if (imageSettings.mockupLayout === 'grid-2') return 2; - return 1; - }; - const activeCanvasKey = activeImageIndex !== null && uploadedImages.length > 0 ? Math.floor(activeImageIndex / getChunkSize()) : -1; + const activeCanvasKey = uploadedImages.length > 0 ? getActiveCanvasKey() : -1; pushToHistory(); @@ -1059,9 +1432,12 @@ export default function ImageGenerator() { if (newData.y !== undefined) newData.y = Math.min(100, newData.y + offset); if (newData.xPosition !== undefined) newData.xPosition = Math.min(100, newData.xPosition + offset); if (newData.yPosition !== undefined) newData.yPosition = Math.min(100, newData.yPosition + offset); - if (newData.start) { - newData.start = { x: Math.min(100, newData.start.x + offset), y: Math.min(100, newData.start.y + offset) }; - newData.end = { x: Math.min(100, newData.end.x + offset), y: Math.min(100, newData.end.y + offset) }; + if (newData.x1 !== undefined) newData.x1 = Math.min(100, newData.x1 + offset); + if (newData.y1 !== undefined) newData.y1 = Math.min(100, newData.y1 + offset); + if (newData.x2 !== undefined) newData.x2 = Math.min(100, newData.x2 + offset); + if (newData.y2 !== undefined) newData.y2 = Math.min(100, newData.y2 + offset); + if (Array.isArray(newData.points)) { + newData.points = newData.points.map((p: any) => ({ x: Math.min(100, p.x + offset), y: Math.min(100, p.y + offset) })); } if (clipboard.type === "text") { @@ -1074,10 +1450,59 @@ export default function ImageGenerator() { setAllRedactions(prev => ({ ...prev, [activeCanvasKey]: [...(prev[activeCanvasKey] || []), newData] })); } else if (clipboard.type === "shape") { setAllShapes(prev => ({ ...prev, [activeCanvasKey]: [...(prev[activeCanvasKey] || []), newData] })); + } else if (clipboard.type === "brush") { + setAllBrushes(prev => ({ ...prev, [activeCanvasKey]: [...(prev[activeCanvasKey] || []), newData] })); } setSelection({ canvasKey: activeCanvasKey, itemId: newId, type: clipboard.type as any }); - }, [clipboard, activeImageIndex, imageSettings.mockup, imageSettings.mockupLayout, uploadedImages.length, pushToHistory]); + }, [clipboard, getActiveCanvasKey, uploadedImages.length, pushToHistory]); + + const addImageFiles = useCallback( + (files: File[]) => { + const imageFiles = files.filter((f) => f.type.startsWith("image/")); + if (imageFiles.length === 0) return; + if (uploadedImages.length + imageFiles.length > 10) { + alert("You can upload a maximum of 10 images."); + return; + } + const newImagesPromises = imageFiles.map((file: File) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target?.result && typeof e.target.result === "string") { + const baseName = file.name ? file.name.replace(/\.[^/.]+$/, "") : `pasted-${Date.now()}`; + resolve({ id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, src: e.target.result, name: baseName }); + } else { + reject(new Error("Failed to read file")); + } + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }); + Promise.all(newImagesPromises) + .then((newImages) => { + const prevUploadedLength = uploadedImages.length; + setUploadedImages((prev) => [...prev, ...newImages]); + setActiveImageIndex(prevUploadedLength); + setAllTexts((prev) => { + const newTextEntries: Record = {}; + const textToCarryOver = prev[-1] || []; + newImages.forEach((_, i) => { + const newIndex = prevUploadedLength + i; + if (prevUploadedLength === 0 && i === 0) { + newTextEntries[newIndex] = textToCarryOver; + } else { + newTextEntries[newIndex] = []; + } + }); + return { ...prev, ...newTextEntries }; + }); + }) + .catch((err) => console.error("Error reading files:", err)); + }, + [uploadedImages.length] + ); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1096,15 +1521,81 @@ export default function ImageGenerator() { if (modifier && e.key.toLowerCase() === 'c' && selection) { e.preventDefault(); handleCopySelected(); - } else if (modifier && e.key.toLowerCase() === 'v') { + } else if (modifier && e.key.toLowerCase() === 'v' && selection) { + // Only handle duplicate-paste here when an object is selected. + // Image-paste from system clipboard is handled by the document-level paste listener below. e.preventDefault(); handlePaste(); + } else if (modifier && e.key.toLowerCase() === 'z' && !e.shiftKey) { + e.preventDefault(); + handleUndo(); + } else if ((modifier && e.key.toLowerCase() === 'z' && e.shiftKey) || (modifier && e.key.toLowerCase() === 'y')) { + e.preventDefault(); + handleRedo(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [selection, editing, handleDeleteSelected, handleCopySelected, handlePaste]); + useEffect(() => { + const handleClipboardPaste = (e: ClipboardEvent) => { + const target = e.target as HTMLElement | null; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; + if (selection) return; + const items = e.clipboardData?.items; + if (!items) return; + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === "file" && item.type.startsWith("image/")) { + const file = item.getAsFile(); + if (file) files.push(file); + } + } + if (files.length > 0) { + e.preventDefault(); + addImageFiles(files); + } + }; + window.addEventListener("paste", handleClipboardPaste); + return () => window.removeEventListener("paste", handleClipboardPaste); + }, [selection, addImageFiles]); + + // Ctrl/Cmd + wheel zooms the whole canvas viewport. Attached as non-passive so we can + // preventDefault and stop the browser from zooming the whole page. + useEffect(() => { + const el = mainRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + if (!(e.ctrlKey || e.metaKey)) return; + e.preventDefault(); + const factor = e.deltaY < 0 ? 1.05 : 1 / 1.05; + setCanvasZoom((z) => Math.min(2, Math.max(0.3, z * factor))); + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, []); + + // Measure the unscaled natural size of the canvas content (the first child of the zoom div, + // which is the grid/single-image wrapper). We read its offsetWidth/Height which give the + // layout box, unaffected by the parent's transform. + useEffect(() => { + const zoomDiv = zoomContentRef.current; + if (!zoomDiv) return; + const child = zoomDiv.firstElementChild as HTMLElement | null; + if (!child) return; + const update = () => { + const w = child.offsetWidth; + const h = child.offsetHeight; + if (w > 0 && h > 0) setZoomContentSize({ w, h }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(child); + return () => ro.disconnect(); + }, [uploadedImages.length, aspectRatio, imageSettings.mockup, imageSettings.mockupLayout]); + const generateNewGradient = useCallback(() => { setGradient(generateRandomGradient()); }, []); @@ -1113,49 +1604,8 @@ export default function ImageGenerator() { const files = event.target.files; const input = event.target; if (!files || files.length === 0) return; - const fileCount = files.length; - if (uploadedImages.length + fileCount > 10) { - alert("You can upload a maximum of 10 images."); - input.value = ""; - return; - } - const newImagesPromises = Array.from(files).map((file: File) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => { - if (e.target?.result && typeof e.target.result === "string") { - resolve({ id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, src: e.target.result, name: file.name.replace(/\.[^/.]+$/, "") }); - } else { - reject(new Error("Failed to read file")); - } - }; - reader.onerror = reject; - reader.readAsDataURL(file); - }); - }); - Promise.all(newImagesPromises) - .then((newImages) => { - const prevUploadedLength = uploadedImages.length; - setUploadedImages((prev) => [...prev, ...newImages]); - setActiveImageIndex(prevUploadedLength); - setAllTexts((prev) => { - const newTextEntries: Record = {}; - const textToCarryOver = prev[-1] || []; - newImages.forEach((_, i) => { - const newIndex = prevUploadedLength + i; - if (prevUploadedLength === 0 && i === 0) { - newTextEntries[newIndex] = textToCarryOver; - } else { - newTextEntries[newIndex] = []; - } - }); - return { ...prev, ...newTextEntries }; - }); - }) - .catch((err) => console.error("Error reading files:", err)) - .finally(() => { - if (input) input.value = ""; - }); + addImageFiles(Array.from(files)); + if (input) input.value = ""; }; const handleBackgroundUpload = (event: React.ChangeEvent) => { @@ -1173,17 +1623,23 @@ export default function ImageGenerator() { const removeUploadedImage = (indexToRemove: number) => { pushToHistory(); setUploadedImages((prevImages) => prevImages.filter((_, index) => index !== indexToRemove)); - setAllTexts((prevAllTexts) => { - const newAllTexts: Record = { "-1": prevAllTexts[-1] || [] }; - Object.keys(prevAllTexts).forEach((keyStr) => { + const remapRecord = (record: Record) => { + const remapped: Record = { [-1]: record[-1] || [] }; + Object.keys(record).forEach((keyStr) => { const key = parseInt(keyStr, 10); if (key !== -1) { - if (key < indexToRemove) newAllTexts[key] = prevAllTexts[key]; - else if (key > indexToRemove) newAllTexts[key - 1] = prevAllTexts[key]; + if (key < indexToRemove) remapped[key] = record[key]; + else if (key > indexToRemove) remapped[key - 1] = record[key]; } }); - return newAllTexts; - }); + return remapped; + }; + setAllTexts(remapRecord); + setAllArrows(remapRecord); + setAllCounters(remapRecord); + setAllRedactions(remapRecord); + setAllShapes(remapRecord); + setAllBrushes(remapRecord); setActiveImageIndex((prev) => { if (prev === null) return null; if (prev === indexToRemove) return uploadedImages.length - 1 > 0 ? Math.min(prev, uploadedImages.length - 2) : null; @@ -1199,15 +1655,46 @@ export default function ImageGenerator() { setUploadedImages([]); setActiveImageIndex(null); setAllTexts({ [-1]: allTexts[-1] || [] }); + setAllArrows({}); + setAllCounters({}); + setAllRedactions({}); + setAllShapes({}); + setAllBrushes({}); setSelection(null); setEditing(null); }; const generateRandomFilename = () => `d4_${Math.random().toString(36).substr(2, 6)}`; + const captureNode = useCallback( + async (node: HTMLDivElement, format: "png" | "jpeg" | "webp" = "png", quality: number = 0.95): Promise => { + // Use offsetWidth/Height (unscaled layout box) so we capture at the true canvas size, + // independent of the viewport-level canvasZoom transform applied higher in the tree. + const width = node.offsetWidth; + const height = node.offsetHeight; + const options = { + cacheBust: true, + pixelRatio: 4, + quality, + width, + height, + // Override transform on the capture root so the cloned subtree starts at identity. + style: { transform: "none", transformOrigin: "top left" }, + }; + if (format === "jpeg") return htmlToImage.toJpeg(node, options); + if (format === "webp") { + const canvas = await htmlToImage.toCanvas(node, options); + return canvas.toDataURL("image/webp", quality); + } + return htmlToImage.toPng(node, options); + }, + [] + ); + const handleDownloadSingle = useCallback(async () => { const activeIdx = activeImageIndex; - const nodeToCapture: HTMLDivElement | null = activeIdx !== null ? (previewRefs.current[activeIdx] ?? null) : singlePreviewRef.current; + const previewIndex = activeIdx !== null ? getCanvasKeyForImageIndex(activeIdx) : -1; + const nodeToCapture: HTMLDivElement | null = activeIdx !== null ? (previewRefs.current[previewIndex] ?? null) : singlePreviewRef.current; if (!nodeToCapture) { alert(uploadedImages.length > 0 ? "Please select an image to download." : "Could not generate image."); return; @@ -1215,23 +1702,16 @@ export default function ImageGenerator() { setIsDownloading(true); setSelection(null); setIsStylePopoverOpen(false); - await new Promise((resolve) => setTimeout(resolve, 100)); + setCropImageId(null); + const prevZoom = canvasZoom; + setCanvasZoom(1); + // Wait a frame for the overlay/handles to actually unmount before capturing. + await new Promise((resolve) => setTimeout(resolve, 250)); try { - const width = nodeToCapture.offsetWidth; - const height = nodeToCapture.offsetHeight; - const dataUrl: string = await htmlToImage.toPng(nodeToCapture, { - cacheBust: true, - pixelRatio: 4, - quality: 1.0, - width, - height, - style: { - transform: "none", - } - }); + const dataUrl = await captureNode(nodeToCapture, exportFormat, exportQuality); const link = document.createElement("a"); const filename = activeIdx !== null && uploadedImages[activeIdx] ? uploadedImages[activeIdx].name : generateRandomFilename(); - link.download = `${filename}.png`; + link.download = `${filename}.${exportFormat === "jpeg" ? "jpg" : exportFormat}`; link.href = dataUrl; link.click(); } catch (err) { @@ -1239,33 +1719,30 @@ export default function ImageGenerator() { alert("Could not generate image. Please try again."); } finally { setIsDownloading(false); + setCanvasZoom(prevZoom); } - }, [activeImageIndex, uploadedImages]); + }, [activeImageIndex, uploadedImages, exportFormat, exportQuality, captureNode, getCanvasKeyForImageIndex, canvasZoom]); const handleDownloadZip = useCallback(async () => { if (uploadedImages.length < 2) return; setIsDownloading(true); setSelection(null); setIsStylePopoverOpen(false); - await new Promise((resolve) => setTimeout(resolve, 100)); + setCropImageId(null); + const prevZoom = canvasZoom; + setCanvasZoom(1); + await new Promise((resolve) => setTimeout(resolve, 250)); try { const zip = new JSZip(); - for (let i = 0; i < uploadedImages.length; i++) { + const ext = exportFormat === "jpeg" ? "jpg" : exportFormat; + const chunkSize = getChunkSize(); + const previewCount = Math.ceil(uploadedImages.length / chunkSize); + for (let i = 0; i < previewCount; i++) { const nodeToCapture = previewRefs.current[i]; - if (nodeToCapture) { - const width = nodeToCapture.offsetWidth; - const height = nodeToCapture.offsetHeight; - const dataUrl: string = await htmlToImage.toPng(nodeToCapture, { - cacheBust: true, - pixelRatio: 4, - quality: 1.0, - width, - height, - style: { - transform: "none", - } - }); - zip.file(`${uploadedImages[i].name}.png`, dataUrl.substring(dataUrl.indexOf(",") + 1), { base64: true }); + const firstImage = uploadedImages[i * chunkSize]; + if (nodeToCapture && firstImage) { + const dataUrl = await captureNode(nodeToCapture, exportFormat, exportQuality); + zip.file(`${firstImage.name}.${ext}`, dataUrl.substring(dataUrl.indexOf(",") + 1), { base64: true }); } } const content = (await zip.generateAsync({ type: "blob" })) as Blob; @@ -1279,8 +1756,50 @@ export default function ImageGenerator() { alert("Could not generate images zip. Please try again."); } finally { setIsDownloading(false); + setCanvasZoom(prevZoom); } - }, [uploadedImages]); + }, [uploadedImages, exportFormat, exportQuality, captureNode, getChunkSize, canvasZoom]); + + const handleCopyToClipboard = useCallback(async () => { + const activeIdx = activeImageIndex; + const previewIndex = activeIdx !== null ? getCanvasKeyForImageIndex(activeIdx) : -1; + const nodeToCapture: HTMLDivElement | null = activeIdx !== null ? (previewRefs.current[previewIndex] ?? null) : singlePreviewRef.current; + if (!nodeToCapture) return; + if (typeof window === "undefined" || !navigator.clipboard || typeof ClipboardItem === "undefined") { + setCopyStatus("error"); + setTimeout(() => setCopyStatus("idle"), 2000); + return; + } + setCopyStatus("copying"); + setSelection(null); + setIsStylePopoverOpen(false); + setCropImageId(null); + const prevZoom = canvasZoom; + setCanvasZoom(1); + await new Promise((resolve) => setTimeout(resolve, 250)); + try { + const width = nodeToCapture.offsetWidth; + const height = nodeToCapture.offsetHeight; + const blob = await htmlToImage.toBlob(nodeToCapture, { + cacheBust: true, + pixelRatio: 4, + quality: 1.0, + width, + height, + style: { transform: "none" }, + }); + if (!blob) throw new Error("Failed to generate blob"); + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + setCopyStatus("copied"); + setTimeout(() => setCopyStatus("idle"), 2000); + } catch (err) { + console.error("Failed to copy to clipboard:", err); + setCopyStatus("error"); + setTimeout(() => setCopyStatus("idle"), 2000); + } finally { + setCanvasZoom(prevZoom); + } + }, [activeImageIndex, getCanvasKeyForImageIndex, canvasZoom]); const backgroundValue = gradientToString(gradient); @@ -1295,7 +1814,9 @@ export default function ImageGenerator() { ? allShapes[selection.canvasKey]?.find((s) => s.id === selection.itemId) || null : selection.type === "redact" ? allRedactions[selection.canvasKey]?.find((r) => r.id === selection.itemId) || null - : null + : selection.type === "brush" + ? allBrushes[selection.canvasKey]?.find((b) => b.id === selection.itemId) || null + : null : null; const hasTextOnCanvas = Object.values(allTexts).some((texts) => Array.isArray(texts) && texts.length > 0); @@ -1330,7 +1851,7 @@ export default function ImageGenerator() { setBackgroundEffects={setBackgroundEffects} textEffects={textEffects} setTextEffects={setTextEffects} - imageSettings={imageSettings} + imageSettings={{ ...imageSettings, scale: activeImageIndex !== null ? (uploadedImages[activeImageIndex]?.scale ?? imageSettings.scale) : imageSettings.scale }} setImageSettings={setImageSettings} uploadedImages={uploadedImages} activeImageIndex={activeImageIndex} @@ -1341,6 +1862,12 @@ export default function ImageGenerator() { generateNewGradient={generateNewGradient} onDownloadSingle={handleDownloadSingle} onDownloadZip={handleDownloadZip} + onCopyToClipboard={handleCopyToClipboard} + copyStatus={copyStatus} + exportFormat={exportFormat} + setExportFormat={setExportFormat} + exportQuality={exportQuality} + setExportQuality={setExportQuality} isDownloading={isDownloading} isDevMode={isDevMode} setIsDevMode={setIsDevMode} @@ -1386,22 +1913,115 @@ export default function ImageGenerator() { }} /> -
+
{ + if (drawingMode) return; + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + dragCounterRef.current += 1; + setIsDraggingFile(true); + }} + onDragLeave={(e) => { + if (drawingMode) return; + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + dragCounterRef.current -= 1; + if (dragCounterRef.current <= 0) { + dragCounterRef.current = 0; + setIsDraggingFile(false); + } + }} + onDragOver={(e) => { + if (drawingMode) return; + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }} + onDrop={(e) => { + if (drawingMode) return; + if (!Array.from(e.dataTransfer.types).includes("Files")) return; + e.preventDefault(); + dragCounterRef.current = 0; + setIsDraggingFile(false); + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) addImageFiles(files); + }} + > + {isDraggingFile && ( +
+
+ +
Drop images to add
+
PNG, JPEG, WebP
+
+
+ )}
1 ? 'cursor-grab' : ''}`} onClick={() => { setSelection(null); setIsStylePopoverOpen(false); }} + onPointerDown={(e) => { + // Hold-and-drag with primary button on empty space to pan. + // Only activate when the press lands on the scroll container itself or its + // immediate spacer (not on an image / annotation, which has stopPropagation). + const target = e.target as HTMLElement; + if (e.button !== 0) return; + if (!scrollContainerRef.current) return; + // Don't intercept if user is in a drawing mode or pressing on an interactive element. + if (drawingMode) return; + // Only pan when zoomed in beyond fit. + if (canvasZoom <= 1) return; + // Don't start panning if click is on an image / annotation that handles its own drag. + if (target.closest('[data-canvas-content]') && target !== scrollContainerRef.current) return; + const startX = e.clientX; + const startY = e.clientY; + const startScrollLeft = scrollContainerRef.current.scrollLeft; + const startScrollTop = scrollContainerRef.current.scrollTop; + let didMove = false; + setIsPanning(true); + const onMove = (ev: PointerEvent) => { + const dx = ev.clientX - startX; + const dy = ev.clientY - startY; + if (!didMove && Math.hypot(dx, dy) > 3) didMove = true; + if (didMove && scrollContainerRef.current) { + scrollContainerRef.current.scrollLeft = startScrollLeft - dx; + scrollContainerRef.current.scrollTop = startScrollTop - dy; + } + }; + const onUp = () => { + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + setIsPanning(false); + }; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + }} > -
+
+
{(() => { - const getChunkSize = () => { - if (!imageSettings.mockup) return 1; - if (imageSettings.mockupLayout === 'grid-3') return 3; - if (imageSettings.mockupLayout === 'grid-2') return 2; - return 1; - }; const chunkSize = getChunkSize(); const chunks = []; for (let i = 0; i < uploadedImages.length; i += chunkSize) { @@ -1412,7 +2032,7 @@ export default function ImageGenerator() { if (chunks.length > 0) { return ( -
1 ? "grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 p-4 w-[90vw]" : "p-4 w-[min(80vh,90vw,1200px)]"}> +
1 ? "grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4 w-full" : "p-4 mx-auto w-[min(80vh,90%,1200px)]"}> {chunks.map((chunk, index) => (
e.stopPropagation()}> >) => handleArrowUpdate(index, id, props)} onArrowUpdateWithHistory={(id: string, props: Partial>) => handleArrowUpdateWithHistory(index, id, props)} onArrowDelete={(id: string) => handleArrowDelete(index, id)} - onCounterAdd={handleCounterAdd} + onCounterAdd={(coords) => handleCounterAdd(index, coords)} onCounterUpdate={(id, props) => handleCounterUpdate(index, id, props)} onCounterUpdateWithHistory={(id, props) => handleCounterUpdateWithHistory(index, id, props)} onCounterDelete={(id) => handleCounterDelete(index, id)} @@ -1457,6 +2078,18 @@ export default function ImageGenerator() { onShapeUpdate={(id, props) => handleShapeUpdate(index, id, props)} onShapeUpdateWithHistory={(id, props) => handleShapeUpdateWithHistory(index, id, props)} onShapeDelete={(id) => handleShapeDelete(index, id)} + brushes={allBrushes[index] || []} + brushDefaults={brushDefaults} + onBrushAdd={(brush) => handleBrushAdd(index, brush)} + onBrushDelete={(id) => handleBrushDelete(index, id)} + onBeginInteractionHistory={pushToHistory} + cropImageId={cropImageId} + onCropApply={(crop) => { + pushToHistory(); + setUploadedImages((prev) => prev.map((i) => i.id === cropImageId ? { ...i, crop } : i)); + setCropImageId(null); + }} + onCropCancel={() => setCropImageId(null)} selection={selection} onSelectObject={(canvasKey: number, itemId: string | null, type: any) => (itemId ? setSelection({ canvasKey, itemId, type }) : setSelection(null))} editing={editing} @@ -1492,6 +2125,7 @@ export default function ImageGenerator() { drawingMode={drawingMode} texts={allTexts[activeImageIndex ?? -1] || []} arrows={allArrows[-1] || []} + arrowDefaults={arrowDefaults} counters={allCounters[-1] || []} redactions={allRedactions[-1] || []} shapes={allShapes[-1] || []} @@ -1502,7 +2136,7 @@ export default function ImageGenerator() { onArrowUpdate={(id: string, props: Partial>) => handleArrowUpdate(-1, id, props)} onArrowUpdateWithHistory={(id: string, props: Partial>) => handleArrowUpdateWithHistory(-1, id, props)} onArrowDelete={(id: string) => handleArrowDelete(-1, id)} - onCounterAdd={handleCounterAdd} + onCounterAdd={(coords) => handleCounterAdd(-1, coords)} onCounterUpdate={(id, props) => handleCounterUpdate(-1, id, props)} onCounterUpdateWithHistory={(id, props) => handleCounterUpdateWithHistory(-1, id, props)} onCounterDelete={(id) => handleCounterDelete(-1, id)} @@ -1514,6 +2148,18 @@ export default function ImageGenerator() { onShapeUpdate={(id, props) => handleShapeUpdate(-1, id, props)} onShapeUpdateWithHistory={(id, props) => handleShapeUpdateWithHistory(-1, id, props)} onShapeDelete={(id) => handleShapeDelete(-1, id)} + brushes={allBrushes[-1] || []} + brushDefaults={brushDefaults} + onBrushAdd={(brush) => handleBrushAdd(-1, brush)} + onBrushDelete={(id) => handleBrushDelete(-1, id)} + onBeginInteractionHistory={pushToHistory} + cropImageId={cropImageId} + onCropApply={(crop) => { + pushToHistory(); + setUploadedImages((prev) => prev.map((i) => i.id === cropImageId ? { ...i, crop } : i)); + setCropImageId(null); + }} + onCropCancel={() => setCropImageId(null)} selection={selection} onSelectObject={(canvasKey: number, itemId: string | null, type: any) => (itemId ? setSelection({ canvasKey, itemId, type }) : setSelection(null))} editing={editing} @@ -1525,22 +2171,38 @@ export default function ImageGenerator() { ); })()}
+
handleCounterAdd()} onAddShape={handleShapeAdd} onAddRedact={handleRedactAdd} onUndo={handleUndo} + onRedo={handleRedo} onDeleteSelected={handleDeleteSelected} isObjectSelected={!!selectedObject} + isStyleableSelected={!!selectedObject && selection?.type !== "brush"} canUndo={history.length > 0} + canRedo={redoStack.length > 0} isStylePopoverOpen={isStylePopoverOpen} onToggleStylePopover={() => setIsStylePopoverOpen((p) => !p)} drawingMode={drawingMode} setDrawingMode={setDrawingMode} + arrowDefaults={arrowDefaults} + setArrowDefaults={setArrowDefaults} + brushDefaults={brushDefaults} + setBrushDefaults={setBrushDefaults} + counterDefaults={counterDefaults} + setCounterDefaults={setCounterDefaults} + onCrop={() => { + if (activeImageIndex !== null && uploadedImages[activeImageIndex]) { + setCropImageId(uploadedImages[activeImageIndex].id); + } + }} + canCrop={activeImageIndex !== null && !!uploadedImages[activeImageIndex]} > - {selectedObject && ( + {selectedObject && selection?.type !== "brush" && ( = ({ mockupId, color, layout = 'single', uploadedImages, fallbackImage, padding }) => { + getImageUrl: (url: string | null) => string | null; +}> = ({ mockupId, color, layout = 'single', uploadedImages, fallbackImage, padding, getImageUrl }) => { const device = DEVICE_MOCKUPS.find(d => d.id === mockupId); if (!device) return null; @@ -96,19 +98,19 @@ const DeviceMockupFrame: React.FC<{ const { x, y, width, height } = device.screen; const deviceCount = layout === 'grid-3' ? 3 : layout === 'grid-2' ? 2 : 1; - const displayImages: string[] = []; + const displayImages: { src: string; crop?: UploadedImage['crop'] }[] = []; // We want to fill `deviceCount` slots. // If the user hasn't uploaded enough images, repeat the first one over and over. for (let i = 0; i < deviceCount; i++) { if (uploadedImages.length > i) { - displayImages.push(uploadedImages[i].src); + displayImages.push({ src: getImageUrl(uploadedImages[i].src) || uploadedImages[i].src, crop: uploadedImages[i].crop }); } else if (uploadedImages.length > 0) { // fallback to the first uploaded image - displayImages.push(uploadedImages[0].src); + displayImages.push({ src: getImageUrl(uploadedImages[0].src) || uploadedImages[0].src, crop: uploadedImages[0].crop }); } else { // fallback to the single main editor image - displayImages.push(fallbackImage); + displayImages.push({ src: fallbackImage }); } } @@ -118,7 +120,11 @@ const DeviceMockupFrame: React.FC<{ style={{ padding: `${padding}px` }} >
- {displayImages.map((src, index) => ( + {displayImages.map((image, index) => { + const crop = image.crop; + const bgPosX = crop && crop.width < 100 ? (crop.x / (100 - crop.width)) * 100 : 0; + const bgPosY = crop && crop.height < 100 ? (crop.y / (100 - crop.height)) * 100 : 0; + return (
{/* Spacer image using frameUrl to naturally bound the size of this block */} @@ -140,7 +146,19 @@ const DeviceMockupFrame: React.FC<{ height: `${height}%`, }} > - Screen Content + {crop ? ( +
+ ) : ( + Screen Content + )}
{/* Frame overlay - sits above the screen area for inner shadows/bezels */} @@ -153,7 +171,8 @@ const DeviceMockupFrame: React.FC<{
- ))} + ); + })}
); @@ -172,12 +191,13 @@ const alignmentClasses = { 'bottom-right': 'items-end justify-end', }; -const TextElement: React.FC & { text: TextObject, isSelected: boolean, isEditing: boolean, previewRef: React.RefObject }> = - ({ canvasKey, text, isSelected, isEditing, onSetEditing, onSelectObject, onTextUpdate, onTextUpdateWithHistory, textEffects, aspectRatio, previewRef }) => { +const TextElement: React.FC & { text: TextObject, isSelected: boolean, isEditing: boolean, previewRef: React.RefObject }> = + ({ canvasKey, text, isSelected, isEditing, onSetEditing, onSelectObject, onTextUpdate, onTextUpdateWithHistory, onBeginInteractionHistory, textEffects, aspectRatio, previewRef }) => { const textareaRef = useRef(null); const elementRef = useRef(null); const dragInfo = useRef({ hasMoved: false }); const lastClickTime = useRef(0); + const editHistoryStarted = useRef(false); const handlePointerDown = (e: React.PointerEvent) => { if (e.button !== 0) return; @@ -197,6 +217,7 @@ const TextElement: React.FC { const dx = moveEvent.clientX - startX; @@ -204,6 +225,7 @@ const TextElement: React.FC 5) { dragInfo.current.hasMoved = true; + onBeginInteractionHistory(); target.setPointerCapture(e.pointerId); document.body.style.cursor = 'grabbing'; } @@ -226,7 +248,7 @@ const TextElement: React.FC { const currentDistance = Math.hypot(moveEvent.clientX - centerX, moveEvent.clientY - centerY); @@ -307,7 +330,7 @@ const TextElement: React.FC) => { + if (!editHistoryStarted.current) { + onBeginInteractionHistory(); + editHistoryStarted.current = true; + } + onTextUpdate(text.id, { content: e.target.value }); + }; + const handleBlur = (e: React.FocusEvent) => { - onTextUpdateWithHistory(text.id, { content: e.target.value }); + onTextUpdate(text.id, { content: e.target.value }); onSetEditing(canvasKey, null); }; - const baseFontSize = aspectRatio === '1:1' ? '6vw' : '5vw'; - const maxFontSize = aspectRatio === '1:1' ? 72 : 96; + const isPortrait = aspectRatio === '1:1' || aspectRatio === '9:16' || aspectRatio === '4:5'; + const baseFontSize = isPortrait ? '6vw' : '5vw'; + const maxFontSize = isPortrait ? 72 : 96; let fontSize = `clamp(1.5rem, ${baseFontSize}, ${maxFontSize}px)`; - if (text.content.length > 15) fontSize = `clamp(1.25rem, ${aspectRatio === '1:1' ? '4vw' : '3vw'}, ${maxFontSize}px)`; - if (text.content.length > 25) fontSize = `clamp(1rem, ${aspectRatio === '1:1' ? '3vw' : '2vw'}, ${maxFontSize}px)`; + if (text.content.length > 15) fontSize = `clamp(1.25rem, ${isPortrait ? '4vw' : '3vw'}, ${maxFontSize}px)`; + if (text.content.length > 25) fontSize = `clamp(1rem, ${isPortrait ? '3vw' : '2vw'}, ${maxFontSize}px)`; const glassmorphicStyle: React.CSSProperties = textEffects.isGlassmorphic ? { backgroundColor: hexToRgba(textEffects.glassColor, textEffects.glassOpacity), @@ -423,7 +456,7 @@ const TextElement: React.FC onTextUpdate(text.id, { content: e.target.value })} + onChange={handleTextareaChange} onBlur={handleBlur} onKeyDown={handleTextareaKeyDown} className="w-full p-4 bg-transparent border-0 resize-none overflow-hidden text-center font-bold focus:outline-none ring-2 ring-white/50 rounded-lg" @@ -438,8 +471,8 @@ const TextElement: React.FC & { arrow: ArrowObject, isSelected: boolean, previewRef: React.RefObject }> = - ({ canvasKey, arrow, isSelected, onSelectObject, onArrowUpdate, onArrowUpdateWithHistory, previewRef }) => { +const ArrowElement: React.FC & { arrow: ArrowObject, isSelected: boolean, previewRef: React.RefObject }> = + ({ canvasKey, arrow, isSelected, onSelectObject, onArrowUpdate, onArrowUpdateWithHistory, onBeginInteractionHistory, previewRef }) => { const lineRef = useRef(null); const dragInfo = useRef({ hasMoved: false }); // Fix: Use unique ID for each arrow's marker to prevent color conflicts @@ -458,6 +491,7 @@ const ArrowElement: React.FC { const dx = moveEvent.clientX - startX; @@ -465,6 +499,7 @@ const ArrowElement: React.FC 3) { dragInfo.current.hasMoved = true; + onBeginInteractionHistory(); document.body.style.cursor = 'grabbing'; } @@ -485,9 +520,7 @@ const ArrowElement: React.FC {isSelected && ( <> @@ -593,8 +632,33 @@ const ArrowElement: React.FC & { counter: CounterObject, isSelected: boolean, previewRef: React.RefObject }> = - ({ canvasKey, counter, isSelected, onSelectObject, onCounterUpdate, onCounterUpdateWithHistory, previewRef }) => { +const formatCounterLabel = (n: number, format: CounterObject['format']): string => { + if (format === 'roman') { + const map: [number, string][] = [ + [1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], + [100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'], + [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'], + ]; + let num = n, out = ''; + for (const [v, s] of map) { + while (num >= v) { out += s; num -= v; } + } + return out || String(n); + } + if (format === 'alpha') { + let num = n, out = ''; + while (num > 0) { + const rem = (num - 1) % 26; + out = String.fromCharCode(65 + rem) + out; + num = Math.floor((num - 1) / 26); + } + return out || 'A'; + } + return String(n); +}; + +const CounterElement: React.FC & { counter: CounterObject, isSelected: boolean, previewRef: React.RefObject }> = + ({ canvasKey, counter, isSelected, onSelectObject, onCounterUpdate, onCounterUpdateWithHistory, onBeginInteractionHistory, previewRef }) => { const dragInfo = useRef({ hasMoved: false }); const handlePointerDown = (e: React.PointerEvent) => { @@ -618,6 +682,7 @@ const CounterElement: React.FC 3) { dragInfo.current.hasMoved = true; + onBeginInteractionHistory(); document.body.style.cursor = 'grabbing'; } @@ -632,9 +697,7 @@ const CounterElement: React.FC e.stopPropagation()} > - {counter.count} + {formatCounterLabel(counter.count, counter.format)}
); }; -const RedactElement: React.FC & { redact: RedactObject, isSelected: boolean, previewRef: React.RefObject }> = - ({ canvasKey, redact, isSelected, onSelectObject, onRedactUpdate, onRedactUpdateWithHistory, previewRef }) => { +const CroppedImage: React.FC<{ + src: string; + crop: { x: number; y: number; width: number; height: number }; + imageStyle: React.CSSProperties; + radius: string; +}> = ({ src, crop, imageStyle, radius }) => { + const [naturalSize, setNaturalSize] = useState<{ w: number; h: number } | null>(null); + const [parentSize, setParentSize] = useState<{ w: number; h: number } | null>(null); + const outerRef = useRef(null); + + useEffect(() => { + const img = new window.Image(); + img.onload = () => setNaturalSize({ w: img.naturalWidth, h: img.naturalHeight }); + img.src = src; + }, [src]); + + // Measure the available canvas area. Walk up past the inline-flex fit-content parent + // (which would collapse to our own size — circular dependency) to the alignment div. + // Subtract padding manually because clientWidth/clientHeight include padding in modern browsers. + useEffect(() => { + const el = outerRef.current; + if (!el) return; + const flexParent = el.parentElement; + const measureTarget = flexParent?.parentElement; + if (!measureTarget || !flexParent) return; + const update = () => { + const targetStyles = window.getComputedStyle(measureTarget); + const padL = parseFloat(targetStyles.paddingLeft) || 0; + const padR = parseFloat(targetStyles.paddingRight) || 0; + const padT = parseFloat(targetStyles.paddingTop) || 0; + const padB = parseFloat(targetStyles.paddingBottom) || 0; + // clientWidth/Height include padding; subtract it to get the true inner content area. + const w = measureTarget.clientWidth - padL - padR; + const h = measureTarget.clientHeight - padT - padB; + // Divide out the inline-flex parent's transform scale so the rendered image fits. + const flexStyles = window.getComputedStyle(flexParent); + const matrix = new DOMMatrixReadOnly(flexStyles.transform === 'none' ? '' : flexStyles.transform); + const scale = matrix.a || 1; + if (w > 0 && h > 0 && scale > 0) { + setParentSize({ w: w / scale, h: h / scale }); + } + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(measureTarget); + ro.observe(flexParent); + return () => ro.disconnect(); + }, []); + + if (!naturalSize) { + return
; + } + + const cropAspect = (naturalSize.w * crop.width) / (naturalSize.h * crop.height); + + // Compute the largest size that fits inside parentSize while preserving aspect. + let fitW: number; + let fitH: number; + if (!parentSize) { + // Before measurement — use a reasonable initial size based on the crop's natural pixels + // so the inline-flex parent has something to size against. The ResizeObserver will + // correct this on the next frame. + const cropPxWidth = (naturalSize.w * crop.width) / 100; + const cropPxHeight = (naturalSize.h * crop.height) / 100; + fitW = cropPxWidth; + fitH = cropPxHeight; + } else { + const parentAspect = parentSize.w / parentSize.h; + if (cropAspect >= parentAspect) { + fitW = parentSize.w; + fitH = fitW / cropAspect; + } else { + fitH = parentSize.h; + fitW = fitH * cropAspect; + } + } + + const { width: _w, height: _h, ...imageStyleRest } = imageStyle; + void _w; void _h; + + // Safe background-position: when crop covers a full axis (width or height = 100), + // the denominator becomes 0. Default to 0% in that case. + const bgPosX = crop.width >= 100 ? 0 : (crop.x / (100 - crop.width)) * 100; + const bgPosY = crop.height >= 100 ? 0 : (crop.y / (100 - crop.height)) * 100; + + return ( +
+ ); +}; + +const pointsToPath = (points: BrushPoint[]): string => { + if (points.length === 0) return ''; + if (points.length === 1) return `M ${points[0].x} ${points[0].y}`; + return points.reduce((acc, p, i) => acc + (i === 0 ? `M ${p.x} ${p.y}` : ` L ${p.x} ${p.y}`), ''); +}; + +const BrushLayer: React.FC<{ + brushes: BrushObject[]; + drawingBrush: Omit | null; + canvasKey: number; + selection: ImagePreviewProps['selection']; + onSelectObject: ImagePreviewProps['onSelectObject']; +}> = ({ brushes, drawingBrush, canvasKey, selection, onSelectObject }) => { + const blurBrushes = brushes.filter(b => b.mode === 'blur'); + const inkBrushes = brushes.filter(b => b.mode !== 'blur'); + const drawingIsBlur = drawingBrush?.mode === 'blur'; + const drawingIsInk = drawingBrush && drawingBrush.mode !== 'blur'; + + return ( + <> + {(blurBrushes.length > 0 || drawingIsBlur) && ( +
${ + [...blurBrushes, ...(drawingIsBlur ? [drawingBrush!] : [])] + .map((b) => ``) + .join('') + }` + )}")`, + maskImage: `url("data:image/svg+xml;utf8,${encodeURIComponent( + `${ + [...blurBrushes, ...(drawingIsBlur ? [drawingBrush!] : [])] + .map((b) => ``) + .join('') + }` + )}")`, + WebkitMaskSize: '100% 100%', + maskSize: '100% 100%', + }} + /> + )} + {(inkBrushes.length > 0 || drawingIsInk) && ( + + {inkBrushes.map((b) => { + const isSelected = selection?.canvasKey === canvasKey && selection.itemId === b.id && selection.type === 'brush'; + return ( + { e.stopPropagation(); onSelectObject(canvasKey, b.id, 'brush'); }} + /> + ); + })} + {drawingIsInk && ( + + )} + + )} + + ); +}; + +const RedactElement: React.FC & { redact: RedactObject, isSelected: boolean, previewRef: React.RefObject }> = + ({ canvasKey, redact, isSelected, onSelectObject, onRedactUpdate, onRedactUpdateWithHistory, onBeginInteractionHistory, previewRef }) => { const elementRef = useRef(null); const dragInfo = useRef({ hasMoved: false }); @@ -689,6 +939,7 @@ const RedactElement: React.FC 3) { dragInfo.current.hasMoved = true; + onBeginInteractionHistory(); document.body.style.cursor = 'grabbing'; } @@ -703,9 +954,7 @@ const RedactElement: React.FC { const dx = moveEvent.clientX - startX; @@ -742,13 +992,22 @@ const RedactElement: React.FC { + if (redact.mode === 'blur') return 'blur(16px)'; + if (redact.mode === 'pixelate') return 'blur(4px) contrast(1.4) saturate(1.2)'; + return 'none'; + })(); + const backgroundImage = redact.mode === 'pixelate' + ? 'linear-gradient(to right, rgba(0,0,0,0.08) 1px, transparent 1px), linear-gradient(to bottom, rgba(0,0,0,0.08) 1px, transparent 1px)' + : undefined; + const backgroundColor = redact.mode === 'solid' ? 'black' : 'transparent'; + return (
& { shape: ShapeObject, isSelected: boolean, previewRef: React.RefObject }> = - ({ canvasKey, shape, isSelected, onSelectObject, onShapeUpdate, onShapeUpdateWithHistory, previewRef }) => { +const ShapeElement: React.FC & { shape: ShapeObject, isSelected: boolean, previewRef: React.RefObject }> = + ({ canvasKey, shape, isSelected, onSelectObject, onShapeUpdate, onShapeUpdateWithHistory, onBeginInteractionHistory, previewRef }) => { const dragInfo = useRef({ hasMoved: false }); const handlePointerDown = (e: React.PointerEvent) => { @@ -800,6 +1062,7 @@ const ShapeElement: React.FC 3) { dragInfo.current.hasMoved = true; + onBeginInteractionHistory(); document.body.style.cursor = 'grabbing'; } @@ -814,9 +1077,7 @@ const ShapeElement: React.FC( backgroundEffects, textEffects, onUpdateImage, imageSettings, drawingMode, - texts, arrows, counters, redactions, shapes, + texts, arrows, arrowDefaults, counters, redactions, shapes, brushes, brushDefaults, onTextUpdate, onTextUpdateWithHistory, onTextDelete, onArrowAdd, onArrowUpdate, onArrowUpdateWithHistory, onCounterAdd, onCounterUpdate, onCounterUpdateWithHistory, onCounterDelete, onRedactAdd, onRedactUpdate, onRedactUpdateWithHistory, onRedactDelete, onShapeAdd, onShapeUpdate, onShapeUpdateWithHistory, onShapeDelete, + onBrushAdd, onBrushDelete, onBeginInteractionHistory, + cropImageId, onCropApply, onCropCancel, selection, onSelectObject, editing, onSetEditing, onActivate, isActive, setDrawingMode, onImageSettingsChange, uploadedImageObj, uploadedImage, uploadedImages @@ -876,8 +1139,10 @@ export const ImagePreview = forwardRef( const localPreviewRef = useRef(null); const [drawingArrow, setDrawingArrow] = useState | null>(null); + const [drawingBrush, setDrawingBrush] = useState | null>(null); const drawingArrowRef = useRef | null>(null); + const drawingBrushRef = useRef | null>(null); const setRefs = useCallback((node: HTMLDivElement | null) => { localPreviewRef.current = node; @@ -886,15 +1151,64 @@ export const ImagePreview = forwardRef( }, [fwdRef]); const handlePointerDown = (e: React.PointerEvent) => { - if (drawingMode !== 'arrow' || !localPreviewRef.current) return; + if (!localPreviewRef.current) return; + + if (drawingMode === 'brush') { + onSelectObject(canvasKey, null, 'text'); + const previewRect = localPreviewRef.current.getBoundingClientRect(); + const x = ((e.clientX - previewRect.left) / previewRect.width) * 100; + const y = ((e.clientY - previewRect.top) / previewRect.height) * 100; + + const newBrush: Omit = { + mode: brushDefaults.mode, + color: brushDefaults.color, + size: brushDefaults.size, + points: [{ x, y }], + }; + drawingBrushRef.current = newBrush; + setDrawingBrush(newBrush); + + const onMove = (moveEvent: PointerEvent) => { + if (!drawingBrushRef.current) return; + const cx = ((moveEvent.clientX - previewRect.left) / previewRect.width) * 100; + const cy = ((moveEvent.clientY - previewRect.top) / previewRect.height) * 100; + const last = drawingBrushRef.current.points[drawingBrushRef.current.points.length - 1]; + // Throttle: only add if moved >0.3% to keep paths smooth but small + if (Math.hypot(cx - last.x, cy - last.y) < 0.3) return; + drawingBrushRef.current = { + ...drawingBrushRef.current, + points: [...drawingBrushRef.current.points, { x: cx, y: cy }], + }; + setDrawingBrush({ ...drawingBrushRef.current }); + }; + const onUp = () => { + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + if (drawingBrushRef.current && drawingBrushRef.current.points.length > 1) { + onBrushAdd(drawingBrushRef.current); + } + drawingBrushRef.current = null; + setDrawingBrush(null); + }; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + return; + } + + if (drawingMode !== 'arrow') return; onSelectObject(canvasKey, null, 'text'); // Deselect all const previewRect = localPreviewRef.current.getBoundingClientRect(); const x = ((e.clientX - previewRect.left) / previewRect.width) * 100; const y = ((e.clientY - previewRect.top) / previewRect.height) * 100; - // Default drawing color red, more visible than white - const newArrow = { x1: x, y1: y, x2: x, y2: y, color: '#ef4444', strokeWidth: 4 }; + const newArrow: Omit = { + x1: x, y1: y, x2: x, y2: y, + color: arrowDefaults.color, + strokeWidth: arrowDefaults.strokeWidth, + lineStyle: arrowDefaults.lineStyle, + headStyle: arrowDefaults.headStyle, + }; drawingArrowRef.current = newArrow; setDrawingArrow(newArrow); @@ -928,10 +1242,31 @@ export const ImagePreview = forwardRef( document.addEventListener('pointerup', onPointerUp); }; - const aspectClass = aspectRatio === '1:1' ? 'aspect-square' : 'aspect-video'; + const aspectClass = (() => { + switch (aspectRatio) { + case '1:1': return 'aspect-square'; + case '16:9': return 'aspect-video'; + case '9:16': return ''; + case '4:5': return ''; + case '4:3': return ''; + case '3:2': return ''; + case '1.91:1': return ''; + default: return 'aspect-video'; + } + })(); + const aspectStyle: React.CSSProperties = (() => { + switch (aspectRatio) { + case '9:16': return { aspectRatio: '9 / 16' }; + case '4:5': return { aspectRatio: '4 / 5' }; + case '4:3': return { aspectRatio: '4 / 3' }; + case '3:2': return { aspectRatio: '3 / 2' }; + case '1.91:1': return { aspectRatio: '1.91 / 1' }; + default: return {}; + } + })(); const alignmentClass = alignmentClasses[imageSettings.alignment]; const imageStyle: React.CSSProperties = { - // transform: `scale(${imageSettings.scale})`, // Moved to wrapper div + // transform: `scale(${foregroundScale})`, // Moved to wrapper div boxShadow: `0 25px 50px -12px rgba(0, 0, 0, ${imageSettings.shadow / 100 * 0.5})`, borderRadius: (() => { const r = imageSettings.corners; @@ -964,6 +1299,7 @@ export const ImagePreview = forwardRef( }; const imageContainerStyle = getImageContainerStyle(); + const foregroundScale = uploadedImageObj?.scale ?? imageSettings.scale; const activeClass = isActive ? 'ring-4 ring-offset-4 ring-offset-neutral-950 ring-blue-500' : 'ring-0'; @@ -980,6 +1316,7 @@ export const ImagePreview = forwardRef( const container = localPreviewRef.current; if (!container || !uploadedImageObj) return; // Ensure container and uploadedImageObj are available + onBeginInteractionHistory(); const containerRect = container.getBoundingClientRect(); const startX = e.clientX; @@ -996,7 +1333,7 @@ export const ImagePreview = forwardRef( // Calculate current width in % of container, compensating for the scale transform // rect.width includes the scale, so we divide by scale to get the "unscaled" base size - const currentWidth = ((rect.width / imageSettings.scale) / containerRect.width) * 100; + const currentWidth = ((rect.width / foregroundScale) / containerRect.width) * 100; // const currentHeight = ((rect.height / imageSettings.scale) / containerRect.height) * 100; // Removed to fix stretching // Initial set to lock position AND dimensions to prevent auto-scaling "jump" @@ -1077,8 +1414,21 @@ export const ImagePreview = forwardRef( const proxiedBackgroundImage = getProxiedUrl(backgroundImage); const proxiedUploadedImage = getProxiedUrl(uploadedImage); + // Match the ring's corner radius to the canvas radius. The ring sits OUTSIDE the canvas + // (via ring-offset-4), so its radius should equal canvas radius + offset (~8px) to look + // visually concentric. Cap below at 0 to keep sharp corners working. + const canvasRadius = backgroundEffects.canvasCornerRadius ?? 16; + const ringRadius = isActive ? Math.max(0, canvasRadius + 8) : canvasRadius; + return ( -
}> +
} + > @@ -1099,8 +1449,12 @@ export const ImagePreview = forwardRef(
{ if (drawingMode === 'counter') { const rect = e.currentTarget.getBoundingClientRect(); @@ -1143,6 +1497,7 @@ export const ImagePreview = forwardRef( uploadedImages={uploadedImages} fallbackImage={proxiedUploadedImage} padding={imageSettings.padding} + getImageUrl={getProxiedUrl} /> ); } @@ -1156,8 +1511,9 @@ export const ImagePreview = forwardRef(
( position: isManualPosition ? 'absolute' : 'relative', left: isManualPosition ? `${uploadedImageObj?.x}%` : undefined, top: isManualPosition ? `${uploadedImageObj?.y}%` : undefined, - // Use stored width/height if available, fallback to fit-content (auto wrapping) - width: isManualPosition && uploadedImageObj?.width ? `${uploadedImageObj.width}%` : 'fit-content', - height: isManualPosition && uploadedImageObj?.width ? 'auto' : 'fit-content', // Depend on width to maintain aspect ratio + width: uploadedImageObj?.width ? `${uploadedImageObj.width}%` : 'fit-content', + height: uploadedImageObj?.width ? 'auto' : 'fit-content', maxWidth: '100%', maxHeight: '100%', }} @@ -1193,6 +1548,7 @@ export const ImagePreview = forwardRef( const container = localPreviewRef.current; if (!container || !uploadedImageObj) return; + onBeginInteractionHistory(); const containerRect = container.getBoundingClientRect(); const imageWrapper = target.parentElement as HTMLElement; @@ -1261,7 +1617,7 @@ export const ImagePreview = forwardRef( right: `-${imageSettings.glassmorphicBorder.size}px`, bottom: `-${imageSettings.glassmorphicBorder.size}px`, backgroundColor: hexToRgba(imageSettings.glassmorphicBorder.color, 0.2), - border: `${Math.max(1, 1 / imageSettings.scale)}px solid ${hexToRgba(imageSettings.glassmorphicBorder.color, 0.3)}`, + border: `${Math.max(1, 1 / foregroundScale)}px solid ${hexToRgba(imageSettings.glassmorphicBorder.color, 0.3)}`, borderRadius: (() => { const r = imageSettings.corners + imageSettings.glassmorphicBorder.size; const a = imageSettings.alignment; @@ -1278,9 +1634,8 @@ export const ImagePreview = forwardRef( }} /> )} - { + {(() => { + const radius = (() => { if (imageSettings.mockup) return '0px'; const r = imageSettings.corners; const a = imageSettings.alignment; @@ -1292,8 +1647,43 @@ export const ImagePreview = forwardRef( if (a.includes('right')) { tr = 0; br = 0; } } return `${tl}px ${tr}px ${br}px ${bl}px`; - })(), - }} alt="Uploaded content" className="relative block w-auto h-auto z-10 max-w-full max-h-full" /> + })(); + const crop = uploadedImageObj?.crop; + if (crop) { + // Use a single rendered at the source's natural size scaled down + // via transform, but use object-* properties to make the box represent only + // the crop region. Simplest reliable approach: render the source image at the + // size of (crop.width%, crop.height%) of the original natural dimensions via + // a wrapper sized with width:auto + aspect-ratio + an inline-block sizer. + // + // Implementation: track the natural image dimensions via a stateful image and + // render the wrapper at the exact pixel dimensions of the crop. + return ( + + ); + } + return ( + Uploaded content + ); + })()} + {uploadedImageObj && cropImageId === uploadedImageObj.id && ( + + )}
); @@ -1322,14 +1712,26 @@ export const ImagePreview = forwardRef( - {arrows.map(arrow => ( - - - - ))} - {drawingArrow && ( + {arrows.map(arrow => { + const head = arrow.headStyle ?? 'filled'; + if (head === 'none') return null; + return ( + + {head === 'hollow' ? ( + + ) : ( + + )} + + ); + })} + {drawingArrow && (drawingArrow.headStyle ?? 'filled') !== 'none' && ( - + {(drawingArrow.headStyle ?? 'filled') === 'hollow' ? ( + + ) : ( + + )} )} @@ -1342,6 +1744,7 @@ export const ImagePreview = forwardRef( onSelectObject={onSelectObject} onArrowUpdate={onArrowUpdate} onArrowUpdateWithHistory={onArrowUpdateWithHistory} + onBeginInteractionHistory={onBeginInteractionHistory} previewRef={localPreviewRef} /> ))} @@ -1351,8 +1754,15 @@ export const ImagePreview = forwardRef( x2={`${drawingArrow.x2}%`} y2={`${drawingArrow.y2}%`} stroke={drawingArrow.color} strokeWidth={drawingArrow.strokeWidth} - markerEnd={`url(#arrowhead-${canvasKey}-drawing)`} + markerEnd={(drawingArrow.headStyle ?? 'filled') === 'none' ? undefined : `url(#arrowhead-${canvasKey}-drawing)`} strokeLinecap="round" + strokeDasharray={ + drawingArrow.lineStyle === 'dashed' + ? `${drawingArrow.strokeWidth * 2.5} ${drawingArrow.strokeWidth * 1.5}` + : drawingArrow.lineStyle === 'dotted' + ? `0.01 ${drawingArrow.strokeWidth * 2}` + : undefined + } style={{ filter: 'drop-shadow(0px 2px 3px rgba(0,0,0,0.3))' }} /> )} @@ -1360,22 +1770,23 @@ export const ImagePreview = forwardRef( { redactions && redactions.map(redact => ( - + )) } + { shapes && shapes.map(shape => ( - + )) } { texts.map(text => ( - + )) } { counters && counters.map(counter => ( - + )) }
diff --git a/components/image-generator/icons.tsx b/components/image-generator/icons.tsx index d8cf023..9134888 100644 --- a/components/image-generator/icons.tsx +++ b/components/image-generator/icons.tsx @@ -111,6 +111,13 @@ export const Undo = (props: React.SVGProps) => ( ); +export const Redo = (props: React.SVGProps) => ( + + + + +); + export const Move = (props: React.SVGProps) => ( @@ -248,3 +255,56 @@ export const Monitor = (props: React.SVGProps) => ( ); + +export const Crop = (props: React.SVGProps) => ( + + + + +); + +export const Image = (props: React.SVGProps) => ( + + + + + +); + +export const Layers = (props: React.SVGProps) => ( + + + + + +); + +export const Sparkles = (props: React.SVGProps) => ( + + + + + + + +); + +export const Smartphone = (props: React.SVGProps) => ( + + + + +); + +export const ChevronRight = (props: React.SVGProps) => ( + + + +); + +export const Plus = (props: React.SVGProps) => ( + + + + +); diff --git a/components/image-generator/types.ts b/components/image-generator/types.ts index 7a7ec98..01e4893 100644 --- a/components/image-generator/types.ts +++ b/components/image-generator/types.ts @@ -1,6 +1,24 @@ import React from 'react'; -export type AspectRatio = '1:1' | '16:9'; +export type AspectRatio = '1:1' | '16:9' | '9:16' | '4:5' | '4:3' | '3:2' | '1.91:1'; + +export interface AspectRatioPreset { + id: AspectRatio; + label: string; + description: string; + width: number; + height: number; +} + +export const ASPECT_RATIO_PRESETS: AspectRatioPreset[] = [ + { id: '1:1', label: 'Square', description: 'Instagram post', width: 1080, height: 1080 }, + { id: '16:9', label: 'Wide', description: 'YouTube / Twitter', width: 1920, height: 1080 }, + { id: '9:16', label: 'Story', description: 'Instagram / TikTok story', width: 1080, height: 1920 }, + { id: '4:5', label: 'Portrait', description: 'Instagram portrait', width: 1080, height: 1350 }, + { id: '4:3', label: 'Classic', description: 'Standard', width: 1600, height: 1200 }, + { id: '3:2', label: 'Photo', description: 'Photography', width: 1500, height: 1000 }, + { id: '1.91:1', label: 'OG / LinkedIn', description: 'Open Graph / LinkedIn', width: 1200, height: 627 }, +]; export type Alignment = 'top-left' | 'top-center' | 'top-right' | @@ -39,6 +57,7 @@ export interface BackgroundEffects { watercolor: number; pattern: 'none' | 'dots' | 'grid' | 'lines' | 'waves' | 'zigzag' | 'hexagons' | 'diagonal-stripes' | 'crosshatch' | 'plus'; patternOpacity: number; + canvasCornerRadius?: number; } export interface TextShadow { @@ -74,6 +93,9 @@ export interface TextObject { width?: number; } +export type ArrowLineStyle = 'solid' | 'dashed' | 'dotted'; +export type ArrowHeadStyle = 'filled' | 'hollow' | 'none'; + export interface ArrowObject { id: string; type: 'arrow'; @@ -83,17 +105,35 @@ export interface ArrowObject { y2: number; color: string; strokeWidth: number; + lineStyle?: ArrowLineStyle; + headStyle?: ArrowHeadStyle; +} + +export interface ArrowDefaults { + color: string; + strokeWidth: number; + lineStyle: ArrowLineStyle; + headStyle: ArrowHeadStyle; } +export type CounterFormat = 'number' | 'roman' | 'alpha'; + export interface CounterObject { id: string; type: 'counter'; x: number; y: number; count: number; - format: 'number' | 'roman' | 'alpha'; + format: CounterFormat; + color: string; + scale: number; +} + +export interface CounterDefaults { color: string; scale: number; + format: CounterFormat; + startAt: number; } export interface RedactObject { @@ -119,13 +159,35 @@ export interface ShapeObject { strokeWidth: number; } -export type CanvasObject = TextObject | ArrowObject | CounterObject | RedactObject | ShapeObject; +export type BrushMode = 'pencil' | 'highlighter' | 'blur'; + +export interface BrushPoint { + x: number; + y: number; +} + +export interface BrushObject { + id: string; + type: 'brush'; + mode: BrushMode; + points: BrushPoint[]; + color: string; + size: number; +} + +export interface BrushDefaults { + mode: BrushMode; + color: string; + size: number; +} + +export type CanvasObject = TextObject | ArrowObject | CounterObject | RedactObject | ShapeObject | BrushObject; export interface Selection { canvasKey: number; itemId: string; - type: 'text' | 'arrow' | 'counter' | 'redact' | 'shape'; + type: 'text' | 'arrow' | 'counter' | 'redact' | 'shape' | 'brush'; } export interface UploadedImage { @@ -136,9 +198,13 @@ export interface UploadedImage { y?: number; width?: number; height?: number; + scale?: number; + crop?: { x: number; y: number; width: number; height: number }; } -export type DrawingMode = 'arrow' | 'redact' | 'shape' | 'counter' | 'move' | null; +export type CropAspectRatio = 'free' | '1:1' | '16:9' | '9:16' | '4:3' | '3:2'; + +export type DrawingMode = 'arrow' | 'redact' | 'shape' | 'counter' | 'move' | 'brush' | 'crop' | null; export interface ControlsProps { aspectRatio: AspectRatio; @@ -166,6 +232,12 @@ export interface ControlsProps { generateNewGradient: () => void; onDownloadSingle: () => void; onDownloadZip: () => void; + onCopyToClipboard: () => void; + copyStatus: 'idle' | 'copying' | 'copied' | 'error'; + exportFormat: 'png' | 'jpeg' | 'webp'; + setExportFormat: React.Dispatch>; + exportQuality: number; + setExportQuality: React.Dispatch>; isDownloading: boolean; isDevMode: boolean; setIsDevMode: React.Dispatch>; @@ -193,6 +265,7 @@ export interface ImagePreviewProps { texts: TextObject[]; arrows: ArrowObject[]; + arrowDefaults: ArrowDefaults; onTextUpdate: (id: string, props: Partial>) => void; onTextUpdateWithHistory: (id: string, props: Partial>) => void; onTextDelete: (id: string) => void; @@ -215,9 +288,17 @@ export interface ImagePreviewProps { onShapeUpdate: (id: string, props: Partial>) => void; onShapeUpdateWithHistory: (id: string, props: Partial>) => void; onShapeDelete: (id: string) => void; + brushes: BrushObject[]; + brushDefaults: BrushDefaults; + onBrushAdd: (brush: Omit) => void; + onBrushDelete: (id: string) => void; + onBeginInteractionHistory: () => void; + cropImageId: string | null; + onCropApply: (crop: { x: number; y: number; width: number; height: number } | undefined) => void; + onCropCancel: () => void; onImageSettingsChange: (key: K, value: ImageSettings[K]) => void; selection: Selection | null; - onSelectObject: (canvasKey: number, id: string | null, type: 'text' | 'arrow' | 'counter' | 'redact' | 'shape') => void; + onSelectObject: (canvasKey: number, id: string | null, type: 'text' | 'arrow' | 'counter' | 'redact' | 'shape' | 'brush') => void; editing: Selection | null; onSetEditing: (canvasKey: number, id: string | null) => void; onActivate: () => void; diff --git a/package-lock.json b/package-lock.json index af43682..92a9f08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3173,7 +3173,7 @@ "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -3183,7 +3183,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -4359,7 +4359,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -8205,7 +8205,6 @@ "version": "4.1.11", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { diff --git a/package.json b/package.json index 7d9b419..d28f845 100644 --- a/package.json +++ b/package.json @@ -54,4 +54,4 @@ "tw-animate-css": "^1.3.6", "typescript": "^5" } -} +} \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml index f42fdfe..cf4ee8c 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,14 +1,14 @@ -https://delta4.io/tools/readme-today2025-12-16T06:26:41.974Zmonthly0.7 -https://delta4.io/tools2025-12-16T06:26:41.976Zmonthly0.7 -https://delta4.io/tools/find-and-replace2025-12-16T06:26:41.976Zmonthly0.7 -https://delta4.io/tools/uri-encode-decode2025-12-16T06:26:41.976Zmonthly0.7 -https://delta4.io/tools/code-comparator2025-12-16T06:26:41.976Zmonthly0.7 -https://delta4.io/tools/js-object-to-json2025-12-16T06:26:41.976Zmonthly0.7 -https://delta4.io/tools/linkedin-text-formatter2025-12-16T06:26:41.976Zmonthly0.7 -https://delta4.io/tools/base64-encoder-and-decoder2025-12-16T06:26:41.976Zmonthly0.7 -https://delta4.io/tools/jwt-token-encoder-and-decoder2025-12-16T06:26:41.976Zmonthly0.7 -https://delta4.io/tools/bg-studio2025-12-16T06:26:41.976Zmonthly0.7 -https://delta4.io/tools/json-code-formatter2025-12-16T06:26:41.976Zmonthly0.7 +https://delta4.io/tools/jwt-token-encoder-and-decoder2026-05-28T13:08:10.818Zmonthly0.7 +https://delta4.io/tools/readme-today2026-05-28T13:08:10.818Zmonthly0.7 +https://delta4.io/tools/find-and-replace2026-05-28T13:08:10.818Zmonthly0.7 +https://delta4.io/tools/linkedin-text-formatter2026-05-28T13:08:10.818Zmonthly0.7 +https://delta4.io/tools/json-code-formatter2026-05-28T13:08:10.818Zmonthly0.7 +https://delta4.io/tools/js-object-to-json2026-05-28T13:08:10.818Zmonthly0.7 +https://delta4.io/tools/uri-encode-decode2026-05-28T13:08:10.818Zmonthly0.7 +https://delta4.io/tools/base64-encoder-and-decoder2026-05-28T13:08:10.818Zmonthly0.7 +https://delta4.io/tools/code-comparator2026-05-28T13:08:10.818Zmonthly0.7 +https://delta4.io/tools2026-05-28T13:08:10.818Zmonthly0.7 +https://delta4.io/tools/bg-studio2026-05-28T13:08:10.818Zmonthly0.7 \ No newline at end of file