web(avatar-crop): smooth zoom via transform scale with stable cover sizing
All checks were successful
CI / test (push) Successful in 46s
All checks were successful
CI / test (push) Successful in 46s
This commit is contained in:
@@ -16,7 +16,7 @@ const MASK_RATIO = 0.88;
|
||||
|
||||
export function AvatarCropModal({ open, file, onCancel, onApply }: AvatarCropModalProps) {
|
||||
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 [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<HTMLDivElement>) {
|
||||
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<HTMLDivElement>) {
|
||||
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<Blob | null>((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
|
||||
<p className="text-xs text-slate-400">Output: 500x500 JPEG</p>
|
||||
<button
|
||||
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()}
|
||||
type="button"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user