feat: add item creation tools

This commit is contained in:
syntaxbullet
2026-02-19 14:40:22 +01:00
parent f5fecb59cb
commit 7cc2f61db6
9 changed files with 4409 additions and 11 deletions

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@imgly/background-removal": "^1.7.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.564.0",

View File

@@ -0,0 +1,881 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Upload, Download, X, Wand2, ImageIcon, Loader2 } 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" } },
];
// Max normalised distances for each keying space
const MAX_RGB = Math.sqrt(3); // ≈ 1.732
const MAX_HSV = 1.5; // sqrt(0.5² × 4 + 1² + 1² × 0.25)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
}
function hexToRgb(hex: string): [number, number, number] | null {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
if (!m) return null;
return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
}
// ---------------------------------------------------------------------------
// WebGL — shaders
// ---------------------------------------------------------------------------
const VERT = `
attribute vec2 aPos;
varying vec2 vUV;
void main() {
vUV = aPos * 0.5 + 0.5;
vUV.y = 1.0 - vUV.y;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const KEY_FRAG = `
precision mediump float;
uniform sampler2D uImage;
uniform vec3 uKey;
uniform float uTol;
uniform float uFeather;
uniform float uSatMin;
uniform float uSpill;
uniform float uHueMode;
varying vec2 vUV;
vec3 rgbToHsv(vec3 c) {
float cmax = max(c.r, max(c.g, c.b));
float cmin = min(c.r, min(c.g, c.b));
float delta = cmax - cmin;
float h = 0.0;
float s = (cmax < 0.0001) ? 0.0 : delta / cmax;
if (delta > 0.0001) {
if (cmax == c.r) h = mod((c.g - c.b) / delta, 6.0);
else if (cmax == c.g) h = (c.b - c.r) / delta + 2.0;
else h = (c.r - c.g) / delta + 4.0;
h /= 6.0;
}
return vec3(h, s, cmax);
}
void main() {
vec4 c = texture2D(uImage, vUV);
vec3 hsv = rgbToHsv(c.rgb);
vec3 keyHsv = rgbToHsv(uKey);
float d;
if (uHueMode > 0.5) {
float dh = abs(hsv.x - keyHsv.x);
if (dh > 0.5) dh = 1.0 - dh;
float ds = abs(hsv.y - keyHsv.y);
float dv = abs(hsv.z - keyHsv.z);
d = sqrt(dh * dh * 4.0 + ds * ds + dv * dv * 0.25);
} else {
d = distance(c.rgb, uKey);
}
float a = c.a;
if (hsv.y >= uSatMin) {
if (d <= uTol) {
a = 0.0;
} else if (uFeather > 0.0 && d <= uTol + uFeather) {
a = (d - uTol) / uFeather * c.a;
}
}
vec3 rgb = c.rgb;
if (uSpill > 0.0) {
float edgeDist = max(0.0, d - uTol);
float spillZone = max(uFeather + uTol * 0.5, 0.01);
float spillFact = clamp(1.0 - edgeDist / spillZone, 0.0, 1.0) * uSpill;
if (uKey.g >= uKey.r && uKey.g >= uKey.b) {
float excess = rgb.g - max(rgb.r, rgb.b);
if (excess > 0.0) rgb.g -= excess * spillFact;
} else if (uKey.b >= uKey.r && uKey.b >= uKey.g) {
float excess = rgb.b - max(rgb.r, rgb.g);
if (excess > 0.0) rgb.b -= excess * spillFact;
} else {
float excess = rgb.r - max(rgb.g, rgb.b);
if (excess > 0.0) rgb.r -= excess * spillFact;
}
}
gl_FragColor = vec4(rgb, a);
}`;
const HALO_FRAG = `
precision mediump float;
uniform sampler2D uKeyed;
uniform float uHaloStr;
uniform float uHaloRadius;
uniform vec2 uTexelSize;
varying vec2 vUV;
void main() {
vec4 c = texture2D(uKeyed, vUV);
if (uHaloStr <= 0.0) {
gl_FragColor = c;
return;
}
vec2 r = uTexelSize * uHaloRadius;
vec2 rd = r * 0.7071;
float minA = c.a;
minA = min(minA, texture2D(uKeyed, vUV + vec2( r.x, 0.0 )).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2(-r.x, 0.0 )).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, r.y )).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, -r.y )).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, rd.y)).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, rd.y)).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, -rd.y)).a);
minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, -rd.y)).a);
gl_FragColor = vec4(c.rgb, mix(c.a, minA, uHaloStr));
}`;
// ---------------------------------------------------------------------------
// WebGL — types + init
// ---------------------------------------------------------------------------
type GlState = {
gl: WebGLRenderingContext;
kProg: WebGLProgram;
srcTex: WebGLTexture;
fbo: WebGLFramebuffer;
fboTex: WebGLTexture;
uKey: WebGLUniformLocation;
uTol: WebGLUniformLocation;
uFeather: WebGLUniformLocation;
uSatMin: WebGLUniformLocation;
uSpill: WebGLUniformLocation;
uHueMode: WebGLUniformLocation;
hProg: WebGLProgram;
uHaloStr: WebGLUniformLocation;
uHaloRadius: WebGLUniformLocation;
uTexelSize: WebGLUniformLocation;
};
function compileShader(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
}
function makeProgram(gl: WebGLRenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram {
const p = gl.createProgram()!;
gl.attachShader(p, vs);
gl.attachShader(p, fs);
gl.bindAttribLocation(p, 0, "aPos");
gl.linkProgram(p);
return p;
}
function makeTexture(gl: WebGLRenderingContext): WebGLTexture {
const t = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return t;
}
function initGl(canvas: HTMLCanvasElement): GlState | null {
const gl = canvas.getContext("webgl", {
premultipliedAlpha: false,
preserveDrawingBuffer: true,
}) as WebGLRenderingContext | null;
if (!gl) return null;
const vs = compileShader(gl, gl.VERTEX_SHADER, VERT);
const kFrag = compileShader(gl, gl.FRAGMENT_SHADER, KEY_FRAG);
const hFrag = compileShader(gl, gl.FRAGMENT_SHADER, HALO_FRAG);
const kProg = makeProgram(gl, vs, kFrag);
const hProg = makeProgram(gl, vs, hFrag);
const buf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const srcTex = makeTexture(gl);
const fboTex = makeTexture(gl);
const fbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return {
gl, kProg, hProg, srcTex, fbo, fboTex,
uKey: gl.getUniformLocation(kProg, "uKey")!,
uTol: gl.getUniformLocation(kProg, "uTol")!,
uFeather: gl.getUniformLocation(kProg, "uFeather")!,
uSatMin: gl.getUniformLocation(kProg, "uSatMin")!,
uSpill: gl.getUniformLocation(kProg, "uSpill")!,
uHueMode: gl.getUniformLocation(kProg, "uHueMode")!,
uHaloStr: gl.getUniformLocation(hProg, "uHaloStr")!,
uHaloRadius: gl.getUniformLocation(hProg, "uHaloRadius")!,
uTexelSize: gl.getUniformLocation(hProg, "uTexelSize")!,
};
}
// ---------------------------------------------------------------------------
// AI remove tab
// ---------------------------------------------------------------------------
type AiStatus = "idle" | "loading" | "done" | "error";
function AiRemoveTab({ imageFile, imageSrc, onClear }: {
imageFile: File;
imageSrc: string;
onClear: () => void;
}) {
const [status, setStatus] = useState<AiStatus>("idle");
const [resultUrl, setResultUrl] = useState<string | null>(null);
const [bgPreset, setBgPreset] = useState(0);
const [progress, setProgress] = useState("");
const handleRemove = async () => {
setStatus("loading");
setProgress("Loading AI model…");
try {
const { removeBackground } = await import("@imgly/background-removal");
setProgress("Removing background…");
const blob = await removeBackground(imageSrc, {
progress: (_key: string, current: number, total: number) => {
if (total > 0) {
setProgress(`Downloading model… ${Math.round((current / total) * 100)}%`);
}
},
});
const url = URL.createObjectURL(blob);
setResultUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return url; });
setStatus("done");
} catch (err) {
console.error(err);
setStatus("error");
}
};
const handleDownload = () => {
if (!resultUrl) return;
const a = document.createElement("a");
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_nobg.png";
a.href = resultUrl;
a.click();
};
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
{imageFile.name}
</span>
<button
onClick={onClear}
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>
{status !== "done" ? (
<button
onClick={handleRemove}
disabled={status === "loading"}
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
status === "loading"
? "bg-raised border border-border text-text-tertiary cursor-not-allowed"
: "bg-primary text-white hover:bg-primary/90",
)}
>
{status === "loading" ? (
<><Loader2 className="w-3.5 h-3.5 animate-spin" /> {progress}</>
) : (
<><Wand2 className="w-3.5 h-3.5" /> Remove Background</>
)}
</button>
) : (
<button
onClick={handleDownload}
className="flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold bg-primary text-white hover:bg-primary/90 transition-colors"
>
<Download className="w-3.5 h-3.5" /> Download PNG
</button>
)}
</div>
{status === "error" && (
<p className="text-xs text-destructive">
Something went wrong. Check the console for details.
</p>
)}
{/* Side-by-side */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Original</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
<img src={imageSrc} className="w-full block" alt="Original" />
</div>
</div>
<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 transparent background
</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">
{resultUrl ? (
<div style={BG_PRESETS[bgPreset].style}>
<img src={resultUrl} className="w-full block" alt="Result" />
</div>
) : (
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
{status === "loading" ? (
<><Loader2 className="w-10 h-10 opacity-40 animate-spin" /><span className="text-xs opacity-40 text-center">{progress}</span></>
) : (
<><ImageIcon className="w-10 h-10 opacity-20" /><span className="text-xs opacity-40 text-center">Click Remove Background to process</span></>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// BackgroundRemoval component
// ---------------------------------------------------------------------------
type Mode = "chroma" | "ai";
export function BackgroundRemoval() {
const [mode, setMode] = useState<Mode>("chroma");
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imageReady, setImageReady] = useState(false);
const [keyColor, setKeyColor] = useState<[number, number, number] | null>(null);
const [hexInput, setHexInput] = useState("");
const [tolerance, setTolerance] = useState(30);
const [feather, setFeather] = useState(10);
const [satMin, setSatMin] = useState(0);
const [spillStr, setSpillStr] = useState(0);
const [hueMode, setHueMode] = useState(false);
const [haloStr, setHaloStr] = useState(0);
const [haloRadius, setHaloRadius] = useState(2);
const [dragOver, setDragOver] = useState(false);
const [bgPreset, setBgPreset] = useState(0);
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
const glCanvasRef = useRef<HTMLCanvasElement>(null);
const glRef = useRef<GlState | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (keyColor) setHexInput(rgbToHex(...keyColor));
}, [keyColor]);
useEffect(() => {
if (!imageSrc) return;
setImageReady(false);
setKeyColor(null);
setHexInput("");
const img = new Image();
img.onload = () => {
const src = sourceCanvasRef.current;
if (!src) return;
src.width = img.naturalWidth;
src.height = img.naturalHeight;
src.getContext("2d")!.drawImage(img, 0, 0);
const glCanvas = glCanvasRef.current;
if (!glCanvas) return;
glCanvas.width = img.naturalWidth;
glCanvas.height = img.naturalHeight;
if (!glRef.current) {
glRef.current = initGl(glCanvas);
if (!glRef.current) {
console.error("BackgroundRemoval: WebGL not supported");
return;
}
}
const { gl, srcTex, fboTex } = glRef.current;
gl.bindTexture(gl.TEXTURE_2D, srcTex);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.bindTexture(gl.TEXTURE_2D, fboTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, img.naturalWidth, img.naturalHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
setImageReady(true);
};
img.src = imageSrc;
}, [imageSrc]);
useEffect(() => {
const state = glRef.current;
if (!state || !imageReady || !keyColor) return;
const w = glCanvasRef.current!.width;
const h = glCanvasRef.current!.height;
const { gl } = state;
const MAX = hueMode ? MAX_HSV : MAX_RGB;
gl.bindFramebuffer(gl.FRAMEBUFFER, state.fbo);
gl.viewport(0, 0, w, h);
gl.useProgram(state.kProg);
gl.bindTexture(gl.TEXTURE_2D, state.srcTex);
gl.uniform3f(state.uKey, keyColor[0] / 255, keyColor[1] / 255, keyColor[2] / 255);
gl.uniform1f(state.uTol, (tolerance / 100) * MAX);
gl.uniform1f(state.uFeather, (feather / 100) * MAX);
gl.uniform1f(state.uSatMin, satMin / 100);
gl.uniform1f(state.uSpill, spillStr / 100);
gl.uniform1f(state.uHueMode, hueMode ? 1.0 : 0.0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, w, h);
gl.useProgram(state.hProg);
gl.bindTexture(gl.TEXTURE_2D, state.fboTex);
gl.uniform1f(state.uHaloStr, haloStr / 100);
gl.uniform1f(state.uHaloRadius, haloRadius);
gl.uniform2f(state.uTexelSize, 1 / w, 1 / h);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}, [keyColor, tolerance, feather, satMin, spillStr, hueMode, haloStr, haloRadius, imageReady]);
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);
};
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = sourceCanvasRef.current;
if (!canvas || !imageReady) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = Math.floor((e.clientX - rect.left) * scaleX);
const y = Math.floor((e.clientY - rect.top) * scaleY);
const px = canvas.getContext("2d")!.getImageData(x, y, 1, 1).data;
setKeyColor([px[0], px[1], px[2]]);
};
const handleHexInput = (v: string) => {
setHexInput(v);
const parsed = hexToRgb(v);
if (parsed) setKeyColor(parsed);
};
const handleDownload = () => {
const glCanvas = glCanvasRef.current;
if (!glCanvas || !keyColor || !imageFile) return;
glCanvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_transparent.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);
setKeyColor(null);
setHexInput("");
glRef.current = null;
if (fileInputRef.current) fileInputRef.current.value = "";
}, [imageSrc]);
// ── Upload screen ──────────────────────────────────────────────────────────
if (!imageSrc) {
return (
<div className="max-w-2xl mx-auto py-8 space-y-4">
{/* Page header + mode toggle */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="text-lg font-semibold text-foreground mb-1">
Background Removal
</h2>
<p className="text-sm text-text-secondary">
Upload an image then remove its background either by selecting a
key color (chroma key) or using the AI model.
</p>
</div>
<ModeToggle mode={mode} onChange={setMode} />
</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 hasResult = imageReady && keyColor !== null;
return (
<div className="space-y-4 pb-8">
{/* Page header + mode toggle */}
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-foreground flex-1">
Background Removal
</h2>
<ModeToggle mode={mode} onChange={setMode} />
</div>
{/* AI tab */}
{mode === "ai" && (
<AiRemoveTab imageFile={imageFile!} imageSrc={imageSrc} onClear={clearAll} />
)}
{/* Chroma key tab */}
{mode === "chroma" && (
<>
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
{imageFile?.name}
</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={!hasResult}
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
hasResult
? "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 — Key color + mode */}
<div className="flex flex-wrap gap-6 items-center">
<div className="space-y-1.5 shrink-0">
<p className="text-xs font-medium text-text-secondary">Key Color</p>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded-md border border-border shadow-inner shrink-0"
style={
keyColor
? { backgroundColor: rgbToHex(...keyColor) }
: {
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: "8px 8px",
backgroundPosition: "0 0,0 4px,4px -4px,-4px 0",
backgroundColor: "#2a2a2a",
}
}
/>
<input
type="text"
value={hexInput}
onChange={(e) => handleHexInput(e.target.value)}
placeholder="#rrggbb"
maxLength={7}
spellCheck={false}
className={cn(
"w-[76px] text-xs font-mono bg-transparent border rounded px-1.5 py-0.5",
"text-text-secondary focus:outline-none transition-colors",
hexInput && !hexToRgb(hexInput)
? "border-destructive focus:border-destructive"
: "border-border focus:border-primary",
)}
/>
</div>
</div>
<div className="space-y-1.5 shrink-0">
<p className="text-xs font-medium text-text-secondary">Keying Mode</p>
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium">
<button
onClick={() => setHueMode(false)}
className={cn(
"px-3 py-1.5 transition-colors",
!hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
)}
>
RGB
</button>
<button
onClick={() => setHueMode(true)}
className={cn(
"px-3 py-1.5 transition-colors border-l border-border",
hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
)}
>
HSV
</button>
</div>
</div>
{!keyColor && (
<span className="text-xs text-text-tertiary flex items-center gap-1.5 ml-auto">
<Wand2 className="w-3.5 h-3.5" /> Click the image to pick a key color
</span>
)}
</div>
{/* Row 2 — Matte */}
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary mb-2">Matte</p>
<div className="flex flex-wrap gap-6">
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Tolerance</p>
<span className="text-xs font-mono text-text-tertiary">{tolerance}%</span>
</div>
<input type="range" min="0" max="100" value={tolerance} onChange={(e) => setTolerance(Number(e.target.value))} className="w-full accent-primary" />
</div>
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Sat. Gate</p>
<span className="text-xs font-mono text-text-tertiary">{satMin}%</span>
</div>
<input type="range" min="0" max="100" value={satMin} onChange={(e) => setSatMin(Number(e.target.value))} className="w-full accent-primary" />
<p className="text-[10px] text-text-tertiary leading-tight">Skip pixels below this saturation preserves neutral tones</p>
</div>
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Edge Feather</p>
<span className="text-xs font-mono text-text-tertiary">{feather}%</span>
</div>
<input type="range" min="0" max="50" value={feather} onChange={(e) => setFeather(Number(e.target.value))} className="w-full accent-primary" />
</div>
</div>
</div>
{/* Row 3 — Cleanup */}
<div>
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary mb-2">Cleanup</p>
<div className="flex flex-wrap gap-6">
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Despill</p>
<span className="text-xs font-mono text-text-tertiary">{spillStr}%</span>
</div>
<input type="range" min="0" max="100" value={spillStr} onChange={(e) => setSpillStr(Number(e.target.value))} className="w-full accent-primary" />
<p className="text-[10px] text-text-tertiary leading-tight">Suppress key-color fringing on subject edges</p>
</div>
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Halo Remove</p>
<span className="text-xs font-mono text-text-tertiary">{haloStr}%</span>
</div>
<input type="range" min="0" max="100" value={haloStr} onChange={(e) => setHaloStr(Number(e.target.value))} className="w-full accent-primary" />
<p className="text-[10px] text-text-tertiary leading-tight">Erode the matte inward to eliminate bright rim pixels</p>
</div>
<div className="space-y-1.5 flex-1 min-w-[130px]">
<div className="flex justify-between">
<p className="text-xs font-medium text-text-secondary">Halo Radius</p>
<span className="text-xs font-mono text-text-tertiary">{haloRadius} px</span>
</div>
<input type="range" min="1" max="8" step="1" value={haloRadius} onChange={(e) => setHaloRadius(Number(e.target.value))} className="w-full accent-primary" disabled={haloStr === 0} />
<p className="text-[10px] text-text-tertiary leading-tight">How far to look for transparent neighbours</p>
</div>
</div>
</div>
</div>
{/* Side-by-side view */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Original click to pick key color
</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
<canvas ref={sourceCanvasRef} className="w-full cursor-crosshair block" onClick={handleCanvasClick} title="Click a pixel to set it as the chroma key color" />
</div>
</div>
<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 transparent background
</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", !hasResult && "hidden")}>
<canvas ref={glCanvasRef} className="w-full block" />
</div>
{!hasResult && (
<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">
{imageReady ? "Click the image on the left to pick a key color" : "Loading image…"}
</span>
</div>
)}
</div>
</div>
</div>
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// ModeToggle
// ---------------------------------------------------------------------------
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
return (
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium shrink-0">
<button
onClick={() => onChange("chroma")}
className={cn(
"px-3 py-1.5 transition-colors",
mode === "chroma" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
)}
>
Chroma Key
</button>
<button
onClick={() => onChange("ai")}
className={cn(
"px-3 py-1.5 transition-colors border-l border-border",
mode === "ai" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
)}
>
AI Remove
</button>
</div>
);
}

View File

@@ -0,0 +1,509 @@
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>
);
}

View File

@@ -0,0 +1,653 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Upload, Download, X, ImageIcon, Crosshair, 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",
};
// Extra space around the output rect in the editor so you can see the image
// when it extends (or will be panned) outside the output boundaries.
const PAD = 120;
const HANDLE_HIT = 8;
const MIN_CROP = 4;
// ---------------------------------------------------------------------------
// Module-level checkerboard tile cache
// Avoids recreating a canvas element on every drawEditor call.
// ---------------------------------------------------------------------------
let _checkerTile: HTMLCanvasElement | null = null;
function checkerTile(): HTMLCanvasElement {
if (_checkerTile) return _checkerTile;
const c = document.createElement("canvas");
c.width = 16; c.height = 16;
const ctx = c.getContext("2d")!;
ctx.fillStyle = "#2a2a2a"; ctx.fillRect(0, 0, 16, 16);
ctx.fillStyle = "#3d3d3d"; ctx.fillRect(0, 0, 8, 8); ctx.fillRect(8, 8, 8, 8);
_checkerTile = c;
return c;
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
// "pan" = drag inside output rect to move the image
type DragHandle =
| "nw" | "n" | "ne"
| "w" | "e"
| "sw" | "s" | "se"
| "pan";
type DragState = {
handle: DragHandle;
startX: number;
startY: number;
origW: number;
origH: number;
origImgX: number;
origImgY: number;
};
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
function getHandlePoints(
cx0: number, cy0: number, cx1: number, cy1: number
): [DragHandle, number, number][] {
const mx = (cx0 + cx1) / 2;
const my = (cy0 + cy1) / 2;
return [
["nw", cx0, cy0], ["n", mx, cy0], ["ne", cx1, cy0],
["w", cx0, my], ["e", cx1, my],
["sw", cx0, cy1], ["s", mx, cy1], ["se", cx1, cy1],
];
}
function hitHandle(
px: number, py: number,
cx0: number, cy0: number, cx1: number, cy1: number
): DragHandle | null {
for (const [name, hx, hy] of getHandlePoints(cx0, cy0, cx1, cy1)) {
if (Math.abs(px - hx) <= HANDLE_HIT && Math.abs(py - hy) <= HANDLE_HIT) {
return name;
}
}
if (px >= cx0 && px <= cx1 && py >= cy0 && py <= cy1) return "pan";
return null;
}
function handleCursor(h: DragHandle | null, dragging = false): string {
if (!h) return "default";
if (h === "pan") return dragging ? "grabbing" : "grab";
if (h === "nw" || h === "se") return "nwse-resize";
if (h === "ne" || h === "sw") return "nesw-resize";
if (h === "n" || h === "s") return "ns-resize";
return "ew-resize";
}
// ---------------------------------------------------------------------------
// drawEditor
//
// Mental model: the output canvas (cropW × cropH) is pinned at (PAD, PAD).
// The source image sits at (PAD + imgX, PAD + imgY) and can extend outside
// the output in any direction. Areas inside the output not covered by the
// image are transparent (shown as checkerboard).
// ---------------------------------------------------------------------------
function drawEditor(
canvas: HTMLCanvasElement,
img: HTMLImageElement,
imgW: number, imgH: number,
cropW: number, cropH: number,
imgX: number, imgY: number,
) {
const cW = Math.max(cropW, imgW) + 2 * PAD;
const cH = Math.max(cropH, imgH) + 2 * PAD;
if (canvas.width !== cW || canvas.height !== cH) {
canvas.width = cW;
canvas.height = cH;
}
const ctx = canvas.getContext("2d")!;
// 1. Checkerboard background — use cached tile to avoid createElement every frame
ctx.fillStyle = ctx.createPattern(checkerTile(), "repeat")!;
ctx.fillRect(0, 0, cW, cH);
// 2. Faint image boundary indicator (dashed border around full source image)
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.2)";
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(PAD + imgX + 0.5, PAD + imgY + 0.5, imgW - 1, imgH - 1);
ctx.setLineDash([]);
ctx.restore();
// 3. Draw source image
ctx.drawImage(img, PAD + imgX, PAD + imgY);
// 4. Dark overlay outside the output rect
const ox0 = PAD, oy0 = PAD, ox1 = PAD + cropW, oy1 = PAD + cropH;
ctx.fillStyle = "rgba(0,0,0,0.55)";
ctx.fillRect(0, 0, cW, oy0); // top
ctx.fillRect(0, oy1, cW, cH - oy1); // bottom
ctx.fillRect(0, oy0, ox0, oy1 - oy0); // left
ctx.fillRect(ox1, oy0, cW - ox1, oy1 - oy0); // right
// 5. Rule-of-thirds grid
ctx.strokeStyle = "rgba(255,255,255,0.18)";
ctx.lineWidth = 0.75;
for (const t of [1 / 3, 2 / 3]) {
ctx.beginPath(); ctx.moveTo(ox0 + cropW * t, oy0); ctx.lineTo(ox0 + cropW * t, oy1); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ox0, oy0 + cropH * t); ctx.lineTo(ox1, oy0 + cropH * t); ctx.stroke();
}
// 6. Output rect border
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(ox0 + 0.5, oy0 + 0.5, cropW - 1, cropH - 1);
ctx.setLineDash([]);
// 7. Resize handles
ctx.fillStyle = "#fff";
ctx.strokeStyle = "#555";
ctx.lineWidth = 1;
for (const [, hx, hy] of getHandlePoints(ox0, oy0, ox1, oy1)) {
ctx.beginPath();
ctx.rect(hx - 4, hy - 4, 8, 8);
ctx.fill();
ctx.stroke();
}
}
// ---------------------------------------------------------------------------
// CropTool
// ---------------------------------------------------------------------------
export function CropTool() {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imgW, setImgW] = useState(0);
const [imgH, setImgH] = useState(0);
const [imageReady, setImageReady] = useState(false);
const [dragOver, setDragOver] = useState(false);
// Output canvas dimensions
const [cropW, setCropW] = useState(128);
const [cropH, setCropH] = useState(128);
// Where the source image's top-left sits within the output canvas.
const [imgX, setImgX] = useState(0);
const [imgY, setImgY] = useState(0);
// Padding used by "Fit to Content"
const [padding, setPadding] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const displayCanvasRef = useRef<HTMLCanvasElement>(null);
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
// Direct canvas preview — avoids toDataURL on every drag frame
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const imgElRef = useRef<HTMLImageElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
// Always-fresh values for use inside stable callbacks
const outputRef = useRef({ w: cropW, h: cropH, imgX, imgY, padding });
useEffect(() => { outputRef.current = { w: cropW, h: cropH, imgX, imgY, padding }; });
// ── Load image onto hidden source canvas ────────────────────────────────────
useEffect(() => {
if (!imageSrc) return;
setImageReady(false);
const img = new Image();
img.onload = () => {
const src = sourceCanvasRef.current!;
src.width = img.naturalWidth;
src.height = img.naturalHeight;
src.getContext("2d")!.drawImage(img, 0, 0);
imgElRef.current = img;
setImgW(img.naturalWidth);
setImgH(img.naturalHeight);
setCropW(img.naturalWidth);
setCropH(img.naturalHeight);
setImgX(0);
setImgY(0);
setImageReady(true);
};
img.src = imageSrc;
}, [imageSrc]);
// ── Redraw editor whenever state changes ─────────────────────────────────────
useEffect(() => {
if (!imageReady || !displayCanvasRef.current || !imgElRef.current) return;
drawEditor(displayCanvasRef.current, imgElRef.current, imgW, imgH, cropW, cropH, imgX, imgY);
}, [imageReady, imgW, imgH, cropW, cropH, imgX, imgY]);
// ── Live preview — draw directly into previewCanvasRef (no toDataURL) ────────
useEffect(() => {
if (!imageReady || !previewCanvasRef.current) return;
const src = sourceCanvasRef.current!;
const prev = previewCanvasRef.current;
prev.width = Math.max(1, Math.round(cropW));
prev.height = Math.max(1, Math.round(cropH));
// Clear to transparent, then composite the cropped region
const ctx = prev.getContext("2d")!;
ctx.clearRect(0, 0, prev.width, prev.height);
ctx.drawImage(src, Math.round(imgX), Math.round(imgY));
}, [imageReady, cropW, cropH, imgX, imgY]);
// ── Auto-center ──────────────────────────────────────────────────────────────
const autoCenter = useCallback(() => {
if (!imageReady) return;
const src = sourceCanvasRef.current!;
const { data, width, height } = src.getContext("2d")!.getImageData(0, 0, src.width, src.height);
let minX = width, minY = height, maxX = -1, maxY = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (data[(y * width + x) * 4 + 3] > 0) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX === -1) return;
const contentCX = (minX + maxX) / 2;
const contentCY = (minY + maxY) / 2;
const { w, h } = outputRef.current;
setImgX(Math.round(w / 2 - contentCX));
setImgY(Math.round(h / 2 - contentCY));
}, [imageReady]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Fit to content ───────────────────────────────────────────────────────────
const fitToContent = useCallback(() => {
if (!imageReady) return;
const src = sourceCanvasRef.current!;
const { data, width, height } = src.getContext("2d")!.getImageData(0, 0, src.width, src.height);
let minX = width, minY = height, maxX = -1, maxY = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (data[(y * width + x) * 4 + 3] > 0) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX === -1) return;
const pad = outputRef.current.padding;
const contentW = maxX - minX + 1;
const contentH = maxY - minY + 1;
setCropW(contentW + 2 * pad);
setCropH(contentH + 2 * pad);
setImgX(pad - minX);
setImgY(pad - minY);
}, [imageReady]); // eslint-disable-line react-hooks/exhaustive-deps
// ── File loading ─────────────────────────────────────────────────────────────
const loadFile = useCallback((file: File) => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(URL.createObjectURL(file));
setImageFile(file);
}, [imageSrc]);
const clearAll = useCallback(() => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(null);
setImageFile(null);
setImageReady(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [imageSrc]);
// 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(/\.[^.]+$/, "") + "_cropped.png";
a.href = url;
a.click();
URL.revokeObjectURL(url);
}, "image/png");
};
// ── Canvas interaction ───────────────────────────────────────────────────────
const getCanvasXY = (e: React.MouseEvent<HTMLCanvasElement>): [number, number] => {
const canvas = displayCanvasRef.current!;
const rect = canvas.getBoundingClientRect();
return [
(e.clientX - rect.left) * (canvas.width / rect.width),
(e.clientY - rect.top) * (canvas.height / rect.height),
];
};
const onMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const [px, py] = getCanvasXY(e);
const { w, h, imgX: ix, imgY: iy } = outputRef.current;
const handle = hitHandle(px, py, PAD, PAD, PAD + w, PAD + h);
if (!handle) return;
dragStateRef.current = {
handle,
startX: px, startY: py,
origW: w, origH: h,
origImgX: ix, origImgY: iy,
};
if (displayCanvasRef.current) {
displayCanvasRef.current.style.cursor = handleCursor(handle, true);
}
e.preventDefault();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = displayCanvasRef.current;
if (!canvas) return;
const [px, py] = getCanvasXY(e);
if (!dragStateRef.current) {
const { w, h } = outputRef.current;
canvas.style.cursor = handleCursor(hitHandle(px, py, PAD, PAD, PAD + w, PAD + h));
return;
}
const { handle, startX, startY, origW, origH, origImgX, origImgY } = dragStateRef.current;
const dx = Math.round(px - startX);
const dy = Math.round(py - startY);
let nw = origW, nh = origH, nix = origImgX, niy = origImgY;
if (handle === "pan") {
nix = origImgX + dx;
niy = origImgY + dy;
} else {
if (handle.includes("e")) nw = Math.max(MIN_CROP, origW + dx);
if (handle.includes("s")) nh = Math.max(MIN_CROP, origH + dy);
if (handle.includes("w")) {
const d = Math.min(dx, origW - MIN_CROP);
nw = origW - d;
nix = origImgX - d;
}
if (handle.includes("n")) {
const d = Math.min(dy, origH - MIN_CROP);
nh = origH - d;
niy = origImgY - d;
}
}
setCropW(nw);
setCropH(nh);
setImgX(nix);
setImgY(niy);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const onMouseUp = useCallback(() => {
dragStateRef.current = null;
if (displayCanvasRef.current) displayCanvasRef.current.style.cursor = "default";
}, []);
const parseIntSafe = (v: string, fallback: number) => {
const n = parseInt(v, 10);
return isNaN(n) ? fallback : n;
};
// ── 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">Crop Tool</h2>
<p className="text-sm text-text-secondary">
Upload an image, then define an output canvas. Drag inside the canvas
to pan the image within it, or drag the edge handles to resize. The
output canvas can extend beyond the image to add transparent padding.
</p>
</div>
<div
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f?.type.startsWith("image/")) loadFile(f);
}}
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 ────────────────────────────────────────────────────────────
return (
<div className="space-y-4 pb-8">
<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">Crop Tool</h2>
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
{imageFile?.name}
</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-3">
<div className="flex flex-wrap gap-4 items-end">
<label className="space-y-1.5">
<span className="text-xs font-medium text-text-secondary block">Output W (px)</span>
<input
type="number"
min={MIN_CROP}
value={cropW}
onChange={(e) => setCropW(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
className={cn(
"w-24 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",
)}
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-text-secondary block">Output H (px)</span>
<input
type="number"
min={MIN_CROP}
value={cropH}
onChange={(e) => setCropH(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
className={cn(
"w-24 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",
)}
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-text-secondary block">Image X</span>
<input
type="number"
value={imgX}
onChange={(e) => setImgX(parseIntSafe(e.target.value, 0))}
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",
)}
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-text-secondary block">Image Y</span>
<input
type="number"
value={imgY}
onChange={(e) => setImgY(parseIntSafe(e.target.value, 0))}
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",
)}
/>
</label>
<span className="text-xs text-text-tertiary self-center font-mono pb-1">
src: {imgW} × {imgH}
</span>
</div>
<div className="flex flex-wrap items-center gap-3 pt-1 border-t border-border">
<label className="space-y-1.5">
<span className="text-xs font-medium text-text-secondary block">Padding (px)</span>
<input
type="number"
min={0}
value={padding}
onChange={(e) => setPadding(Math.max(0, parseIntSafe(e.target.value, 0)))}
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",
)}
/>
</label>
<div className="flex gap-2 self-end pb-0.5">
<button
onClick={autoCenter}
className={cn(
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
)}
title="Pan the image so non-transparent content is centered within the current output canvas"
>
<Crosshair className="w-3.5 h-3.5" /> Auto-center
</button>
<button
onClick={fitToContent}
className={cn(
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
)}
title="Resize output to the non-transparent content bounding box + padding, then center"
>
<Maximize2 className="w-3.5 h-3.5" /> Fit to Content
</button>
</div>
<p className="text-xs text-text-tertiary self-end pb-1 ml-auto">
Drag inside the canvas to pan · drag handles to resize
</p>
</div>
</div>
{/* Editor + Preview */}
<div className="grid grid-cols-[1fr_260px] gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Editor
</p>
<div
className="bg-card border border-border rounded-lg overflow-auto"
style={{ maxHeight: "62vh" }}
>
<canvas
ref={displayCanvasRef}
className="block"
style={{ maxWidth: "100%" }}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
/>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Preview
</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
{imageReady ? (
<div style={CHECKERBOARD}>
<canvas
ref={previewCanvasRef}
className="w-full block"
style={{ imageRendering: (cropW < 64 || cropH < 64) ? "pixelated" : "auto" }}
/>
</div>
) : (
<div className="aspect-square flex items-center justify-center">
<ImageIcon className="w-10 h-10 text-text-tertiary opacity-20" />
</div>
)}
</div>
{imageReady && (
<p className="text-xs text-text-tertiary text-center font-mono">
{Math.round(cropW)} × {Math.round(cropH)} px
</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,470 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Upload, Download, X, ImageIcon } 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",
};
// ---------------------------------------------------------------------------
// WebGL — shaders + init
// ---------------------------------------------------------------------------
const VERT = `
attribute vec2 aPos;
varying vec2 vUV;
void main() {
vUV = aPos * 0.5 + 0.5;
vUV.y = 1.0 - vUV.y;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
// RGB↔HSL math in GLSL — runs massively parallel on GPU
const FRAG = `
precision mediump float;
uniform sampler2D uImage;
uniform float uHueShift;
uniform float uSaturation;
uniform float uLightness;
varying vec2 vUV;
vec3 rgb2hsl(vec3 c) {
float maxC = max(c.r, max(c.g, c.b));
float minC = min(c.r, min(c.g, c.b));
float l = (maxC + minC) * 0.5;
float d = maxC - minC;
if (d < 0.001) return vec3(0.0, 0.0, l);
float s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC);
float h;
if (maxC == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
else if (maxC == c.g) h = (c.b - c.r) / d + 2.0;
else h = (c.r - c.g) / d + 4.0;
return vec3(h / 6.0, s, l);
}
float hue2rgb(float p, float q, float t) {
if (t < 0.0) t += 1.0;
if (t > 1.0) t -= 1.0;
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
if (t < 0.5) return q;
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
return p;
}
vec3 hsl2rgb(vec3 hsl) {
float h = hsl.x, s = hsl.y, l = hsl.z;
if (s < 0.001) return vec3(l);
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
float p = 2.0 * l - q;
return vec3(
hue2rgb(p, q, h + 1.0 / 3.0),
hue2rgb(p, q, h),
hue2rgb(p, q, h - 1.0 / 3.0)
);
}
void main() {
vec4 c = texture2D(uImage, vUV);
if (c.a < 0.001) { gl_FragColor = c; return; }
vec3 hsl = rgb2hsl(c.rgb);
hsl.x = fract(hsl.x + uHueShift + 1.0);
hsl.y = clamp(hsl.y + uSaturation, 0.0, 1.0);
hsl.z = clamp(hsl.z + uLightness, 0.0, 1.0);
gl_FragColor = vec4(hsl2rgb(hsl), c.a);
}`;
type GlState = {
gl: WebGLRenderingContext;
tex: WebGLTexture;
uHueShift: WebGLUniformLocation;
uSaturation: WebGLUniformLocation;
uLightness: WebGLUniformLocation;
};
function initGl(canvas: HTMLCanvasElement): GlState | null {
const gl = canvas.getContext("webgl", {
premultipliedAlpha: false,
preserveDrawingBuffer: true,
}) as WebGLRenderingContext | null;
if (!gl) return null;
const vert = gl.createShader(gl.VERTEX_SHADER)!;
gl.shaderSource(vert, VERT);
gl.compileShader(vert);
const frag = gl.createShader(gl.FRAGMENT_SHADER)!;
gl.shaderSource(frag, FRAG);
gl.compileShader(frag);
const prog = gl.createProgram()!;
gl.attachShader(prog, vert);
gl.attachShader(prog, frag);
gl.linkProgram(prog);
gl.useProgram(prog);
const buf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
);
const aPos = gl.getAttribLocation(prog, "aPos");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return {
gl,
tex,
uHueShift: gl.getUniformLocation(prog, "uHueShift")!,
uSaturation: gl.getUniformLocation(prog, "uSaturation")!,
uLightness: gl.getUniformLocation(prog, "uLightness")!,
};
}
// ---------------------------------------------------------------------------
// Slider
// ---------------------------------------------------------------------------
function Slider({
label, value, min, max, unit, onChange, gradient,
}: {
label: string;
value: number;
min: number;
max: number;
unit: string;
onChange: (v: number) => void;
gradient?: string;
}) {
return (
<div className="space-y-2">
<div className="flex justify-between items-baseline">
<p className="text-xs font-medium text-text-secondary">{label}</p>
<span className="text-xs font-mono text-text-tertiary tabular-nums w-16 text-right">
{`${value > 0 ? "+" : ""}${value}${unit}`}
</span>
</div>
<div className="relative h-5 flex items-center">
{gradient && (
<div
className="absolute inset-x-0 h-2 rounded-full pointer-events-none"
style={{ background: gradient }}
/>
)}
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className={cn(
"relative z-10 w-full appearance-none bg-transparent cursor-pointer",
"[&::-webkit-slider-thumb]:appearance-none",
"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4",
"[&::-webkit-slider-thumb]:-mt-1",
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white",
"[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-border",
"[&::-webkit-slider-thumb]:shadow-sm",
gradient
? "[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent"
: "accent-primary",
)}
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// HueShifter
// ---------------------------------------------------------------------------
export function HueShifter() {
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 [hueShift, setHueShift] = useState(0);
const [saturation, setSaturation] = useState(0);
const [lightness, setLightness] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// glCanvasRef — WebGL result canvas (also the preview)
const glCanvasRef = useRef<HTMLCanvasElement>(null);
const glRef = useRef<GlState | null>(null);
// ── Load image → upload texture once ──────────────────────────────────────
useEffect(() => {
if (!imageSrc) return;
setImageReady(false);
const img = new Image();
img.onload = () => {
const glCanvas = glCanvasRef.current;
if (!glCanvas) return;
glCanvas.width = img.naturalWidth;
glCanvas.height = img.naturalHeight;
if (!glRef.current) {
glRef.current = initGl(glCanvas);
if (!glRef.current) {
console.error("HueShifter: WebGL not supported");
return;
}
}
const { gl, tex } = glRef.current;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
setImageReady(true);
};
img.src = imageSrc;
}, [imageSrc]);
// ── Re-render on GPU whenever sliders change ───────────────────────────────
useEffect(() => {
const state = glRef.current;
if (!state || !imageReady) return;
const { gl, uHueShift, uSaturation, uLightness } = state;
gl.uniform1f(uHueShift, hueShift / 360);
gl.uniform1f(uSaturation, saturation / 100);
gl.uniform1f(uLightness, lightness / 100);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}, [imageReady, hueShift, saturation, lightness]);
// ── Helpers ────────────────────────────────────────────────────────────────
const loadFile = useCallback((file: File) => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(URL.createObjectURL(file));
setImageFile(file);
setHueShift(0);
setSaturation(0);
setLightness(0);
}, [imageSrc]);
const clearAll = useCallback(() => {
if (imageSrc) URL.revokeObjectURL(imageSrc);
setImageSrc(null);
setImageFile(null);
setImageReady(false);
glRef.current = null;
if (fileInputRef.current) fileInputRef.current.value = "";
}, [imageSrc]);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file?.type.startsWith("image/")) loadFile(file);
};
const handleDownload = () => {
const glCanvas = glCanvasRef.current;
if (!glCanvas || !imageFile || !imageReady) return;
glCanvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_recolored.png";
a.href = url;
a.click();
URL.revokeObjectURL(url);
}, "image/png");
};
const handleReset = () => {
setHueShift(0);
setSaturation(0);
setLightness(0);
};
const isDefault = hueShift === 0 && saturation === 0 && lightness === 0;
// ── 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">Hue Shifter</h2>
<p className="text-sm text-text-secondary">
Upload an image and shift its hue, saturation, and lightness to create colour
variants. Fully transparent pixels are preserved.
</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 ──────────────────────────────────────────────────────────
return (
<div className="space-y-4 pb-8">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-foreground flex-1">Hue Shifter</h2>
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
{imageFile?.name}
</span>
<button
onClick={handleReset}
disabled={isDefault}
className={cn(
"px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary transition-colors",
isDefault
? "opacity-40 cursor-not-allowed"
: "hover:text-foreground hover:border-primary/40",
)}
>
Reset
</button>
<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 || isDefault}
className={cn(
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
imageReady && !isDefault
? "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-5 space-y-5">
<Slider
label="Hue Shift"
value={hueShift}
min={-180}
max={180}
unit="°"
onChange={setHueShift}
gradient="linear-gradient(to right, hsl(0,80%,55%), hsl(60,80%,55%), hsl(120,80%,55%), hsl(180,80%,55%), hsl(240,80%,55%), hsl(300,80%,55%), hsl(360,80%,55%))"
/>
<Slider
label="Saturation"
value={saturation}
min={-100}
max={100}
unit="%"
onChange={setSaturation}
gradient="linear-gradient(to right, hsl(210,0%,50%), hsl(210,0%,55%) 50%, hsl(210,90%,55%))"
/>
<Slider
label="Lightness"
value={lightness}
min={-100}
max={100}
unit="%"
onChange={setLightness}
gradient="linear-gradient(to right, hsl(0,0%,10%), hsl(0,0%,50%) 50%, hsl(0,0%,92%))"
/>
</div>
{/* Side-by-side */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Original
</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
<div style={CHECKERBOARD}>
<img src={imageSrc} alt="Original" className="w-full block" />
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
Result
</p>
<div className="bg-card border border-border rounded-lg overflow-hidden">
{/* WebGL canvas always in DOM; hidden until image is ready */}
<div style={CHECKERBOARD}>
<canvas
ref={glCanvasRef}
className={cn("w-full block", !imageReady && "hidden")}
/>
{!imageReady && (
<div className="aspect-square flex items-center justify-center">
<ImageIcon className="w-10 h-10 opacity-20 text-white" />
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,18 @@ import {
Package,
AlertTriangle,
Sparkles,
Scissors,
Crop,
Palette,
Maximize2,
} from "lucide-react";
import { cn } from "../lib/utils";
import { useItems, type Item } from "../lib/useItems";
import { ItemStudio } from "./ItemStudio";
import { BackgroundRemoval } from "./BackgroundRemoval";
import { CropTool } from "./CropTool";
import { HueShifter } from "./HueShifter";
import { CanvasTool } from "./CanvasTool";
// ---------------------------------------------------------------------------
// Helpers
@@ -26,7 +35,7 @@ const RARITY_COLORS: Record<string, string> = {
SSR: "bg-amber-500/20 text-amber-400",
};
type Tab = "all" | "studio";
type Tab = "all" | "studio" | "bgremoval" | "crop" | "hue" | "canvas";
// ---------------------------------------------------------------------------
// SearchFilterBar
@@ -383,6 +392,7 @@ export default function Items() {
setPage,
loading,
error,
refetch,
} = useItems();
const [activeTab, setActiveTab] = useState<Tab>("all");
@@ -436,6 +446,54 @@ export default function Items() {
<Sparkles className="w-4 h-4" />
Item Studio
</button>
<button
onClick={() => setActiveTab("bgremoval")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "bgremoval"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Scissors className="w-4 h-4" />
Background Removal
</button>
<button
onClick={() => setActiveTab("crop")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "crop"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Crop className="w-4 h-4" />
Crop
</button>
<button
onClick={() => setActiveTab("hue")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "hue"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Palette className="w-4 h-4" />
Hue Shifter
</button>
<button
onClick={() => setActiveTab("canvas")}
className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "canvas"
? "border-primary text-primary"
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
)}
>
<Maximize2 className="w-4 h-4" />
Canvas
</button>
</div>
</header>
@@ -475,17 +533,21 @@ export default function Items() {
/>
)}
</div>
) : activeTab === "studio" ? (
<ItemStudio
onSuccess={() => {
refetch();
setActiveTab("all");
}}
/>
) : activeTab === "bgremoval" ? (
<BackgroundRemoval />
) : activeTab === "crop" ? (
<CropTool />
) : activeTab === "hue" ? (
<HueShifter />
) : (
<div className="flex flex-col items-center justify-center py-24 text-center">
<Sparkles className="w-16 h-16 text-text-tertiary mb-4" />
<h2 className="text-lg font-semibold text-text-secondary mb-2">
Item Studio
</h2>
<p className="text-sm text-text-tertiary max-w-md">
AI-assisted item editor coming soon. Create and customize items with
generated icons, balanced stats, and lore descriptions.
</p>
</div>
<CanvasTool />
)}
</div>
</div>