import { useCallback, useEffect, useRef, useState } from "react"; import type { CropCanvasHandle } from "../../CropCanvas.js"; import type { EditAction } from "../../types.js"; import { fullImageQuad, type Point, type Quad, useEdgeDetection } from "../../useEdgeDetection.js"; import { loadOpenCV, useOpenCV } from "../../useOpenCV.js"; export interface ApplyCropResult { blob: Blob; actions: EditAction[]; } /** * `corners` — drag the four corner/edge handles of the current selection. * `rect` — drag out a brand new axis-aligned rectangle. */ export type CropMode = "corners" | "rect"; /** How a stored crop action must be replayed. Axis-aligned rects need no OpenCV. */ type CropGeometry = "perspective" | "rect"; interface RectDraft { startX: number; startY: number; currentX: number; currentY: number; } export interface CropToolState { activeCorners: Quad; mode: CropMode; /** False while the user is in rect mode and has not drawn a rectangle yet. */ hasSelection: boolean; scale: number; displaySize: { width: number; height: number }; canvasRef: React.RefObject; cvReady: boolean; cvError: Error | null; cropping: boolean; error: string | null; handleCanvasReady: (canvas: HTMLCanvasElement, w: number, h: number) => void; updateCorner: (index: number, screenX: number, screenY: number) => void; /** Enters rect mode, temporarily clearing (and remembering) the current selection. */ startRectMode: () => void; /** Leaves rect mode without drawing, restoring the selection that was active before. */ cancelRectMode: () => void; startRectDraw: (screenX: number, screenY: number) => void; updateRectDraw: (screenX: number, screenY: number) => void; finishRectDraw: () => void; applyCrop: () => Promise; } /** Relative (0–1) tolerance for treating a quad as axis-aligned. */ const RECT_TOLERANCE = 0.001; /** Minimum drag distance in CSS pixels before a drawn rectangle is accepted. */ const MIN_RECT_DRAG = 4; /** * Manages all state and logic for the crop tool. * OpenCV is loaded lazily for corner suggestion and perspective warping, but is optional: * when it fails to load the tool stays fully usable, only the suggested border is missing. */ export function useCropTool(imageUrl: string, enabled: boolean): CropToolState { let { cv, ready: cvReady, error: cvError } = useOpenCV(enabled); let [corners, setCorners] = useState(null); let [canvasPixelSize, setCanvasPixelSize] = useState({ width: 1, height: 1 }); let [displaySize, setDisplaySize] = useState({ width: 0, height: 0 }); let [canvasEl, setCanvasEl] = useState(null); let [cropping, setCropping] = useState(false); let [error, setError] = useState(null); let [mode, setMode] = useState("corners"); let [rectDraft, setRectDraft] = useState(null); let canvasRef = useRef(null); let prevSuggested = useRef(null); // Selection stashed when entering rect mode, restored if the user backs out. let stashedCorners = useRef(null); // Mirrors rectDraft so pointer handlers never read a stale render closure. let rectDraftRef = useRef(null); // Set once the user has touched the selection, so a late suggestion can't overwrite it. let userAdjusted = useRef(false); let { corners: suggestedCorners } = useEdgeDetection(cvReady ? cv : null, canvasEl, cvReady && enabled); useEffect(() => { if (suggestedCorners && suggestedCorners !== prevSuggested.current) { prevSuggested.current = suggestedCorners; if (!userAdjusted.current) setCorners(suggestedCorners); } }, [suggestedCorners]); // Reset when the source image changes (e.g. after a previous crop or rotate) useEffect(() => { prevSuggested.current = null; stashedCorners.current = null; rectDraftRef.current = null; userAdjusted.current = false; setCorners(null); setCanvasEl(null); setRectDraft(null); setMode("corners"); }, [imageUrl]); let draftQuad = rectDraft ? draftToQuad(rectDraft) : null; let hasSelection = mode === "corners" || draftQuad !== null; let activeCorners: Quad = draftQuad ?? corners ?? fullImageQuad(canvasPixelSize.width, canvasPixelSize.height); let scale = displaySize.width > 0 ? displaySize.width / canvasPixelSize.width : 1; let handleCanvasReady = useCallback((canvas: HTMLCanvasElement, w: number, h: number) => { setCanvasEl(canvas); setCanvasPixelSize({ width: w, height: h }); setDisplaySize({ width: canvas.clientWidth, height: canvas.clientHeight }); }, []); /** Converts CSS-pixel overlay coords to canvas-pixel coords, clamped to the image. */ function toCanvasPoint(screenX: number, screenY: number): Point { return { x: Math.max(0, Math.min(canvasPixelSize.width, screenX / scale)), y: Math.max(0, Math.min(canvasPixelSize.height, screenY / scale)), }; } function updateCorner(index: number, screenX: number, screenY: number) { if (mode !== "corners") return; let point = toCanvasPoint(screenX, screenY); userAdjusted.current = true; setCorners((prev) => { let next = [...(prev ?? activeCorners)] as Quad; next[index] = point; return next; }); } function startRectMode() { if (mode === "rect") return; stashedCorners.current = corners; rectDraftRef.current = null; userAdjusted.current = true; setRectDraft(null); setCorners(null); setMode("rect"); } function cancelRectMode() { if (mode !== "rect") return; // Fall back to the suggested border when rect mode was entered before any selection existed. setCorners(stashedCorners.current ?? prevSuggested.current); stashedCorners.current = null; rectDraftRef.current = null; setRectDraft(null); setMode("corners"); } function startRectDraw(screenX: number, screenY: number) { if (mode !== "rect") return; let { x, y } = toCanvasPoint(screenX, screenY); rectDraftRef.current = { startX: x, startY: y, currentX: x, currentY: y }; setRectDraft(rectDraftRef.current); } function updateRectDraw(screenX: number, screenY: number) { if (!rectDraftRef.current) return; let { x, y } = toCanvasPoint(screenX, screenY); rectDraftRef.current = { ...rectDraftRef.current, currentX: x, currentY: y }; setRectDraft(rectDraftRef.current); } function finishRectDraw() { let draft = rectDraftRef.current; rectDraftRef.current = null; setRectDraft(null); if (!draft) return; let width = Math.abs(draft.currentX - draft.startX) * scale; let height = Math.abs(draft.currentY - draft.startY) * scale; // Ignore taps and stray micro-drags — stay in rect mode so the user can try again. if (width < MIN_RECT_DRAG || height < MIN_RECT_DRAG) return; // The new rectangle replaces the stashed selection, so there is nothing left to restore. stashedCorners.current = null; setCorners(draftToQuad(draft)); setMode("corners"); } async function applyCrop(): Promise { // Nothing to apply until the user has drawn their rectangle. if (mode === "rect") return null; setCropping(true); setError(null); try { // Store corners as relative (0–1) coords so replay is resolution-independent. // The display canvas may be downscaled (MAX_DISPLAY_WIDTH), so we must map // corners back to relative coords and then re-apply at full resolution. let relCorners = activeCorners.map((c) => ({ x: c.x / canvasPixelSize.width, y: c.y / canvasPixelSize.height, })) as Quad; // Warp the original image at its native resolution to avoid quality loss. let { blob, corners: usedCorners, geometry } = await cropAtFullResolution(cv, imageUrl, relCorners); let actions: EditAction[] = [{ type: "crop", params: { corners: usedCorners, mode: geometry } }]; return { blob, actions }; } catch (err) { setError(err instanceof Error ? err.message : "Crop failed"); return null; } finally { setCropping(false); } } return { activeCorners, mode, hasSelection, scale, displaySize, canvasRef, cvReady, cvError, cropping, error, handleCanvasReady, updateCorner, startRectMode, cancelRectMode, startRectDraw, updateRectDraw, finishRectDraw, applyCrop, }; } /** Normalises a drag into a TL, TR, BR, BL quad. */ function draftToQuad(draft: RectDraft): Quad { let x1 = Math.min(draft.startX, draft.currentX); let x2 = Math.max(draft.startX, draft.currentX); let y1 = Math.min(draft.startY, draft.currentY); let y2 = Math.max(draft.startY, draft.currentY); return [ { x: x1, y: y1 }, { x: x2, y: y1 }, { x: x2, y: y2 }, { x: x1, y: y2 }, ]; } /** True when the quad's edges are horizontal/vertical, so a plain canvas crop is exact. */ function isAxisAlignedRect([tl, tr, br, bl]: Quad): boolean { return ( Math.abs(tl.y - tr.y) <= RECT_TOLERANCE && Math.abs(bl.y - br.y) <= RECT_TOLERANCE && Math.abs(tl.x - bl.x) <= RECT_TOLERANCE && Math.abs(tr.x - br.x) <= RECT_TOLERANCE ); } /** Axis-aligned bounding box of a quad, as a quad. */ function boundingQuad(corners: Quad): Quad { let xs = corners.map((c) => c.x); let ys = corners.map((c) => c.y); let x1 = Math.min(...xs); let x2 = Math.max(...xs); let y1 = Math.min(...ys); let y2 = Math.max(...ys); return [ { x: x1, y: y1 }, { x: x2, y: y1 }, { x: x2, y: y2 }, { x: x1, y: y2 }, ]; } /** * Ensures OpenCV is loaded, reporting success as a boolean instead of throwing. * * Deliberately does not *return* the module: Emscripten's module object is thenable * (it has its own `.then`), so returning it from an async function makes the promise * machinery adopt it and loop forever, freezing the tab. Callers read `window.cv`. */ async function ensureOpenCV(): Promise { try { await loadOpenCV(); return typeof window.cv?.imread === "function"; } catch { return false; } } interface CropOutput { blob: Blob; /** The corners actually used — a bounding box when a perspective crop had to be downgraded. */ corners: Quad; geometry: CropGeometry; } /** * Crops at native resolution, picking the cheapest technique that is exact: * axis-aligned rectangles use plain canvas drawing, other quads need an OpenCV warp. * When OpenCV cannot be loaded, a perspective crop degrades to its bounding box. */ // oxlint-disable-next-line typescript/no-explicit-any -- OpenCV.js types async function cropAtFullResolution(cv: any | null, imageUrl: string, relCorners: Quad): Promise { if (isAxisAlignedRect(relCorners)) { return { blob: await rectCropAtFullResolution(imageUrl, relCorners), corners: relCorners, geometry: "rect" }; } let cvInstance = cv ?? ((await ensureOpenCV()) ? window.cv : null); if (!cvInstance) { console.warn( "[ImageEditor] OpenCV unavailable — cropping to the selection's bounding box without perspective correction.", ); let bounds = boundingQuad(relCorners); return { blob: await rectCropAtFullResolution(imageUrl, bounds), corners: bounds, geometry: "rect" }; } return { blob: await cropImageAtFullResolution(cvInstance, imageUrl, relCorners), corners: relCorners, geometry: "perspective", }; } /** Crops an axis-aligned rectangle with the 2D canvas API — no OpenCV required. */ async function rectCropAtFullResolution(imageUrl: string, relCorners: Quad): Promise { let img = await loadImage(imageUrl); let [tl, , br] = boundingQuad(relCorners); let x = Math.round(tl.x * img.naturalWidth); let y = Math.round(tl.y * img.naturalHeight); let width = Math.max(1, Math.round((br.x - tl.x) * img.naturalWidth)); let height = Math.max(1, Math.round((br.y - tl.y) * img.naturalHeight)); let canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; canvas.getContext("2d")!.drawImage(img, x, y, width, height, 0, 0, width, height); return canvasToPngBlob(canvas); } 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 for crop")); img.src = url; }); } function canvasToPngBlob(canvas: HTMLCanvasElement): Promise { return new Promise((resolve, reject) => { // PNG is lossless — critical for receipts and screenshots where JPEG // compression would make text unreadable. canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/png"); }); } /** Loads the image at native resolution, maps relative corners, and warps. */ // oxlint-disable-next-line typescript/no-explicit-any -- OpenCV.js types async function cropImageAtFullResolution(cv: any, imageUrl: string, relCorners: Quad): Promise { let img = await loadImage(imageUrl); let canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; canvas.getContext("2d")!.drawImage(img, 0, 0); let absCorners = relCorners.map((c) => ({ x: c.x * img.naturalWidth, y: c.y * img.naturalHeight, })) as Quad; return perspectiveCrop(cv, canvas, absCorners); } /** Performs a perspective warp using OpenCV.js. */ // oxlint-disable-next-line typescript/no-explicit-any -- OpenCV.js types function perspectiveCrop(cv: any, srcCanvas: HTMLCanvasElement, corners: Quad): Promise { let [tl, tr, br, bl] = corners; let outW = Math.round(Math.max(Math.hypot(tr.x - tl.x, tr.y - tl.y), Math.hypot(br.x - bl.x, br.y - bl.y))); let outH = Math.round(Math.max(Math.hypot(bl.x - tl.x, bl.y - tl.y), Math.hypot(br.x - tr.x, br.y - tr.y))); let src = cv.imread(srcCanvas); let dst = new cv.Mat(); let srcPts = cv.matFromArray(4, 1, cv.CV_32FC2, [tl.x, tl.y, tr.x, tr.y, br.x, br.y, bl.x, bl.y]); let dstPts = cv.matFromArray(4, 1, cv.CV_32FC2, [0, 0, outW, 0, outW, outH, 0, outH]); let M = cv.getPerspectiveTransform(srcPts, dstPts); cv.warpPerspective(src, dst, M, new cv.Size(outW, outH)); let outCanvas = document.createElement("canvas"); cv.imshow(outCanvas, dst); for (let m of [src, dst, srcPts, dstPts, M]) m.delete(); return canvasToPngBlob(outCanvas); } /** * Standalone replay handler for the "crop" action type. * Corners are stored as relative (0–1) values so they work at any resolution. * Rectangular crops replay without OpenCV; older actions without a stored mode * are treated as perspective crops. */ export async function replayCrop(imageUrl: string, params: Record): Promise { let relCorners = params.corners as Quad; if (params.mode === "rect") return rectCropAtFullResolution(imageUrl, relCorners); let { blob } = await cropAtFullResolution(null, imageUrl, relCorners); return blob; }