326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import ToolLayout from "@/components/ToolLayout";
|
|
|
|
interface RedactBox {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
style: "blackout" | "pixelate";
|
|
}
|
|
|
|
export default function ImageRedactorPage() {
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [boxes, setBoxes] = useState<RedactBox[]>([]);
|
|
const [mode, setMode] = useState<"blackout" | "pixelate">("blackout");
|
|
const [isDrawing, setIsDrawing] = useState(false);
|
|
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null);
|
|
const [currentBox, setCurrentBox] = useState<RedactBox | null>(null);
|
|
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const imageObjRef = useRef<HTMLImageElement | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const renderCanvas = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
const img = imageObjRef.current;
|
|
if (!canvas || !img) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
// Draw base image
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
|
|
// Helper to draw box
|
|
const drawSingleBox = (box: RedactBox) => {
|
|
const rx = Math.min(box.x, box.x + box.w);
|
|
const ry = Math.min(box.y, box.y + box.h);
|
|
const rw = Math.abs(box.w);
|
|
const rh = Math.abs(box.h);
|
|
|
|
if (rw === 0 || rh === 0) return;
|
|
|
|
if (box.style === "blackout") {
|
|
ctx.fillStyle = "#000000";
|
|
ctx.fillRect(rx, ry, rw, rh);
|
|
} else {
|
|
// Pixelate
|
|
try {
|
|
const pixelSize = Math.max(8, Math.round(rw / 10));
|
|
const subW = Math.max(1, Math.round(rw / pixelSize));
|
|
const subH = Math.max(1, Math.round(rh / pixelSize));
|
|
|
|
const offCanvas = document.createElement("canvas");
|
|
offCanvas.width = subW;
|
|
offCanvas.height = subH;
|
|
const offCtx = offCanvas.getContext("2d");
|
|
if (offCtx) {
|
|
offCtx.drawImage(canvas, rx, ry, rw, rh, 0, 0, subW, subH);
|
|
ctx.imageSmoothingEnabled = false;
|
|
ctx.drawImage(offCanvas, 0, 0, subW, subH, rx, ry, rw, rh);
|
|
ctx.imageSmoothingEnabled = true;
|
|
}
|
|
} catch {
|
|
ctx.fillStyle = "#111111";
|
|
ctx.fillRect(rx, ry, rw, rh);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Draw committed boxes
|
|
boxes.forEach(drawSingleBox);
|
|
|
|
// Draw preview box while dragging
|
|
if (currentBox) {
|
|
drawSingleBox(currentBox);
|
|
// Border outline for visibility
|
|
const rx = Math.min(currentBox.x, currentBox.x + currentBox.w);
|
|
const ry = Math.min(currentBox.y, currentBox.y + currentBox.h);
|
|
const rw = Math.abs(currentBox.w);
|
|
const rh = Math.abs(currentBox.h);
|
|
ctx.strokeStyle = "#3b82f6";
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(rx, ry, rw, rh);
|
|
}
|
|
}, [boxes, currentBox]);
|
|
|
|
useEffect(() => {
|
|
renderCanvas();
|
|
}, [renderCanvas]);
|
|
|
|
const handleFileSelect = (selected: File) => {
|
|
if (!selected.type.startsWith("image/")) {
|
|
alert("Please upload a valid image file.");
|
|
return;
|
|
}
|
|
setFile(selected);
|
|
setBoxes([]);
|
|
setCurrentBox(null);
|
|
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(selected);
|
|
img.src = url;
|
|
img.onload = () => {
|
|
imageObjRef.current = img;
|
|
if (canvasRef.current) {
|
|
canvasRef.current.width = img.naturalWidth;
|
|
canvasRef.current.height = img.naturalHeight;
|
|
}
|
|
renderCanvas();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
};
|
|
|
|
const getCanvasCoords = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return { x: 0, y: 0 };
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
return {
|
|
x: (e.clientX - rect.left) * scaleX,
|
|
y: (e.clientY - rect.top) * scaleY,
|
|
};
|
|
};
|
|
|
|
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
if (!imageObjRef.current) return;
|
|
const coords = getCanvasCoords(e);
|
|
setIsDrawing(true);
|
|
setStartPos(coords);
|
|
setCurrentBox({
|
|
x: coords.x,
|
|
y: coords.y,
|
|
w: 0,
|
|
h: 0,
|
|
style: mode,
|
|
});
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
|
if (!isDrawing || !startPos) return;
|
|
const coords = getCanvasCoords(e);
|
|
setCurrentBox({
|
|
x: startPos.x,
|
|
y: startPos.y,
|
|
w: coords.x - startPos.x,
|
|
h: coords.y - startPos.y,
|
|
style: mode,
|
|
});
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (!isDrawing || !currentBox) return;
|
|
setIsDrawing(false);
|
|
if (Math.abs(currentBox.w) > 5 && Math.abs(currentBox.h) > 5) {
|
|
setBoxes((prev) => [...prev, currentBox]);
|
|
}
|
|
setCurrentBox(null);
|
|
setStartPos(null);
|
|
};
|
|
|
|
const handleUndo = () => {
|
|
setBoxes((prev) => prev.slice(0, -1));
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setBoxes([]);
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas || !file) return;
|
|
const cleanUrl = canvas.toDataURL("image/png");
|
|
const a = document.createElement("a");
|
|
a.href = cleanUrl;
|
|
const baseName = file.name.substring(0, file.name.lastIndexOf(".")) || file.name;
|
|
a.download = `${baseName}-redacted.png`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
};
|
|
|
|
return (
|
|
<ToolLayout
|
|
title="Private Document & Image Redactor"
|
|
description="Draw solid blackout or pixelate boxes over sensitive names, credit card numbers, faces, and addresses before sharing. 100% offline."
|
|
>
|
|
<div className="space-y-6">
|
|
{/* Controls */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3 pb-2 border-b border-[#1a1a1a]">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setMode("blackout")}
|
|
className={`px-3 py-1.5 text-xs font-medium border transition-colors cursor-pointer ${
|
|
mode === "blackout"
|
|
? "bg-blue-600 border-blue-500 text-white"
|
|
: "bg-[#111] border-[#222] text-gray-400 hover:text-gray-200"
|
|
}`}
|
|
>
|
|
⬛ Blackout Box
|
|
</button>
|
|
<button
|
|
onClick={() => setMode("pixelate")}
|
|
className={`px-3 py-1.5 text-xs font-medium border transition-colors cursor-pointer ${
|
|
mode === "pixelate"
|
|
? "bg-blue-600 border-blue-500 text-white"
|
|
: "bg-[#111] border-[#222] text-gray-400 hover:text-gray-200"
|
|
}`}
|
|
>
|
|
🏁 Pixelate Blur
|
|
</button>
|
|
<button
|
|
onClick={handleUndo}
|
|
disabled={boxes.length === 0}
|
|
className="px-3 py-1.5 bg-[#111] border border-[#222] hover:bg-[#1a1a1a] disabled:opacity-40 text-gray-300 text-xs font-medium transition-colors cursor-pointer"
|
|
>
|
|
↩ Undo
|
|
</button>
|
|
<button
|
|
onClick={handleClear}
|
|
disabled={boxes.length === 0}
|
|
className="px-3 py-1.5 bg-[#111] border border-[#222] hover:bg-[#1a1a1a] disabled:opacity-40 text-red-400 text-xs font-medium transition-colors cursor-pointer"
|
|
>
|
|
Clear All
|
|
</button>
|
|
</div>
|
|
|
|
{file && (
|
|
<button
|
|
onClick={handleDownload}
|
|
className="px-4 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs font-semibold transition-colors cursor-pointer flex items-center gap-1.5"
|
|
>
|
|
<span>Download Redacted Image</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Upload Box */}
|
|
{!file && (
|
|
<div
|
|
onClick={() => fileInputRef.current?.click()}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
|
handleFileSelect(e.dataTransfer.files[0]);
|
|
}
|
|
}}
|
|
className="border-2 border-dashed border-[#222] hover:border-blue-600/50 bg-[#080808] p-8 text-center cursor-pointer transition-colors"
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
if (e.target.files && e.target.files[0]) {
|
|
handleFileSelect(e.target.files[0]);
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="w-10 h-10 rounded border border-[#222] bg-[#111] flex items-center justify-center text-gray-400">
|
|
🔒
|
|
</div>
|
|
<p className="text-sm font-medium text-gray-300">
|
|
Upload image to redact (screenshots, IDs, receipts, documents)
|
|
</p>
|
|
<p className="text-xs text-[#555]">Click and drag boxes to cover confidential sections</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Interactive Redaction Canvas */}
|
|
{file && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between text-xs text-[#777]">
|
|
<span>
|
|
Draw boxes over sensitive areas ({boxes.length} redactions applied)
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
setFile(null);
|
|
setBoxes([]);
|
|
}}
|
|
className="text-xs text-red-400 hover:text-red-300 cursor-pointer"
|
|
>
|
|
Change Image
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border border-[#1a1a1a] bg-[#050505] p-2 flex justify-center overflow-auto max-h-[650px]">
|
|
<canvas
|
|
ref={canvasRef}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
className="cursor-crosshair max-w-full h-auto object-contain border border-[#181818]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Security & Use Case Highlights */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 pt-4 border-t border-[#1a1a1a] text-xs text-[#666]">
|
|
<div className="border border-[#181818] p-3 bg-black">
|
|
<h4 className="text-gray-300 font-medium mb-1">Permanent Blackout</h4>
|
|
<p>Pixels are physically overwritten on the raw image canvas, making recovery impossible.</p>
|
|
</div>
|
|
<div className="border border-[#181818] p-3 bg-black">
|
|
<h4 className="text-gray-300 font-medium mb-1">Safe Sharing</h4>
|
|
<p>Ideal for redacting government IDs, social security numbers, banking details, or names.</p>
|
|
</div>
|
|
<div className="border border-[#181818] p-3 bg-black">
|
|
<h4 className="text-gray-300 font-medium mb-1">100% Offline</h4>
|
|
<p>Runs entirely inside your browser memory. No files are transmitted to any server.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ToolLayout>
|
|
);
|
|
}
|