236 lines
7.2 KiB
TypeScript
236 lines
7.2 KiB
TypeScript
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<HTMLDivElement>(null)
|
||
const imgRef = useRef<HTMLImageElement>(null)
|
||
|
||
// Keep crop box in both a ref (for event handlers, avoids stale closure) and state (for rendering)
|
||
const cropRef = useRef<CropBox | null>(null)
|
||
const [cropBox, setCropBox] = useState<CropBox | null>(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 (
|
||
<div className="cropper-overlay">
|
||
<div className="cropper-header">
|
||
<button type="button" className="cropper-cancel-btn" onClick={onCancel}>
|
||
Cancel
|
||
</button>
|
||
<span className="cropper-title">Crop Background</span>
|
||
<button
|
||
type="button"
|
||
className="cropper-apply-btn"
|
||
onClick={handleCrop}
|
||
disabled={!cropBox}
|
||
>
|
||
Apply
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
ref={containerRef}
|
||
className="cropper-container"
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
onPointerLeave={onPointerUp}
|
||
>
|
||
<img
|
||
ref={imgRef}
|
||
src={imageSrc}
|
||
className="cropper-image"
|
||
onLoad={initCrop}
|
||
alt=""
|
||
draggable={false}
|
||
/>
|
||
|
||
{cropBox && (
|
||
<>
|
||
{/* Darkened area outside crop box via box-shadow */}
|
||
<div
|
||
className="cropper-shade"
|
||
style={{
|
||
left: cropBox.x,
|
||
top: cropBox.y,
|
||
width: cropBox.w,
|
||
height: cropBox.h,
|
||
}}
|
||
/>
|
||
|
||
{/* Moveable crop box */}
|
||
<div
|
||
className="cropper-box"
|
||
style={{
|
||
left: cropBox.x,
|
||
top: cropBox.y,
|
||
width: cropBox.w,
|
||
height: cropBox.h,
|
||
}}
|
||
onPointerDown={(e) => onPointerDown(e, 'move')}
|
||
>
|
||
{/* Rule-of-thirds grid */}
|
||
<div className="cropper-grid" />
|
||
|
||
{/* Resize handles */}
|
||
<div className="cropper-handle cropper-handle-tl" onPointerDown={(e) => onPointerDown(e, 'tl')} />
|
||
<div className="cropper-handle cropper-handle-tr" onPointerDown={(e) => onPointerDown(e, 'tr')} />
|
||
<div className="cropper-handle cropper-handle-bl" onPointerDown={(e) => onPointerDown(e, 'bl')} />
|
||
<div className="cropper-handle cropper-handle-br" onPointerDown={(e) => onPointerDown(e, 'br')} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<p className="cropper-hint">Drag to move · Drag corners to resize</p>
|
||
</div>
|
||
)
|
||
}
|