web(avatar-crop): smooth zoom via transform scale with stable cover sizing
All checks were successful
CI / test (push) Successful in 46s

This commit is contained in:
2026-03-08 19:36:44 +03:00
parent 702679c99d
commit 8965dc93fd

View File

@@ -16,7 +16,7 @@ const MASK_RATIO = 0.88;
export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropModalProps) { export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropModalProps) {
const [imageUrl, setImageUrl] = useState<string | null>(null); const [imageUrl, setImageUrl] = useState<string | null>(null);
const [imageEl, setImageEl] = useState<HTMLImageElement | null>(null); const [imageMeta, setImageMeta] = useState<{ naturalWidth: number; naturalHeight: number } | null>(null);
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
@@ -25,14 +25,14 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod
useEffect(() => { useEffect(() => {
if (!open || !file) { if (!open || !file) {
setImageUrl(null); setImageUrl(null);
setImageEl(null); setImageMeta(null);
setZoom(1); setZoom(1);
setPosition({ x: 0, y: 0 }); setPosition({ x: 0, y: 0 });
return; return;
} }
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
setImageUrl(url); setImageUrl(url);
setImageEl(null); setImageMeta(null);
setZoom(1); setZoom(1);
setPosition({ x: 0, y: 0 }); setPosition({ x: 0, y: 0 });
return () => { return () => {
@@ -41,22 +41,25 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod
}, [file, open]); }, [file, open]);
const baseScale = useMemo(() => { const baseScale = useMemo(() => {
if (!imageEl) { if (!imageMeta) {
return 1; return 1;
} }
return Math.max(VIEWPORT_SIZE / imageEl.naturalWidth, VIEWPORT_SIZE / imageEl.naturalHeight); return Math.max(VIEWPORT_SIZE / imageMeta.naturalWidth, VIEWPORT_SIZE / imageMeta.naturalHeight);
}, [imageEl]); }, [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(() => { useEffect(() => {
if (!imageEl) { if (!imageMeta) {
return; return;
} }
const width = imageEl.naturalWidth * baseScale; const width = baseWidth;
const height = imageEl.naturalHeight * baseScale; const height = baseHeight;
setPosition(clampPosition({ x: (VIEWPORT_SIZE - width) / 2, y: (VIEWPORT_SIZE - height) / 2 }, width, height)); setPosition(clampPosition({ x: (VIEWPORT_SIZE - width) / 2, y: (VIEWPORT_SIZE - height) / 2 }, width, height));
}, [baseScale, imageEl]); }, [baseHeight, baseWidth, imageMeta]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@@ -76,7 +79,7 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod
} }
function handlePointerDown(event: PointerEvent<HTMLDivElement>) { function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
if (!imageEl || processing) { if (!imageMeta || processing) {
return; return;
} }
event.preventDefault(); event.preventDefault();
@@ -90,13 +93,13 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod
} }
function handlePointerMove(event: PointerEvent<HTMLDivElement>) { function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
if (!imageEl || !dragRef.current || processing) { if (!imageMeta || !dragRef.current || processing) {
return; return;
} }
const dx = event.clientX - dragRef.current.startX; const dx = event.clientX - dragRef.current.startX;
const dy = event.clientY - dragRef.current.startY; const dy = event.clientY - dragRef.current.startY;
const width = imageEl.naturalWidth * scale; const width = displayedWidth;
const height = imageEl.naturalHeight * scale; const height = displayedHeight;
setPosition(clampPosition({ x: dragRef.current.startPosX + dx, y: dragRef.current.startPosY + dy }, width, height)); 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) { function handleZoomChange(nextZoom: number) {
if (!imageEl) { if (!imageMeta) {
setZoom(nextZoom); setZoom(nextZoom);
return; return;
} }
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, nextZoom)); const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, nextZoom));
const nextScale = baseScale * clamped; const nextScale = clamped;
const prevScale = scale; const prevScale = zoom;
const cx = VIEWPORT_SIZE / 2; const cx = VIEWPORT_SIZE / 2;
const cy = VIEWPORT_SIZE / 2; const cy = VIEWPORT_SIZE / 2;
const nextX = cx - ((cx - position.x) / prevScale) * nextScale; const nextX = cx - ((cx - position.x) / prevScale) * nextScale;
const nextY = cy - ((cy - position.y) / prevScale) * nextScale; const nextY = cy - ((cy - position.y) / prevScale) * nextScale;
const width = imageEl.naturalWidth * nextScale; const width = baseWidth * nextScale;
const height = imageEl.naturalHeight * nextScale; const height = baseHeight * nextScale;
setZoom(clamped); setZoom(clamped);
setPosition(clampPosition({ x: nextX, y: nextY }, width, height)); setPosition(clampPosition({ x: nextX, y: nextY }, width, height));
} }
async function handleApply() { async function handleApply() {
if (!imageEl || !file || processing) { if (!imageMeta || !file || processing) {
return; return;
} }
setProcessing(true); setProcessing(true);
try { try {
const sourceX = Math.max(0, Math.min(imageEl.naturalWidth, (-position.x) / scale)); const totalScale = baseScale * zoom;
const sourceY = Math.max(0, Math.min(imageEl.naturalHeight, (-position.y) / scale)); const sourceX = Math.max(0, Math.min(imageMeta.naturalWidth, (-position.x) / totalScale));
const sourceSize = Math.min(imageEl.naturalWidth - sourceX, imageEl.naturalHeight - sourceY, VIEWPORT_SIZE / scale); 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"); const canvas = document.createElement("canvas");
canvas.width = OUTPUT_SIZE; canvas.width = OUTPUT_SIZE;
canvas.height = OUTPUT_SIZE; canvas.height = OUTPUT_SIZE;
@@ -142,7 +150,10 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod
setProcessing(false); setProcessing(false);
return; 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<Blob | null>((resolve) => { const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((value) => resolve(value), "image/jpeg", 0.86); 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" className="pointer-events-none absolute left-0 top-0 select-none"
draggable={false} draggable={false}
onLoad={(event) => { onLoad={(event) => {
setImageEl(event.currentTarget); setImageMeta({
naturalWidth: event.currentTarget.naturalWidth,
naturalHeight: event.currentTarget.naturalHeight,
});
}} }}
src={imageUrl} src={imageUrl}
style={{ style={{
width: imageEl ? imageEl.naturalWidth * scale : "auto", width: baseWidth ? `${baseWidth}px` : "auto",
height: imageEl ? imageEl.naturalHeight * scale : "auto", height: baseHeight ? `${baseHeight}px` : "auto",
transform: `translate(${position.x}px, ${position.y}px)`, transform: `translate(${position.x}px, ${position.y}px) scale(${zoom})`,
transformOrigin: "top left", transformOrigin: "top left",
}} }}
/> />
@@ -221,7 +235,7 @@ export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropMod
<p className="text-xs text-slate-400">Output: 500x500 JPEG</p> <p className="text-xs text-slate-400">Output: 500x500 JPEG</p>
<button <button
className="rounded bg-sky-500 px-3 py-1.5 text-xs font-semibold text-slate-950 disabled:opacity-60" className="rounded bg-sky-500 px-3 py-1.5 text-xs font-semibold text-slate-950 disabled:opacity-60"
disabled={!imageEl || processing} disabled={!imageMeta || processing}
onClick={() => void handleApply()} onClick={() => void handleApply()}
type="button" type="button"
> >