import { useState, useRef, useCallback, useEffect } from "react"; import { Upload, Download, X, Lock, Unlock, ImageIcon, Maximize2 } from "lucide-react"; import { cn } from "../lib/utils"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const CHECKERBOARD: React.CSSProperties = { backgroundImage: ` linear-gradient(45deg, #444 25%, transparent 25%), linear-gradient(-45deg, #444 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #444 75%), linear-gradient(-45deg, transparent 75%, #444 75%) `, backgroundSize: "16px 16px", backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px", backgroundColor: "#2a2a2a", }; type BgPreset = { label: string; style: React.CSSProperties }; const BG_PRESETS: BgPreset[] = [ { label: "Checker", style: CHECKERBOARD }, { label: "White", style: { backgroundColor: "#ffffff" } }, { label: "Black", style: { backgroundColor: "#000000" } }, { label: "Red", style: { backgroundColor: "#e53e3e" } }, { label: "Green", style: { backgroundColor: "#38a169" } }, { label: "Blue", style: { backgroundColor: "#3182ce" } }, ]; type ScaleMode = "fit" | "fill" | "stretch" | "original"; const SCALE_MODES: { id: ScaleMode; label: string; desc: string }[] = [ { id: "fit", label: "Fit", desc: "Scale to fit canvas, preserve ratio (letterbox)" }, { id: "fill", label: "Fill", desc: "Scale to fill canvas, preserve ratio (crop edges)" }, { id: "stretch", label: "Stretch", desc: "Stretch to exact dimensions, ignore ratio" }, { id: "original", label: "Original", desc: "No scaling — align/center only" }, ]; type Align = [-1 | 0 | 1, -1 | 0 | 1]; const ALIGN_GRID: Align[] = [ [-1, -1], [0, -1], [1, -1], [-1, 0], [0, 0], [1, 0], [-1, 1], [0, 1], [1, 1], ]; const SIZE_PRESETS: { label: string; w: number; h: number }[] = [ { label: "64", w: 64, h: 64 }, { label: "128", w: 128, h: 128 }, { label: "256", w: 256, h: 256 }, { label: "512", w: 512, h: 512 }, { label: "1024", w: 1024, h: 1024 }, { label: "960×540", w: 960, h: 540 }, { label: "1920×1080", w: 1920, h: 1080 }, ]; // --------------------------------------------------------------------------- // CanvasTool // --------------------------------------------------------------------------- export function CanvasTool() { const [imageSrc, setImageSrc] = useState(null); const [imageFile, setImageFile] = useState(null); const [imageReady, setImageReady] = useState(false); const [dragOver, setDragOver] = useState(false); const [naturalW, setNaturalW] = useState(1); const [naturalH, setNaturalH] = useState(1); const [outW, setOutW] = useState("256"); const [outH, setOutH] = useState("256"); const [aspectLock, setAspectLock] = useState(true); const [scaleMode, setScaleMode] = useState("fit"); const [alignment, setAlignment] = useState([0, 0]); const [bgPreset, setBgPreset] = useState(0); const fileInputRef = useRef(null); const sourceCanvasRef = useRef(null); // previewCanvasRef — drawn to directly on every setting change; no toDataURL const previewCanvasRef = useRef(null); // ── Load image onto hidden source canvas ────────────────────────────────── useEffect(() => { if (!imageSrc) return; setImageReady(false); const img = new Image(); img.onload = () => { const canvas = sourceCanvasRef.current; if (!canvas) return; canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; canvas.getContext("2d")!.drawImage(img, 0, 0); setNaturalW(img.naturalWidth); setNaturalH(img.naturalHeight); setOutW(String(img.naturalWidth)); setOutH(String(img.naturalHeight)); setImageReady(true); }; img.src = imageSrc; }, [imageSrc]); // ── Draw output directly into previewCanvasRef whenever settings change ──── // drawImage is GPU-accelerated; skipping toDataURL eliminates the main bottleneck. useEffect(() => { if (!imageReady) return; const src = sourceCanvasRef.current; const prev = previewCanvasRef.current; if (!src || !prev) return; const w = parseInt(outW); const h = parseInt(outH); if (!w || !h || w <= 0 || h <= 0) return; const frame = requestAnimationFrame(() => { prev.width = w; prev.height = h; const ctx = prev.getContext("2d")!; ctx.clearRect(0, 0, w, h); const srcW = src.width; const srcH = src.height; let drawW: number; let drawH: number; if (scaleMode === "fit") { const scale = Math.min(w / srcW, h / srcH); drawW = srcW * scale; drawH = srcH * scale; } else if (scaleMode === "fill") { const scale = Math.max(w / srcW, h / srcH); drawW = srcW * scale; drawH = srcH * scale; } else if (scaleMode === "stretch") { drawW = w; drawH = h; } else { drawW = srcW; drawH = srcH; } const x = (w - drawW) * (alignment[0] + 1) / 2; const y = (h - drawH) * (alignment[1] + 1) / 2; ctx.drawImage(src, x, y, drawW, drawH); }); return () => cancelAnimationFrame(frame); }, [imageReady, outW, outH, scaleMode, alignment]); // ── Width / height with optional aspect lock ─────────────────────────────── const handleWChange = (v: string) => { setOutW(v); if (aspectLock) { const n = parseInt(v); if (!isNaN(n) && n > 0) setOutH(String(Math.round(n * naturalH / naturalW))); } }; const handleHChange = (v: string) => { setOutH(v); if (aspectLock) { const n = parseInt(v); if (!isNaN(n) && n > 0) setOutW(String(Math.round(n * naturalW / naturalH))); } }; // ── File loading ─────────────────────────────────────────────────────────── const loadFile = useCallback((file: File) => { if (imageSrc) URL.revokeObjectURL(imageSrc); setImageSrc(URL.createObjectURL(file)); setImageFile(file); }, [imageSrc]); const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setDragOver(false); const file = e.dataTransfer.files[0]; if (file?.type.startsWith("image/")) loadFile(file); }; // Download: toBlob from previewCanvasRef — only at explicit user request const handleDownload = () => { const prev = previewCanvasRef.current; if (!prev || !imageReady || !imageFile) return; prev.toBlob((blob) => { if (!blob) return; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.download = imageFile.name.replace(/\.[^.]+$/, "") + `_${outW}x${outH}.png`; a.href = url; a.click(); URL.revokeObjectURL(url); }, "image/png"); }; const clearAll = useCallback(() => { if (imageSrc) URL.revokeObjectURL(imageSrc); setImageSrc(null); setImageFile(null); setImageReady(false); if (fileInputRef.current) fileInputRef.current.value = ""; }, [imageSrc]); // ── Upload screen ────────────────────────────────────────────────────────── if (!imageSrc) { return (

Canvas Tool

Upload an image to resize, scale, or center it on a canvas of any size.

{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onClick={() => fileInputRef.current?.click()} className={cn( "border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none", dragOver ? "border-primary bg-primary/5" : "border-border hover:border-primary/50 hover:bg-primary/3", )} >

{dragOver ? "Drop to upload" : "Drop an image here"}

or click to browse · PNG, JPEG, WebP · max 15 MB

{ const f = e.target.files?.[0]; if (f) loadFile(f); }} className="hidden" />
); } // ── Editor screen ────────────────────────────────────────────────────────── const alignDisabled = scaleMode === "stretch"; return (
{/* Hidden source canvas */} {/* Toolbar */}

