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) => (
+
+ );
+
+ 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}%`,
}}
>
-

+ {crop ? (
+
+ ) : (
+

+ )}
{/* 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(
+ `
`
+ )}")`,
+ WebkitMaskSize: '100% 100%',
+ maskSize: '100% 100%',
+ }}
+ />
+ )}
+ {(inkBrushes.length > 0 || 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 (
- }>
+
}
+ >
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