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) {
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user