import { useState, useRef, useCallback } from 'react' type HandleType = 'move' | 'tl' | 'tr' | 'bl' | 'br' interface CropBox { x: number; y: number; w: number; h: number } interface Props { imageSrc: string aspectRatio: number // width / height of the target display area onCrop: (dataUrl: string) => void onCancel: () => void } const MIN_SIZE = 80 function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)) } export function BgImageCropper({ imageSrc, aspectRatio, onCrop, onCancel }: Props) { const containerRef = useRef(null) const imgRef = useRef(null) // Keep crop box in both a ref (for event handlers, avoids stale closure) and state (for rendering) const cropRef = useRef(null) const [cropBox, setCropBox] = useState(null) const drag = useRef<{ type: HandleType startX: number startY: number startCrop: CropBox } | null>(null) const setBox = useCallback((b: CropBox) => { cropRef.current = b setCropBox(b) }, []) // Centre a crop box filling most of the displayed image at the target aspect ratio const initCrop = useCallback(() => { const c = containerRef.current const img = imgRef.current if (!c || !img) return const cW = c.clientWidth const cH = c.clientHeight const scale = Math.min(cW / img.naturalWidth, cH / img.naturalHeight) const dispW = img.naturalWidth * scale const dispH = img.naturalHeight * scale const imgX = (cW - dispW) / 2 const imgY = (cH - dispH) / 2 let w = dispW * 0.9 let h = w / aspectRatio if (h > dispH * 0.9) { h = dispH * 0.9; w = h * aspectRatio } setBox({ x: imgX + (dispW - w) / 2, y: imgY + (dispH - h) / 2, w, h, }) }, [aspectRatio, setBox]) const onPointerDown = useCallback((e: React.PointerEvent, type: HandleType) => { if (!cropRef.current) return e.preventDefault() e.stopPropagation() drag.current = { type, startX: e.clientX, startY: e.clientY, startCrop: { ...cropRef.current }, } ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) }, []) const onPointerMove = useCallback((e: React.PointerEvent) => { if (!drag.current || !containerRef.current) return const c = containerRef.current const cW = c.clientWidth const cH = c.clientHeight const dx = e.clientX - drag.current.startX const dy = e.clientY - drag.current.startY const sc = drag.current.startCrop const t = drag.current.type let x = sc.x, y = sc.y, w = sc.w, h = sc.h if (t === 'move') { x = clamp(sc.x + dx, 0, cW - sc.w) y = clamp(sc.y + dy, 0, cH - sc.h) } else { // Resize: width driven by dx, height derived from aspect ratio let newW: number if (t === 'br' || t === 'tr') newW = clamp(sc.w + dx, MIN_SIZE, cW) else newW = clamp(sc.w - dx, MIN_SIZE, cW) const newH = newW / aspectRatio if (t === 'br') { x = sc.x; y = sc.y } else if (t === 'bl') { x = sc.x + sc.w - newW; y = sc.y } else if (t === 'tr') { x = sc.x; y = sc.y + sc.h - newH } else { x = sc.x + sc.w - newW; y = sc.y + sc.h - newH } x = clamp(x, 0, cW - newW) y = clamp(y, 0, cH - newH) w = newW h = newH } setBox({ x, y, w, h }) }, [aspectRatio, setBox]) const onPointerUp = useCallback(() => { drag.current = null }, []) const handleCrop = useCallback(() => { const img = imgRef.current const c = containerRef.current const cb = cropRef.current if (!img || !c || !cb) return const cW = c.clientWidth const cH = c.clientHeight const scale = Math.min(cW / img.naturalWidth, cH / img.naturalHeight) const dispW = img.naturalWidth * scale const dispH = img.naturalHeight * scale const offX = (cW - dispW) / 2 const offY = (cH - dispH) / 2 // Map crop box back to source image coordinates const srcX = (cb.x - offX) / scale const srcY = (cb.y - offY) / scale const srcW = cb.w / scale const srcH = cb.h / scale // Output resolution: screen size × device pixel ratio, capped at 1440px wide // Then scale down resolution until the result is under 3MB (keeping quality at 0.92) const MAX_BYTES = 1 * 1024 * 1024 const dpr = Math.min(window.devicePixelRatio || 1, 2) let w = Math.min(Math.round(window.innerWidth * dpr), 1440) const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d')! let dataUrl: string do { const h = Math.round(w / aspectRatio) canvas.width = w canvas.height = h ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, w, h) dataUrl = canvas.toDataURL('image/jpeg', 0.92) // base64 → approx byte size const bytes = (dataUrl.length - dataUrl.indexOf(',') - 1) * 0.75 if (bytes <= MAX_BYTES) break w = Math.round(w * 0.8) } while (w > 200) onCrop(dataUrl!) }, [aspectRatio, onCrop]) return (
Crop Background
{cropBox && ( <> {/* Darkened area outside crop box via box-shadow */}
{/* Moveable crop box */}
onPointerDown(e, 'move')} > {/* Rule-of-thirds grid */}
{/* Resize handles */}
onPointerDown(e, 'tl')} />
onPointerDown(e, 'tr')} />
onPointerDown(e, 'bl')} />
onPointerDown(e, 'br')} />
)}

Drag to move · Drag corners to resize

) }