forked from syntaxbullet/aurorabot
feat: add item creation tools
This commit is contained in:
@@ -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",
|
||||
|
||||
881
panel/src/pages/BackgroundRemoval.tsx
Normal file
881
panel/src/pages/BackgroundRemoval.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
509
panel/src/pages/CanvasTool.tsx
Normal file
509
panel/src/pages/CanvasTool.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
653
panel/src/pages/CropTool.tsx
Normal file
653
panel/src/pages/CropTool.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
470
panel/src/pages/HueShifter.tsx
Normal file
470
panel/src/pages/HueShifter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1588
panel/src/pages/ItemStudio.tsx
Normal file
1588
panel/src/pages/ItemStudio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user