added bg feature
This commit is contained in:
223
src/components/BgImageCropper.tsx
Normal file
223
src/components/BgImageCropper.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
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
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
const outW = Math.min(Math.round(window.innerWidth * dpr), 1440)
|
||||
const outH = Math.round(outW / aspectRatio)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = outW
|
||||
canvas.height = outH
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, outW, outH)
|
||||
onCrop(canvas.toDataURL('image/jpeg', 0.72))
|
||||
}, [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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user