63 lines
4.7 KiB
TypeScript
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'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>
|
|
);
|
|
}
|