Canvas Tool

{imageFile?.name} {naturalW}×{naturalH}
{/* Controls */}
{/* Row 1: output size */}

Output Size

handleWChange(e.target.value)} className={cn( "w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono", "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", )} placeholder="W" /> × handleHChange(e.target.value)} className={cn( "w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono", "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", )} placeholder="H" />
{/* Size presets */}

Presets

{SIZE_PRESETS.map((p) => ( ))}
{/* Row 2: scale mode + alignment */}

Scale Mode

{SCALE_MODES.map((m) => ( ))}

{SCALE_MODES.find((m) => m.id === scaleMode)?.desc}

{scaleMode === "fill" ? "Crop Position" : "Alignment"}

{ALIGN_GRID.map(([col, row], i) => { const active = alignment[0] === col && alignment[1] === row; return ( ); })}
{/* Side-by-side preview */}
{/* Original */}

Original — {naturalW}×{naturalH}

Source
{/* Result — canvas drawn directly, no toDataURL */}

Result — {outW || "?"}×{outH || "?"}

{BG_PRESETS.map((preset, i) => (
{!imageReady && (
Loading image…
)}
{/* Info strip */} {imageReady && (
Output: {outW}×{outH} px {" · "} Mode: {scaleMode} {scaleMode !== "stretch" && ( <> {" · "} Align:{" "} {alignment[1] === -1 ? "Top" : alignment[1] === 0 ? "Middle" : "Bottom"} -{alignment[0] === -1 ? "Left" : alignment[0] === 0 ? "Center" : "Right"} )}
)}
); }