import { useEffect, useRef, useState, type Dispatch } from "react"; import Cropper from "react-easy-crop"; import type { Area } from "react-easy-crop"; import type { LoadedImage } from "../lib/image"; import type { EditorAction, EditorState } from "../hooks/useEditor"; import { buildFilter } from "../lib/filter"; import { getTemplate } from "../templates"; import { ResizeIcon } from "./icons"; interface EditorCanvasProps { image: LoadedImage; state: EditorState; dispatch: Dispatch; } interface Rect { left: number; top: number; width: number; height: number; } /** * A face-framing guide (head ellipse + eye line) for identity templates such as * passports. Auto-detection is phase 2; this helps position the face by hand. * Coordinates come from the template's `framing` metadata. */ function FramingGuide({ rect, framing, }: { rect: Rect; framing: NonNullable["framing"]>; }) { const W = rect.width; const H = rect.height; const headH = ((framing.faceHeightRatio[0] + framing.faceHeightRatio[1]) / 2) * H; const eyeY = framing.eyeLineFromTop * H; const crownY = eyeY - 0.45 * headH; const cy = crownY + headH / 2; const ry = headH / 2; const rx = 0.38 * headH; const stroke = "var(--color-accent-400)"; return ( ); } export function EditorCanvas({ image, state, dispatch }: EditorCanvasProps) { const containerRef = useRef(null); const [cropRect, setCropRect] = useState(null); const framing = getTemplate(state.templateId).framing; // Track the crop box so the framing guide can sit exactly over it. // `.reactEasyCrop_CropArea` is part of react-easy-crop's documented CSS API. useEffect(() => { // When there's no framing guide the render gate (`framing && cropRect`) // already hides it, so a stale cropRect is harmless — no need to null it. if (!framing) return; const container = containerRef.current; if (!container) return; const measure = () => { const area = container.querySelector(".reactEasyCrop_CropArea"); if (!area) return; const c = container.getBoundingClientRect(); const a = area.getBoundingClientRect(); setCropRect({ left: a.left - c.left, top: a.top - c.top, width: a.width, height: a.height }); }; const raf = requestAnimationFrame(measure); const observer = new ResizeObserver(measure); observer.observe(container); return () => { cancelAnimationFrame(raf); observer.disconnect(); }; }, [framing, state.aspect, image.url]); // A null template aspect means "free": match the source ratio so the crop box // frames the whole image. const aspect = state.aspect ?? image.width / image.height; const filter = buildFilter(state.adjustments); // Flip is applied to the media via the transform prop (and re-applied on export) // exactly like react-easy-crop's official rotation+flip recipe. const transform = [ `translate(${state.crop.x}px, ${state.crop.y}px)`, `rotateZ(${state.rotation}deg)`, `rotateY(${state.flipH ? 180 : 0}deg)`, `rotateX(${state.flipV ? 180 : 0}deg)`, `scale(${state.zoom})`, ].join(" "); return (
dispatch({ type: "setCrop", crop })} onZoomChange={(zoom) => dispatch({ type: "setZoom", zoom })} onRotationChange={(rotation) => dispatch({ type: "setRotation", rotation })} onCropComplete={(_area: Area, pixels: Area) => dispatch({ type: "setCroppedAreaPixels", rect: pixels })} style={{ containerStyle: { background: "transparent" }, mediaStyle: filter ? { filter } : undefined, }} /> {framing && cropRect && }
); }