MEGAINFRA-WEBAPP/src/app/clock/document-crop-editor.tsx

63 lines
4.7 KiB
TypeScript

"use client";
import * as React from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { validPaperCorners, type PaperCorners, type PaperPoint } from "@/lib/document-crop";
const names = ["Top left", "Top right", "Bottom right", "Bottom left"];
/** Edits the original image, so adjusting a bad auto-crop can restore lost edges. */
export function DocumentCropEditor({ url, initialCorners, busy, error, onApply, onClose }: {
url: string; initialCorners: PaperCorners; busy: boolean; error: string | null;
onApply: (corners: PaperCorners) => void; onClose: () => void;
}) {
const [corners, setCorners] = React.useState<PaperCorners>(initialCorners);
const [size, setSize] = React.useState({ width: 1, height: 1 });
const svgRef = React.useRef<SVGSVGElement>(null);
const valid = validPaperCorners(corners);
function move(index: number, point: PaperPoint) {
setCorners(previous => previous.map((corner, i) => i === index ? {
x: Math.max(0, Math.min(1, point.x)), y: Math.max(0, Math.min(1, point.y)),
} : corner) as PaperCorners);
}
return (
<Dialog open onOpenChange={open => { if (!open && !busy) onClose(); }}>
<DialogContent className="max-w-lg" onEscapeKeyDown={event => { if (busy) event.preventDefault(); }} onPointerDownOutside={event => { if (busy) event.preventDefault(); }}>
<DialogHeader>
<DialogTitle>Adjust paper edges</DialogTitle>
<DialogDescription>Drag each corner onto the paper&apos;s corner. Include the heading and totals. You can also select a corner and use the arrow keys.</DialogDescription>
</DialogHeader>
<div className="relative mx-auto max-h-[52dvh] max-w-full" style={{ width: `min(100%, ${52 * size.width / size.height}dvh)`, aspectRatio: `${size.width} / ${size.height}` }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt="Original document photo" className="block h-full w-full" onLoad={event => setSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })} />
<svg ref={svgRef} viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 h-full w-full overflow-visible">
<path d={`M0 0H100V100H0Z M${corners.map(p => `${p.x * 100} ${p.y * 100}`).join("L")}Z`} fill="rgba(0,0,0,.55)" fillRule="evenodd" pointerEvents="none" />
<polygon points={corners.map(p => `${p.x * 100},${p.y * 100}`).join(" ")} fill="none" stroke={valid ? "#22d3ee" : "#f87171"} strokeWidth="2" vectorEffect="non-scaling-stroke" pointerEvents="none" />
</svg>
{corners.map((point, index) => <button key={index} type="button" aria-label={`${names[index]} paper corner`} disabled={busy}
className="absolute flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 touch-none items-center justify-center rounded-full focus-visible:outline-2 focus-visible:outline-white"
style={{ left: `${point.x * 100}%`, top: `${point.y * 100}%` }}
onPointerDown={event => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); }}
onPointerMove={event => {
if (!event.currentTarget.hasPointerCapture(event.pointerId) || busy) return;
const rect = svgRef.current?.getBoundingClientRect();
if (rect?.width && rect.height) move(index, { x: (event.clientX - rect.left) / rect.width, y: (event.clientY - rect.top) / rect.height });
}}
onPointerUp={event => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }}
onKeyDown={event => {
const delta = event.shiftKey ? .03 : .005;
const offsets: Record<string, [number, number]> = { ArrowLeft: [-delta, 0], ArrowRight: [delta, 0], ArrowUp: [0, -delta], ArrowDown: [0, delta] };
const offset = offsets[event.key];
if (offset) { event.preventDefault(); move(index, { x: point.x + offset[0], y: point.y + offset[1] }); }
}}><span className="h-4 w-4 rounded-full border-2 border-white bg-cyan-400 shadow" /></button>)}
</div>
{(!valid || error) && <p role="alert" className="text-sm text-red-500">{error || "Keep the corners in order around the paper without crossing the edges."}</p>}
<div className="flex gap-2">
<Button variant="outline" className="h-12 flex-1" disabled={busy} onClick={onClose}>Cancel</Button>
<Button className="h-12 flex-1" disabled={busy || !valid} onClick={() => onApply(corners)}>{busy ? "Cropping…" : "Apply crop"}</Button>
</div>
</DialogContent>
</Dialog>
);
}