510 lines
20 KiB
TypeScript
510 lines
20 KiB
TypeScript
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<string | null>(null);
|
||
const [imageFile, setImageFile] = useState<File | null>(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<ScaleMode>("fit");
|
||
const [alignment, setAlignment] = useState<Align>([0, 0]);
|
||
const [bgPreset, setBgPreset] = useState(0);
|
||
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
|
||
// previewCanvasRef — drawn to directly on every setting change; no toDataURL
|
||
const previewCanvasRef = useRef<HTMLCanvasElement>(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 (
|
||
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-foreground mb-1">Canvas Tool</h2>
|
||
<p className="text-sm text-text-secondary">
|
||
Upload an image to resize, scale, or center it on a canvas of any size.
|
||
</p>
|
||
</div>
|
||
|
||
<div
|
||
onDrop={handleDrop}
|
||
onDragOver={(e) => { 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",
|
||
)}
|
||
>
|
||
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
|
||
<div className="text-center">
|
||
<p className="text-sm font-medium text-text-secondary">
|
||
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||
</p>
|
||
<p className="text-xs text-text-tertiary mt-1">
|
||
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Editor screen ──────────────────────────────────────────────────────────
|
||
const alignDisabled = scaleMode === "stretch";
|
||
|
||
return (
|
||
<div className="space-y-4 pb-8">
|
||
{/* Hidden source canvas */}
|
||
<canvas ref={sourceCanvasRef} className="hidden" />
|
||
|
||
{/* Toolbar */}
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<h2 className="text-lg font-semibold text-foreground flex-1">Canvas Tool</h2>
|
||
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||
{imageFile?.name}
|
||
</span>
|
||
<span className="text-xs text-text-tertiary font-mono">
|
||
{naturalW}×{naturalH}
|
||
</span>
|
||
<button
|
||
onClick={clearAll}
|
||
className={cn(
|
||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||
"hover:text-destructive hover:border-destructive transition-colors",
|
||
)}
|
||
>
|
||
<X className="w-3.5 h-3.5" /> Clear
|
||
</button>
|
||
<button
|
||
onClick={handleDownload}
|
||
disabled={!imageReady}
|
||
className={cn(
|
||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||
imageReady
|
||
? "bg-primary text-white hover:bg-primary/90"
|
||
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||
)}
|
||
>
|
||
<Download className="w-3.5 h-3.5" /> Download PNG
|
||
</button>
|
||
</div>
|
||
|
||
{/* Controls */}
|
||
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
|
||
{/* Row 1: output size */}
|
||
<div className="flex flex-wrap gap-4 items-end">
|
||
<div className="space-y-1.5">
|
||
<p className="text-xs font-medium text-text-secondary">Output Size</p>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="number" min="1" max="4096" value={outW}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<span className="text-text-tertiary text-xs">×</span>
|
||
<input
|
||
type="number" min="1" max="4096" value={outH}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<button
|
||
onClick={() => setAspectLock((l) => !l)}
|
||
title={aspectLock ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||
className={cn(
|
||
"p-1.5 rounded-md border transition-colors",
|
||
aspectLock
|
||
? "border-primary text-primary bg-primary/10"
|
||
: "border-border text-text-tertiary hover:border-primary/50",
|
||
)}
|
||
>
|
||
{aspectLock ? <Lock className="w-3.5 h-3.5" /> : <Unlock className="w-3.5 h-3.5" />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Size presets */}
|
||
<div className="space-y-1.5">
|
||
<p className="text-xs font-medium text-text-secondary">Presets</p>
|
||
<div className="flex flex-wrap gap-1">
|
||
<button
|
||
onClick={() => { setOutW(String(naturalW)); setOutH(String(naturalH)); }}
|
||
className={cn(
|
||
"px-2 py-1 rounded text-xs border transition-colors",
|
||
outW === String(naturalW) && outH === String(naturalH)
|
||
? "border-primary text-primary bg-primary/10"
|
||
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||
)}
|
||
>
|
||
Original
|
||
</button>
|
||
{SIZE_PRESETS.map((p) => (
|
||
<button
|
||
key={p.label}
|
||
onClick={() => {
|
||
if (aspectLock) {
|
||
const scale = Math.min(p.w / naturalW, p.h / naturalH);
|
||
setOutW(String(Math.round(naturalW * scale)));
|
||
setOutH(String(Math.round(naturalH * scale)));
|
||
} else {
|
||
setOutW(String(p.w));
|
||
setOutH(String(p.h));
|
||
}
|
||
}}
|
||
className={cn(
|
||
"px-2 py-1 rounded text-xs border transition-colors",
|
||
outW === String(p.w) && outH === String(p.h)
|
||
? "border-primary text-primary bg-primary/10"
|
||
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||
)}
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Row 2: scale mode + alignment */}
|
||
<div className="flex flex-wrap gap-6 items-start">
|
||
<div className="space-y-1.5">
|
||
<p className="text-xs font-medium text-text-secondary">Scale Mode</p>
|
||
<div className="flex gap-1">
|
||
{SCALE_MODES.map((m) => (
|
||
<button
|
||
key={m.id}
|
||
onClick={() => setScaleMode(m.id)}
|
||
title={m.desc}
|
||
className={cn(
|
||
"px-3 py-1.5 rounded-md text-xs font-medium border transition-colors",
|
||
scaleMode === m.id
|
||
? "border-primary text-primary bg-primary/10"
|
||
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||
)}
|
||
>
|
||
{m.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-text-tertiary">
|
||
{SCALE_MODES.find((m) => m.id === scaleMode)?.desc}
|
||
</p>
|
||
</div>
|
||
|
||
<div className={cn("space-y-1.5", alignDisabled && "opacity-40 pointer-events-none")}>
|
||
<p className="text-xs font-medium text-text-secondary">
|
||
{scaleMode === "fill" ? "Crop Position" : "Alignment"}
|
||
</p>
|
||
<div className="grid grid-cols-3 gap-0.5 w-fit">
|
||
{ALIGN_GRID.map(([col, row], i) => {
|
||
const active = alignment[0] === col && alignment[1] === row;
|
||
return (
|
||
<button
|
||
key={i}
|
||
onClick={() => setAlignment([col, row])}
|
||
title={`${row === -1 ? "Top" : row === 0 ? "Middle" : "Bottom"}-${col === -1 ? "Left" : col === 0 ? "Center" : "Right"}`}
|
||
className={cn(
|
||
"w-7 h-7 rounded flex items-center justify-center border transition-colors",
|
||
active
|
||
? "border-primary bg-primary/20"
|
||
: "border-border hover:border-primary/50",
|
||
)}
|
||
>
|
||
<span className={cn(
|
||
"w-2 h-2 rounded-full transition-colors",
|
||
active ? "bg-primary" : "bg-text-tertiary/40",
|
||
)} />
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Side-by-side preview */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{/* Original */}
|
||
<div className="space-y-2">
|
||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||
Original — {naturalW}×{naturalH}
|
||
</p>
|
||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||
<div style={CHECKERBOARD}>
|
||
<img src={imageSrc} alt="Source" className="w-full block" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Result — canvas drawn directly, no toDataURL */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
|
||
Result — {outW || "?"}×{outH || "?"}
|
||
</p>
|
||
<div className="flex items-center gap-1">
|
||
{BG_PRESETS.map((preset, i) => (
|
||
<button
|
||
key={preset.label}
|
||
title={preset.label}
|
||
onClick={() => setBgPreset(i)}
|
||
className={cn(
|
||
"w-5 h-5 rounded border transition-all",
|
||
i === bgPreset
|
||
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
|
||
: "border-border hover:border-primary/50",
|
||
)}
|
||
style={preset.style}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !imageReady && "hidden")}>
|
||
<canvas ref={previewCanvasRef} className="w-full block" />
|
||
</div>
|
||
{!imageReady && (
|
||
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
|
||
<ImageIcon className="w-10 h-10 opacity-20" />
|
||
<span className="text-xs opacity-40 text-center leading-relaxed">
|
||
Loading image…
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Info strip */}
|
||
{imageReady && (
|
||
<div className="flex items-center gap-2 text-xs text-text-tertiary">
|
||
<Maximize2 className="w-3.5 h-3.5 shrink-0" />
|
||
<span>
|
||
Output: <span className="font-mono text-foreground">{outW}×{outH} px</span>
|
||
{" · "}
|
||
Mode: <span className="text-foreground capitalize">{scaleMode}</span>
|
||
{scaleMode !== "stretch" && (
|
||
<>
|
||
{" · "}
|
||
Align:{" "}
|
||
<span className="text-foreground">
|
||
{alignment[1] === -1 ? "Top" : alignment[1] === 0 ? "Middle" : "Bottom"}
|
||
-{alignment[0] === -1 ? "Left" : alignment[0] === 0 ? "Center" : "Right"}
|
||
</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|