Files
aurorabot/panel/src/pages/CanvasTool.tsx
syntaxbullet 7cc2f61db6
All checks were successful
Deploy to Production / test (push) Successful in 37s
feat: add item creation tools
2026-02-19 14:40:22 +01:00

510 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}