import { useCallback, useRef, useState } from "react"; import type { EditAction } from "../../types.js"; export interface RedactRect { x: number; y: number; w: number; h: number; } interface Drawing { startX: number; startY: number; currentX: number; currentY: number; } export interface ApplyRedactResult { blob: Blob; actions: EditAction[]; } export interface RedactToolState { rects: RedactRect[]; drawing: Drawing | null; displaySize: { width: number; height: number }; handleImageLoad: (e: React.SyntheticEvent) => void; startDrawing: (x: number, y: number) => void; updateDrawing: (x: number, y: number) => void; finishDrawing: () => void; deleteRect: (index: number) => void; clearAll: () => void; applyRedact: () => Promise; } /** Manages state and logic for the blur-redaction tool. */ export function useRedactTool(imageUrl: string): RedactToolState { let [rects, setRects] = useState([]); let [drawing, setDrawing] = useState(null); let [displaySize, setDisplaySize] = useState({ width: 0, height: 0 }); let imageUrlRef = useRef(imageUrl); imageUrlRef.current = imageUrl; let handleImageLoad = useCallback((e: React.SyntheticEvent) => { let img = e.currentTarget; setDisplaySize({ width: img.clientWidth, height: img.clientHeight }); }, []); function startDrawing(x: number, y: number) { setDrawing({ startX: x, startY: y, currentX: x, currentY: y }); } function updateDrawing(x: number, y: number) { setDrawing((d) => (d ? { ...d, currentX: x, currentY: y } : null)); } function finishDrawing() { setDrawing((d) => { if (!d) return null; let x = Math.min(d.startX, d.currentX); let y = Math.min(d.startY, d.currentY); let w = Math.abs(d.currentX - d.startX); let h = Math.abs(d.currentY - d.startY); if (w > 4 && h > 4) { setRects((prev) => [...prev, { x, y, w, h }]); } return null; }); } function deleteRect(index: number) { setRects((prev) => prev.filter((_, i) => i !== index)); } function clearAll() { setRects([]); } async function applyRedact(): Promise { if (rects.length === 0) return null; let img = await loadImage(imageUrlRef.current); let canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; let ctx = canvas.getContext("2d")!; ctx.drawImage(img, 0, 0); let scaleX = img.naturalWidth / displaySize.width; let scaleY = img.naturalHeight / displaySize.height; for (let r of rects) { let nx = Math.round(r.x * scaleX); let ny = Math.round(r.y * scaleY); let nw = Math.round(r.w * scaleX); let nh = Math.round(r.h * scaleY); blurRegionOnCanvas(ctx, img, nx, ny, nw, nh); } let blob = await new Promise((resolve, reject) => { canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92); }); // Store natural-pixel coords so replay doesn't need display size. let actions: EditAction[] = rects.map((r) => ({ type: "redact-blur", params: { x: Math.round(r.x * scaleX), y: Math.round(r.y * scaleY), w: Math.round(r.w * scaleX), h: Math.round(r.h * scaleY), }, })); return { blob, actions }; } return { rects, drawing, displaySize, handleImageLoad, startDrawing, updateDrawing, finishDrawing, deleteRect, clearAll, applyRedact, }; } function loadImage(url: string): Promise { return new Promise((resolve, reject) => { let img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error("Failed to load image")); img.src = url; }); } /** Standalone replay handler for the "redact-blur" action type. */ export function replayRedactBlur(imageUrl: string, params: Record): Promise { let { x, y, w, h } = params as { x: number; y: number; w: number; h: number }; return new Promise((resolve, reject) => { let img = new Image(); img.onload = () => { let canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; let ctx = canvas.getContext("2d")!; ctx.drawImage(img, 0, 0); blurRegionOnCanvas(ctx, img, x, y, w, h); canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/jpeg", 0.92); }; img.onerror = () => reject(new Error("Failed to load image for redact replay")); img.src = imageUrl; }); } /** * Redacts a region by downscaling it to a tiny thumbnail then scaling back up. * This produces a strong pixelation effect that is cross-browser (ctx.filter * is unreliable in Firefox). */ function blurRegionOnCanvas( ctx: CanvasRenderingContext2D, img: HTMLImageElement, x: number, y: number, w: number, h: number, ) { if (w <= 0 || h <= 0) return; // Scale down to ~5% — each output "pixel" covers a 20×20 block of the original. let smallW = Math.max(1, Math.round(w / 20)); let smallH = Math.max(1, Math.round(h / 20)); let off = document.createElement("canvas"); off.width = smallW; off.height = smallH; let offCtx = off.getContext("2d")!; offCtx.drawImage(img, x, y, w, h, 0, 0, smallW, smallH); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "low"; ctx.drawImage(off, 0, 0, smallW, smallH, x, y, w, h); // Overlay a semi-opaque fill to prevent text from being readable through the pixelation. ctx.fillStyle = "rgba(128, 128, 128, 0.85)"; ctx.fillRect(x, y, w, h); } /** Normalises a drawing into a positional rect (min x/y, positive w/h). */ export function normRect(d: { startX: number; startY: number; currentX: number; currentY: number }) { return { x: Math.min(d.startX, d.currentX), y: Math.min(d.startY, d.currentY), w: Math.abs(d.currentX - d.startX), h: Math.abs(d.currentY - d.startY), }; }