From 8965dc93fdbbfc81d623a4bddd402fda97c06426 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 19:36:44 +0300 Subject: [PATCH] web(avatar-crop): smooth zoom via transform scale with stable cover sizing --- web/src/components/AvatarCropModal.tsx | 74 +++++++++++++++----------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/web/src/components/AvatarCropModal.tsx b/web/src/components/AvatarCropModal.tsx index f299f33..3480053 100644 --- a/web/src/components/AvatarCropModal.tsx +++ b/web/src/components/AvatarCropModal.tsx @@ -16,7 +16,7 @@ const MASK_RATIO = 0.88; export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropModalProps) { const [imageUrl, setImageUrl] = useState(null); - const [imageEl, setImageEl] = useState(null); + const [imageMeta, setImageMeta] = useState<{ naturalWidth: number; naturalHeight: number } | null>(null); const [zoom, setZoom] = useState(1); const [position, setPosition] = useState({ x: 0, y: 0 }); const [processing, setProcessing] = useState(false); @@ -25,14 +25,14 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod useEffect(() => { if (!open || !file) { setImageUrl(null); - setImageEl(null); + setImageMeta(null); setZoom(1); setPosition({ x: 0, y: 0 }); return; } const url = URL.createObjectURL(file); setImageUrl(url); - setImageEl(null); + setImageMeta(null); setZoom(1); setPosition({ x: 0, y: 0 }); return () => { @@ -41,22 +41,25 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod }, [file, open]); const baseScale = useMemo(() => { - if (!imageEl) { + if (!imageMeta) { return 1; } - return Math.max(VIEWPORT_SIZE / imageEl.naturalWidth, VIEWPORT_SIZE / imageEl.naturalHeight); - }, [imageEl]); + return Math.max(VIEWPORT_SIZE / imageMeta.naturalWidth, VIEWPORT_SIZE / imageMeta.naturalHeight); + }, [imageMeta]); - const scale = baseScale * zoom; + const baseWidth = (imageMeta?.naturalWidth ?? 0) * baseScale; + const baseHeight = (imageMeta?.naturalHeight ?? 0) * baseScale; + const displayedWidth = baseWidth * zoom; + const displayedHeight = baseHeight * zoom; useEffect(() => { - if (!imageEl) { + if (!imageMeta) { return; } - const width = imageEl.naturalWidth * baseScale; - const height = imageEl.naturalHeight * baseScale; + const width = baseWidth; + const height = baseHeight; setPosition(clampPosition({ x: (VIEWPORT_SIZE - width) / 2, y: (VIEWPORT_SIZE - height) / 2 }, width, height)); - }, [baseScale, imageEl]); + }, [baseHeight, baseWidth, imageMeta]); useEffect(() => { if (!open) { @@ -76,7 +79,7 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod } function handlePointerDown(event: PointerEvent) { - if (!imageEl || processing) { + if (!imageMeta || processing) { return; } event.preventDefault(); @@ -90,13 +93,13 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod } function handlePointerMove(event: PointerEvent) { - if (!imageEl || !dragRef.current || processing) { + if (!imageMeta || !dragRef.current || processing) { return; } const dx = event.clientX - dragRef.current.startX; const dy = event.clientY - dragRef.current.startY; - const width = imageEl.naturalWidth * scale; - const height = imageEl.naturalHeight * scale; + const width = displayedWidth; + const height = displayedHeight; setPosition(clampPosition({ x: dragRef.current.startPosX + dx, y: dragRef.current.startPosY + dy }, width, height)); } @@ -108,32 +111,37 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod } function handleZoomChange(nextZoom: number) { - if (!imageEl) { + if (!imageMeta) { setZoom(nextZoom); return; } const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, nextZoom)); - const nextScale = baseScale * clamped; - const prevScale = scale; + const nextScale = clamped; + const prevScale = zoom; const cx = VIEWPORT_SIZE / 2; const cy = VIEWPORT_SIZE / 2; const nextX = cx - ((cx - position.x) / prevScale) * nextScale; const nextY = cy - ((cy - position.y) / prevScale) * nextScale; - const width = imageEl.naturalWidth * nextScale; - const height = imageEl.naturalHeight * nextScale; + const width = baseWidth * nextScale; + const height = baseHeight * nextScale; setZoom(clamped); setPosition(clampPosition({ x: nextX, y: nextY }, width, height)); } async function handleApply() { - if (!imageEl || !file || processing) { + if (!imageMeta || !file || processing) { return; } setProcessing(true); try { - const sourceX = Math.max(0, Math.min(imageEl.naturalWidth, (-position.x) / scale)); - const sourceY = Math.max(0, Math.min(imageEl.naturalHeight, (-position.y) / scale)); - const sourceSize = Math.min(imageEl.naturalWidth - sourceX, imageEl.naturalHeight - sourceY, VIEWPORT_SIZE / scale); + const totalScale = baseScale * zoom; + const sourceX = Math.max(0, Math.min(imageMeta.naturalWidth, (-position.x) / totalScale)); + const sourceY = Math.max(0, Math.min(imageMeta.naturalHeight, (-position.y) / totalScale)); + const sourceSize = Math.min( + imageMeta.naturalWidth - sourceX, + imageMeta.naturalHeight - sourceY, + VIEWPORT_SIZE / totalScale + ); const canvas = document.createElement("canvas"); canvas.width = OUTPUT_SIZE; canvas.height = OUTPUT_SIZE; @@ -142,7 +150,10 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod setProcessing(false); return; } - ctx.drawImage(imageEl, sourceX, sourceY, sourceSize, sourceSize, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE); + const img = new Image(); + img.src = imageUrl ?? ""; + await img.decode(); + ctx.drawImage(img, sourceX, sourceY, sourceSize, sourceSize, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE); const blob = await new Promise((resolve) => { canvas.toBlob((value) => resolve(value), "image/jpeg", 0.86); }); @@ -183,13 +194,16 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod className="pointer-events-none absolute left-0 top-0 select-none" draggable={false} onLoad={(event) => { - setImageEl(event.currentTarget); + setImageMeta({ + naturalWidth: event.currentTarget.naturalWidth, + naturalHeight: event.currentTarget.naturalHeight, + }); }} src={imageUrl} style={{ - width: imageEl ? imageEl.naturalWidth * scale : "auto", - height: imageEl ? imageEl.naturalHeight * scale : "auto", - transform: `translate(${position.x}px, ${position.y}px)`, + width: baseWidth ? `${baseWidth}px` : "auto", + height: baseHeight ? `${baseHeight}px` : "auto", + transform: `translate(${position.x}px, ${position.y}px) scale(${zoom})`, transformOrigin: "top left", }} /> @@ -221,7 +235,7 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod

Output: 500x500 JPEG