Skip to content
File

Blob: src/components/EditorCanvas.tsx

typescript159 lines
1import { useEffect, useRef, useState, type Dispatch } from "react";
2import Cropper from "react-easy-crop";
3import type { Area } from "react-easy-crop";
4import type { LoadedImage } from "../lib/image";
5import type { EditorAction, EditorState } from "../hooks/useEditor";
6import { buildFilter } from "../lib/filter";
7import { getTemplate } from "../templates";
8import { ResizeIcon } from "./icons";
9 
10interface EditorCanvasProps {
11 image: LoadedImage;
12 state: EditorState;
13 dispatch: Dispatch<EditorAction>;
14}
15 
16interface Rect {
17 left: number;
18 top: number;
19 width: number;
20 height: number;
21}
22 
23/**
24 * A face-framing guide (head ellipse + eye line) for identity templates such as
25 * passports. Auto-detection is phase 2; this helps position the face by hand.
26 * Coordinates come from the template's `framing` metadata.
27 */
28function FramingGuide({
29 rect,
30 framing,
31}: {
32 rect: Rect;
33 framing: NonNullable<ReturnType<typeof getTemplate>["framing"]>;
34}) {
35 const W = rect.width;
36 const H = rect.height;
37 const headH = ((framing.faceHeightRatio[0] + framing.faceHeightRatio[1]) / 2) * H;
38 const eyeY = framing.eyeLineFromTop * H;
39 const crownY = eyeY - 0.45 * headH;
40 const cy = crownY + headH / 2;
41 const ry = headH / 2;
42 const rx = 0.38 * headH;
43 const stroke = "var(--color-accent-400)";
44 
45 return (
46 <svg
47 width={W}
48 height={H}
49 className="pointer-events-none absolute"
50 style={{ left: rect.left, top: rect.top }}
51 aria-hidden="true"
52 >
53 <ellipse
54 cx={W / 2}
55 cy={cy}
56 rx={rx}
57 ry={ry}
58 fill="none"
59 stroke={stroke}
60 strokeWidth={2}
61 strokeDasharray="6 5"
62 opacity={0.85}
63 />
64 <line x1={0} y1={eyeY} x2={W} y2={eyeY} stroke={stroke} strokeWidth={1.5} strokeDasharray="6 5" opacity={0.6} />
65 <text x={8} y={eyeY - 7} fill={stroke} fontSize={11} opacity={0.9} fontFamily="var(--font-sans)">
66 eye line
67 </text>
68 </svg>
69 );
70}
71 
72export function EditorCanvas({ image, state, dispatch }: EditorCanvasProps) {
73 const containerRef = useRef<HTMLDivElement>(null);
74 const [cropRect, setCropRect] = useState<Rect | null>(null);
75 
76 const framing = getTemplate(state.templateId).framing;
77 
78 // Track the crop box so the framing guide can sit exactly over it.
79 // `.reactEasyCrop_CropArea` is part of react-easy-crop's documented CSS API.
80 useEffect(() => {
81 // When there's no framing guide the render gate (`framing && cropRect`)
82 // already hides it, so a stale cropRect is harmless — no need to null it.
83 if (!framing) return;
84 const container = containerRef.current;
85 if (!container) return;
86 
87 const measure = () => {
88 const area = container.querySelector(".reactEasyCrop_CropArea");
89 if (!area) return;
90 const c = container.getBoundingClientRect();
91 const a = area.getBoundingClientRect();
92 setCropRect({ left: a.left - c.left, top: a.top - c.top, width: a.width, height: a.height });
93 };
94 
95 const raf = requestAnimationFrame(measure);
96 const observer = new ResizeObserver(measure);
97 observer.observe(container);
98 return () => {
99 cancelAnimationFrame(raf);
100 observer.disconnect();
101 };
102 }, [framing, state.aspect, image.url]);
103 
104 // A null template aspect means "free": match the source ratio so the crop box
105 // frames the whole image.
106 const aspect = state.aspect ?? image.width / image.height;
107 const filter = buildFilter(state.adjustments);
108 
109 // Flip is applied to the media via the transform prop (and re-applied on export)
110 // exactly like react-easy-crop's official rotation+flip recipe.
111 const transform = [
112 `translate(${state.crop.x}px, ${state.crop.y}px)`,
113 `rotateZ(${state.rotation}deg)`,
114 `rotateY(${state.flipH ? 180 : 0}deg)`,
115 `rotateX(${state.flipV ? 180 : 0}deg)`,
116 `scale(${state.zoom})`,
117 ].join(" ");
118 
119 return (
120 <div ref={containerRef} className="relative h-full w-full bg-checker">
121 <Cropper
122 image={image.url}
123 crop={state.crop}
124 zoom={state.zoom}
125 rotation={state.rotation}
126 aspect={aspect}
127 minZoom={1}
128 maxZoom={8}
129 restrictPosition
130 zoomWithScroll
131 showGrid
132 objectFit="contain"
133 transform={transform}
134 onCropChange={(crop) => dispatch({ type: "setCrop", crop })}
135 onZoomChange={(zoom) => dispatch({ type: "setZoom", zoom })}
136 onRotationChange={(rotation) => dispatch({ type: "setRotation", rotation })}
137 onCropComplete={(_area: Area, pixels: Area) => dispatch({ type: "setCroppedAreaPixels", rect: pixels })}
138 style={{
139 containerStyle: { background: "transparent" },
140 mediaStyle: filter ? { filter } : undefined,
141 }}
142 />
143
144 {framing && cropRect && <FramingGuide rect={cropRect} framing={framing} />}
145
146 <button
147 onClick={() => {
148 dispatch({ type: "setZoom", zoom: 1 });
149 dispatch({ type: "setCrop", crop: { x: 0, y: 0 } });
150 }}
151 title="Reset view (0)"
152 className="absolute bottom-3 right-3 flex items-center gap-1.5 rounded-lg border border-zinc-700/60 bg-zinc-900/90 px-2.5 py-1.5 text-xs font-medium text-zinc-400 shadow-sm backdrop-blur-sm transition-transform hover:text-zinc-100"
153 >
154 <ResizeIcon className="size-3.5" /> Fit
155 </button>
156 </div>
157 );
158}