import { BRANDING_SOURCE_TYPES } from '@dorfteich/shared'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Field } from '../components/forms'; import { CropRect, clampCrop, drawCrop, initialCrop, loadImage, outputSize } from './crop'; /** * Pick an image, crop it, see the result (issue #306). * * The crop is driven by NUMBER INPUTS, not by dragging. A drag-only cropper * excludes keyboard and switch users outright, and a number input is * arrow-key operable, screen-reader readable and announces its value without * any custom aria plumbing — the accessible option is also the simpler one. * The preview canvas is a picture of the result, never the control. * * The resulting pixel dimensions are stated in TEXT next to it, so the outcome * does not depend on seeing the frame. */ export function CropField({ idPrefix, square, maxEdge, onChange, }: { idPrefix: string; /** Favicons are square by construction; a logo keeps its own proportions. */ square: boolean; maxEdge: number; /** Called with the rendering canvas whenever the crop changes, so the * parent can encode PNGs from it on submit. Null = nothing selected. */ onChange: (canvas: HTMLCanvasElement | null) => void; }): React.JSX.Element { const { t } = useTranslation('branding'); const [image, setImage] = useState(null); const [crop, setCrop] = useState(null); const [error, setError] = useState(null); const canvasRef = useRef(null); const out = image && crop ? outputSize(crop, maxEdge) : null; // Held in a ref so the redraw depends on the crop alone: callers pass an // inline arrow, whose identity changes every render and would otherwise // repaint the canvas on every keystroke in the surrounding form. const notifyRef = useRef(onChange); notifyRef.current = onChange; useEffect(() => { const canvas = canvasRef.current; if (!canvas || !image || !crop) { notifyRef.current(null); return; } // Derived inside the effect: `outputSize` returns a fresh object every // render, so as a dependency it would never compare equal. drawCrop(image, crop, outputSize(crop, maxEdge), canvas); notifyRef.current(canvas); }, [image, crop, maxEdge]); async function choose(file: File | undefined): Promise { setError(null); if (!file) { setImage(null); setCrop(null); return; } if (!(BRANDING_SOURCE_TYPES as readonly string[]).includes(file.type)) { setImage(null); setCrop(null); // SVG is the one an operator is most likely to try, and it is refused // on purpose (it can carry script) — say which types work instead. setError(file.type === 'image/svg+xml' ? 'branding_svg_rejected' : 'branding_not_an_image'); return; } try { const loaded = await loadImage(file); setImage(loaded); setCrop(initialCrop(loaded, square)); } catch { setError('branding_not_an_image'); } } function update(patch: Partial): void { if (!image || !crop) return; const next = { ...crop, ...patch }; // A square crop has one size, so width and height move together. if (square && patch.width !== undefined) next.height = patch.width; setCrop(clampCrop(next, image)); } return (
void choose(event.target.files?.[0])} /> {image && crop && out && ( <>
update({ x: Number(event.target.value) })} /> update({ y: Number(event.target.value) })} /> update({ width: Number(event.target.value) })} /> {!square && ( update({ height: Number(event.target.value) })} /> )}
{/* The outcome in words: the frame alone would leave a keyboard-only or screen-reader user guessing. */}

{t('crop.result', { width: out.width, height: out.height, sourceWidth: image.width, sourceHeight: image.height, })}

)}
); }