File
Blob: src/components/EditorCanvas.tsx
| 1 | import { useEffect, useRef, useState, type Dispatch } from "react"; |
| 2 | import Cropper from "react-easy-crop"; |
| 3 | import type { Area } from "react-easy-crop"; |
| 4 | import type { LoadedImage } from "../lib/image"; |
| 5 | import type { EditorAction, EditorState } from "../hooks/useEditor"; |
| 6 | import { buildFilter } from "../lib/filter"; |
| 7 | import { getTemplate } from "../templates"; |
| 8 | import { ResizeIcon } from "./icons"; |
| 9 | |
| 10 | interface EditorCanvasProps { |
| 11 | image: LoadedImage; |
| 12 | state: EditorState; |
| 13 | dispatch: Dispatch<EditorAction>; |
| 14 | } |
| 15 | |
| 16 | interface 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 | */ |
| 28 | function 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 | |
| 72 | export 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 | } |