Files
fluxer/fluxer_app/src/components/modals/ImageCropModal.tsx
2026-02-17 12:22:36 +00:00

960 lines
28 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import styles from '@app/components/modals/ImageCropModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Slider} from '@app/components/uikit/Slider';
import {Logger} from '@app/lib/Logger';
import {cropAnimatedImageWithWorkerPool} from '@app/workers/AnimatedImageCropWorkerManager';
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowClockwiseIcon, ImageSquareIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
const logger = new Logger('ImageCropModal');
interface Point {
x: number;
y: number;
}
interface Size {
width: number;
height: number;
}
interface DragBoundaries {
top: number;
bottom: number;
left: number;
right: number;
}
interface AnimatedImageCropOptions {
x: number;
y: number;
width: number;
height: number;
imageRotation?: number;
resizeWidth?: number | null;
resizeHeight?: number | null;
}
async function cropAnimatedImageWithWorker(
imageBytes: Uint8Array,
format: 'gif' | 'webp' | 'avif' | 'apng',
options: AnimatedImageCropOptions,
): Promise<Uint8Array> {
return cropAnimatedImageWithWorkerPool(imageBytes, format, options);
}
function clamp(value: number, min: number, max: number): number {
if (value < min) return min;
if (value > max) return max;
return value;
}
function inRange(value: number, start: number, end?: number): boolean {
if (end === undefined) {
end = start;
start = 0;
}
if (start > end) {
const tmp = start;
start = end;
end = tmp;
}
return value >= start && value < end;
}
function computeCropRect(containerWidth: number, containerHeight: number, aspectRatio: number): Size {
if (containerWidth <= 0 || containerHeight <= 0 || aspectRatio <= 0) {
return {width: containerWidth, height: containerHeight};
}
let width = containerWidth;
let height = width / aspectRatio;
if (height > containerHeight) {
height = containerHeight;
width = height * aspectRatio;
}
return {width, height};
}
function computeDragBoundaries(imageWidth: number, imageHeight: number, cropRect: Size): DragBoundaries {
const excessWidth = imageWidth - cropRect.width;
const excessHeight = imageHeight - cropRect.height;
const left = excessWidth !== 0 ? -Math.abs(excessWidth / 2) : 0;
const right = excessWidth !== 0 ? excessWidth / 2 : 0;
const bottom = excessHeight !== 0 ? -Math.abs(excessHeight / 2) : 0;
const top = excessHeight !== 0 ? excessHeight / 2 : 0;
return {top, bottom, left, right};
}
function clampTransformToBounds(x: number, y: number, bounds: DragBoundaries): Point {
return {
x: clamp(x, bounds.left, bounds.right),
y: clamp(y, bounds.bottom, bounds.top),
};
}
function rotatePoint({x, y}: Point, rotationDeg: number): Point {
const rot = ((rotationDeg % 360) + 360) % 360;
switch (rot) {
case 90:
return {x: y, y: -x};
case 180:
return {x: -x, y: -y};
case 270:
return {x: -y, y: x};
default:
return {x, y};
}
}
function computeDestinationOffset(cropWidthNatural: number, cropHeightNatural: number, rotationDeg: number): Point {
const rot = ((rotationDeg % 360) + 360) % 360;
switch (rot) {
case 0:
return {x: 0, y: 0};
case 90:
return {x: 0, y: -cropWidthNatural};
case 180:
return {x: -cropWidthNatural, y: -cropHeightNatural};
case 270:
return {x: -cropHeightNatural, y: 0};
default:
return {x: 0, y: 0};
}
}
interface ComputeCropGeometryInput {
image: HTMLImageElement;
displayDimensions: Size;
cropDimensions: Size;
cropOrigin: Point;
maxDimensions: Size;
rotationDeg?: number;
}
interface ComputeCropGeometryOutput {
sourceX: number;
sourceY: number;
sourceWidth: number;
sourceHeight: number;
destinationX: number;
destinationY: number;
destinationWidth: number;
destinationHeight: number;
canvasWidth: number;
canvasHeight: number;
}
function computeCropGeometry(input: ComputeCropGeometryInput): ComputeCropGeometryOutput {
const {image, displayDimensions, cropDimensions, cropOrigin, maxDimensions, rotationDeg = 0} = input;
const displayWidth = displayDimensions.width;
const displayHeight = displayDimensions.height;
const scale = image.naturalWidth / displayWidth;
const rotatedOrigin = rotatePoint(cropOrigin, rotationDeg);
const isRotated90Or270 = rotationDeg % 180 !== 0;
const cropWidthNatural = cropDimensions.width * scale;
const cropHeightNatural = cropDimensions.height * scale;
const canvasWidth = Math.min(cropWidthNatural, maxDimensions.width);
const canvasHeight = Math.min(cropHeightNatural, maxDimensions.height);
const halfCropMain = (isRotated90Or270 ? cropDimensions.height : cropDimensions.width) / 2;
const halfCropCross = (isRotated90Or270 ? cropDimensions.width : cropDimensions.height) / 2;
const sourceX = (displayWidth / 2 - halfCropMain - rotatedOrigin.x) * scale;
const sourceY = (displayHeight / 2 - halfCropCross - rotatedOrigin.y) * scale;
const sourceWidth = isRotated90Or270 ? cropHeightNatural : cropWidthNatural;
const sourceHeight = isRotated90Or270 ? cropWidthNatural : cropHeightNatural;
let {x: destX, y: destY} = computeDestinationOffset(cropWidthNatural, cropHeightNatural, rotationDeg);
if (maxDimensions.width < cropWidthNatural) {
destX *= maxDimensions.width / cropWidthNatural;
}
if (maxDimensions.height < cropHeightNatural) {
destY *= maxDimensions.height / cropHeightNatural;
}
const destinationWidth = isRotated90Or270 ? canvasHeight : canvasWidth;
const destinationHeight = isRotated90Or270 ? canvasWidth : canvasHeight;
return {
sourceX,
sourceY,
sourceWidth,
sourceHeight,
destinationX: destX,
destinationY: destY,
destinationWidth,
destinationHeight,
canvasWidth,
canvasHeight,
};
}
function containSize(srcW: number, srcH: number, boxW: number, boxH: number): {w: number; h: number} {
if (!(srcW > 0 && srcH > 0 && boxW > 0 && boxH > 0)) return {w: 0, h: 0};
const scale = Math.min(boxW / srcW, boxH / srcH);
const eff = Math.min(scale, 1);
return {
w: Math.max(1, Math.floor(srcW * eff)),
h: Math.max(1, Math.floor(srcH * eff)),
};
}
async function exportStaticImage(
image: HTMLImageElement,
displayDimensions: Size,
cropDimensions: Size,
cropOrigin: Point,
rotationDeg: number,
maxW: number,
maxH: number,
maxBytes: number,
): Promise<Blob> {
if (!image.complete || image.naturalWidth === 0 || image.naturalHeight === 0) {
throw new Error('Image not fully loaded');
}
const scale = image.naturalWidth / displayDimensions.width;
const cropNativeWidth = cropDimensions.width * scale;
const cropNativeHeight = cropDimensions.height * scale;
const {w: targetW, h: targetH} = containSize(cropNativeWidth, cropNativeHeight, maxW, maxH);
const geom = computeCropGeometry({
image,
displayDimensions,
cropDimensions,
cropOrigin,
maxDimensions: {width: targetW, height: targetH},
rotationDeg,
});
if (
!Number.isFinite(geom.canvasWidth) ||
!Number.isFinite(geom.canvasHeight) ||
geom.canvasWidth <= 0 ||
geom.canvasHeight <= 0
) {
throw new Error('Invalid canvas dimensions');
}
if (
!Number.isFinite(geom.sourceWidth) ||
!Number.isFinite(geom.sourceHeight) ||
geom.sourceWidth <= 0 ||
geom.sourceHeight <= 0
) {
throw new Error('Invalid source dimensions');
}
const canvas = document.createElement('canvas');
canvas.width = Math.round(geom.canvasWidth);
canvas.height = Math.round(geom.canvasHeight);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to get canvas context');
const clampedSourceX = Math.max(0, geom.sourceX);
const clampedSourceY = Math.max(0, geom.sourceY);
ctx.save();
ctx.rotate((rotationDeg * Math.PI) / 180);
ctx.drawImage(
image,
clampedSourceX,
clampedSourceY,
geom.sourceWidth,
geom.sourceHeight,
geom.destinationX,
geom.destinationY,
geom.destinationWidth,
geom.destinationHeight,
);
ctx.restore();
const blob: Blob = await new Promise((resolve, reject) => {
canvas.toBlob((b) => {
if (b) resolve(b);
else reject(new Error('Canvas toBlob failed'));
}, 'image/png');
});
if (blob.size > maxBytes) {
throw new Error(`Image size ${(blob.size / 1024).toFixed(1)} KB exceeds max ${(maxBytes / 1024).toFixed(0)} KB`);
}
return blob;
}
async function exportAnimatedImage(
image: HTMLImageElement,
displayDimensions: Size,
cropDimensions: Size,
cropOrigin: Point,
rotationDeg: number,
maxW: number,
maxH: number,
maxBytes: number,
src: string,
mimeType: string,
): Promise<Blob> {
const scale = image.naturalWidth / displayDimensions.width;
const cropNativeWidth = cropDimensions.width * scale;
const cropNativeHeight = cropDimensions.height * scale;
const {w: targetW, h: targetH} = containSize(cropNativeWidth, cropNativeHeight, maxW, maxH);
const geom = computeCropGeometry({
image,
displayDimensions,
cropDimensions,
cropOrigin,
maxDimensions: {width: targetW, height: targetH},
rotationDeg,
});
const response = await fetch(src);
if (!response.ok) {
throw new Error('Failed to fetch animated image data');
}
const buffer = await response.arrayBuffer();
const imageBytes = new Uint8Array(buffer);
const format = mimeType.toLowerCase().includes('gif')
? 'gif'
: mimeType.toLowerCase().includes('webp')
? 'webp'
: mimeType.toLowerCase().includes('avif')
? 'avif'
: 'apng';
const cropOptions = {
x: Math.max(0, Math.floor(geom.sourceX)),
y: Math.max(0, Math.floor(geom.sourceY)),
width: Math.max(1, Math.floor(geom.sourceWidth)),
height: Math.max(1, Math.floor(geom.sourceHeight)),
imageRotation: rotationDeg,
resizeWidth: Math.floor(targetW),
resizeHeight: Math.floor(targetH),
};
snapCropOptionsToImageBounds(cropOptions, image);
const resultBytes = await cropAnimatedImageWithWorker(imageBytes, format, cropOptions);
const resultBlob = new Blob([new Uint8Array(resultBytes)], {type: mimeType});
if (resultBlob.size === 0) {
throw new Error('Empty animated image blob returned');
}
if (resultBlob.size > maxBytes) {
throw new Error(
`Animated image size ${(resultBlob.size / 1024).toFixed(1)} KB exceeds max ${(maxBytes / 1024).toFixed(0)} KB`,
);
}
return resultBlob;
}
function snapCropOptionsToImageBounds(options: AnimatedImageCropOptions, image: HTMLImageElement): void {
const EPS = 2;
const naturalWidth = image.naturalWidth;
const naturalHeight = image.naturalHeight;
if (Math.abs(options.x) <= EPS) {
options.x = 0;
}
if (Math.abs(options.y) <= EPS) {
options.y = 0;
}
const rightEdge = options.x + options.width;
if (Math.abs(rightEdge - naturalWidth) <= EPS) {
options.width = naturalWidth - options.x;
}
const bottomEdge = options.y + options.height;
if (Math.abs(bottomEdge - naturalHeight) <= EPS) {
options.height = naturalHeight - options.y;
}
if (options.x === 0 && Math.abs(options.width - naturalWidth) <= EPS) {
options.width = naturalWidth;
}
if (options.y === 0 && Math.abs(options.height - naturalHeight) <= EPS) {
options.height = naturalHeight;
}
if (options.resizeWidth != null && Math.abs(options.resizeWidth - naturalWidth) <= EPS) {
options.resizeWidth = naturalWidth;
}
if (options.resizeHeight != null && Math.abs(options.resizeHeight - naturalHeight) <= EPS) {
options.resizeHeight = naturalHeight;
}
}
function hasEditsFrom(zoomRatio: number, rotation: number, transform: Point, heightRatio: number): boolean {
return zoomRatio !== 1 || rotation !== 0 || transform.x !== 0 || transform.y !== 0 || heightRatio !== 1;
}
interface ImageCropModalProps {
imageUrl: string;
sourceMimeType: string;
onCropComplete: (croppedImageBlob: Blob) => void;
onSkip?: () => void;
title: React.ReactNode;
description: React.ReactNode;
saveButtonLabel: React.ReactNode;
errorMessage: string;
aspectRatio: number;
cropShape?: 'rect' | 'round';
maxWidth: number;
maxHeight: number;
sizeLimitBytes: number;
minHeightRatio?: number;
maxHeightRatio?: number;
}
export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
({
imageUrl,
sourceMimeType,
onCropComplete,
onSkip,
title,
description,
saveButtonLabel,
errorMessage,
aspectRatio,
cropShape = 'rect',
maxWidth,
maxHeight,
sizeLimitBytes,
minHeightRatio,
maxHeightRatio,
}) => {
const {t} = useLingui();
const imageRef = useRef<HTMLImageElement | null>(null);
const cropperContainerRef = useRef<HTMLDivElement | null>(null);
const transformRef = useRef<Point>({x: 0, y: 0});
const [displayDimensions, setDisplayDimensions] = useState<Size | null>(null);
const [cropDimensions, setCropDimensions] = useState<Size | null>(null);
const [dragBoundaries, setDragBoundaries] = useState<DragBoundaries>({
top: 0,
bottom: 0,
left: 0,
right: 0,
});
const [zoomRatio, setZoomRatio] = useState(1);
const [rotation, setRotation] = useState(0);
const [hasEdits, setHasEdits] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<Point>({x: 0, y: 0});
const [loadError, setLoadError] = useState(false);
const [sliderKey, setSliderKey] = useState(0);
const [heightRatio, setHeightRatio] = useState(1);
const [heightSliderKey, setHeightSliderKey] = useState(0);
const isRound = cropShape === 'round';
const isAnimated = ['image/gif', 'image/webp', 'image/avif', 'image/png'].some((type) =>
sourceMimeType.toLowerCase().includes(type.replace('image/', '')),
);
const MIN_ZOOM = 1;
const MAX_ZOOM = 3;
const effectiveMinHeightRatio = !isRound ? (minHeightRatio ?? 1) : 1;
const effectiveMaxHeightRatio = !isRound ? (maxHeightRatio ?? 1) : 1;
const heightSliderEnabled = !isRound && effectiveMinHeightRatio < effectiveMaxHeightRatio;
const applyTransform = useCallback((x: number, y: number, rotationDeg: number) => {
transformRef.current = {x, y};
const img = imageRef.current;
if (!img) return;
img.style.transform = `translate3d(calc(-50% + ${x}px), calc(-50% + ${y}px), 0) rotate(${rotationDeg}deg)`;
}, []);
const recalculateLayout = useCallback(
(nextHeightRatio: number, resetTransform: boolean) => {
const img = imageRef.current;
const container = cropperContainerRef.current;
if (!img || !container) return;
const containerRect = container.getBoundingClientRect();
const containerWidth = containerRect.width;
const containerHeight = containerRect.height;
const naturalWidth = img.naturalWidth;
const naturalHeight = img.naturalHeight;
if (!naturalWidth || !naturalHeight || !containerWidth || !containerHeight) {
return;
}
const scale = Math.min(containerWidth / naturalWidth, containerHeight / naturalHeight);
const fittedWidth = naturalWidth * scale;
const fittedHeight = naturalHeight * scale;
const baseAspect = isRound ? 1 : aspectRatio;
const clampedHeightRatio = heightSliderEnabled
? clamp(nextHeightRatio, effectiveMinHeightRatio, effectiveMaxHeightRatio)
: 1;
const effectiveAspect = baseAspect / clampedHeightRatio;
const cropRect = computeCropRect(fittedWidth, fittedHeight, effectiveAspect);
const zoom = resetTransform ? 1 : zoomRatio;
const rotationDeg = resetTransform ? 0 : rotation;
const scaledWidth = fittedWidth * zoom;
const scaledHeight = fittedHeight * zoom;
const bounds = computeDragBoundaries(scaledWidth, scaledHeight, cropRect);
const nextTransform = resetTransform ? {x: 0, y: 0} : transformRef.current;
const clampedTransform = clampTransformToBounds(nextTransform.x, nextTransform.y, bounds);
applyTransform(clampedTransform.x, clampedTransform.y, rotationDeg);
setDisplayDimensions({width: fittedWidth, height: fittedHeight});
setCropDimensions(cropRect);
setDragBoundaries(bounds);
setZoomRatio(zoom);
setRotation(rotationDeg);
setHeightRatio(clampedHeightRatio);
setHasEdits(hasEditsFrom(zoom, rotationDeg, clampedTransform, clampedHeightRatio));
},
[
applyTransform,
aspectRatio,
effectiveMaxHeightRatio,
effectiveMinHeightRatio,
heightSliderEnabled,
isRound,
rotation,
zoomRatio,
],
);
const handleImageLoad = useCallback(() => {
recalculateLayout(heightRatio, true);
}, [heightRatio, recalculateLayout]);
const handleMouseDown: React.MouseEventHandler<HTMLImageElement> = useCallback((event) => {
event.preventDefault();
const {x, y} = transformRef.current;
setIsDragging(true);
setDragStart({
x: event.clientX - x,
y: event.clientY - y,
});
}, []);
const handleMouseMove = useCallback(
(event: MouseEvent) => {
if (!isDragging || !displayDimensions || !cropDimensions) return;
const newX = event.clientX - dragStart.x;
const newY = event.clientY - dragStart.y;
const clamped = clampTransformToBounds(newX, newY, dragBoundaries);
applyTransform(clamped.x, clamped.y, rotation);
setHasEdits(hasEditsFrom(zoomRatio, rotation, clamped, heightRatio));
},
[
applyTransform,
cropDimensions,
dragBoundaries,
dragStart.x,
dragStart.y,
displayDimensions,
heightRatio,
isDragging,
rotation,
zoomRatio,
],
);
const handleMouseUp = useCallback(() => {
if (!isDragging) return;
setIsDragging(false);
const transform = transformRef.current;
setHasEdits(hasEditsFrom(zoomRatio, rotation, transform, heightRatio));
}, [heightRatio, isDragging, rotation, zoomRatio]);
const handleZoomChange = useCallback(
(ratio: number) => {
if (!displayDimensions || !cropDimensions) return;
const clampedZoom = clamp(ratio, MIN_ZOOM, MAX_ZOOM);
const scaledWidth = displayDimensions.width * clampedZoom;
const scaledHeight = displayDimensions.height * clampedZoom;
const newBounds = computeDragBoundaries(scaledWidth, scaledHeight, cropDimensions);
let {x, y} = transformRef.current;
if (!inRange(x, newBounds.right, newBounds.left) || !inRange(y, newBounds.top, newBounds.bottom)) {
const clamped = clampTransformToBounds(x, y, newBounds);
x = clamped.x;
y = clamped.y;
applyTransform(x, y, rotation);
}
setZoomRatio(clampedZoom);
setDragBoundaries(newBounds);
setHasEdits(hasEditsFrom(clampedZoom, rotation, {x, y}, heightRatio));
},
[MIN_ZOOM, MAX_ZOOM, applyTransform, cropDimensions, displayDimensions, heightRatio, rotation],
);
const handleHeightRatioChange = useCallback(
(value: number) => {
if (!heightSliderEnabled) return;
recalculateLayout(value, false);
},
[heightSliderEnabled, recalculateLayout],
);
const handleRotate = useCallback(() => {
if (!displayDimensions || !cropDimensions) return;
const nextRotation = (rotation + 90) % 360;
const current = transformRef.current;
const rotatedTransform = rotatePoint(current, 90);
const scaledWidth = displayDimensions.width * zoomRatio;
const scaledHeight = displayDimensions.height * zoomRatio;
const newBounds = computeDragBoundaries(scaledWidth, scaledHeight, cropDimensions);
const clamped = clampTransformToBounds(rotatedTransform.x, rotatedTransform.y, newBounds);
applyTransform(clamped.x, clamped.y, nextRotation);
setRotation(nextRotation);
setDragBoundaries(newBounds);
setHasEdits(hasEditsFrom(zoomRatio, nextRotation, clamped, heightRatio));
}, [applyTransform, cropDimensions, displayDimensions, heightRatio, rotation, zoomRatio]);
const handleReset = useCallback(() => {
if (!displayDimensions || !cropDimensions) return;
recalculateLayout(1, true);
setSliderKey((k) => k + 1);
setHeightSliderKey((k) => k + 1);
}, [cropDimensions, displayDimensions, recalculateLayout]);
const handleSave = useCallback(async () => {
const img = imageRef.current;
if (!img || !displayDimensions || !cropDimensions) return;
try {
setIsProcessing(true);
const scaledDisplayDimensions: Size = {
width: displayDimensions.width * zoomRatio,
height: displayDimensions.height * zoomRatio,
};
const outBlob = isAnimated
? await exportAnimatedImage(
img,
scaledDisplayDimensions,
cropDimensions,
transformRef.current,
rotation,
maxWidth,
maxHeight,
sizeLimitBytes,
imageUrl,
sourceMimeType,
)
: await exportStaticImage(
img,
scaledDisplayDimensions,
cropDimensions,
transformRef.current,
rotation,
maxWidth,
maxHeight,
sizeLimitBytes,
);
onCropComplete(outBlob);
ModalActionCreators.pop();
} catch (error) {
logger.error('Error cropping image:', error);
const message = error instanceof Error && error.message ? error.message : errorMessage;
ToastActionCreators.createToast({
type: 'error',
children: message,
});
} finally {
setIsProcessing(false);
}
}, [
cropDimensions,
displayDimensions,
errorMessage,
imageUrl,
isAnimated,
maxHeight,
maxWidth,
onCropComplete,
rotation,
sizeLimitBytes,
zoomRatio,
]);
const handleSkip = useCallback(() => {
if (onSkip) onSkip();
ModalActionCreators.pop();
}, [onSkip]);
const handleCancel = useCallback(() => {
ModalActionCreators.pop();
}, []);
useEffect(() => {
const onMouseMove = (e: MouseEvent) => handleMouseMove(e);
const onMouseUp = () => handleMouseUp();
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('resize', handleImageLoad);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('resize', handleImageLoad);
};
}, [handleImageLoad, handleMouseMove, handleMouseUp]);
useEffect(() => {
let isSliderDragging = false;
const onSliderDragStart = () => {
isSliderDragging = true;
};
const onSliderDragEnd = () => {
setTimeout(() => {
isSliderDragging = false;
}, 50);
};
const onClickCapture = (e: MouseEvent) => {
if (isSliderDragging) {
e.stopPropagation();
}
};
document.addEventListener('slider-drag-start', onSliderDragStart);
document.addEventListener('slider-drag-end', onSliderDragEnd);
window.addEventListener('click', onClickCapture, {capture: true});
return () => {
document.removeEventListener('slider-drag-start', onSliderDragStart);
document.removeEventListener('slider-drag-end', onSliderDragEnd);
window.removeEventListener('click', onClickCapture, {capture: true});
};
}, []);
const imageStyle: React.CSSProperties = useMemo(() => {
if (!displayDimensions) return {};
const width = displayDimensions.width * zoomRatio;
const height = displayDimensions.height * zoomRatio;
return {
width,
height,
minWidth: width,
minHeight: height,
};
}, [displayDimensions, zoomRatio]);
const exportDisabled = isProcessing || loadError;
return (
<Modal.Root size="medium">
<Modal.Header title={title} />
<Modal.Content className={styles.content}>
<div className={styles.description}>{description}</div>
<div className={styles.cropperContainer} ref={cropperContainerRef}>
<img
ref={imageRef}
src={imageUrl}
alt={t`Crop preview`}
className={styles.image}
style={{
opacity: displayDimensions ? 1 : 0,
transform: `translate3d(calc(-50% + ${transformRef.current.x}px), calc(-50% + ${
transformRef.current.y
}px), 0) rotate(${rotation}deg)`,
...imageStyle,
}}
onLoad={handleImageLoad}
onError={() => setLoadError(true)}
onMouseDown={handleMouseDown}
draggable={false}
crossOrigin="anonymous"
/>
{cropDimensions && !isRound && (
<div
className={styles.overlayRect}
style={{
width: cropDimensions.width,
height: cropDimensions.height,
}}
aria-hidden
/>
)}
{cropDimensions && isRound && (
<div aria-hidden className={styles.roundOverlay}>
<div
className={styles.roundMask}
style={{
width: cropDimensions.width,
height: cropDimensions.height,
}}
/>
</div>
)}
</div>
<div className={styles.controlsContainer}>
<div className={styles.sliderGroup}>
<div className={styles.sliderContainer}>
<div className={styles.sliderLabel}>
<Trans>Zoom</Trans>
</div>
<div
className={styles.zoomSliderContainer}
role="none"
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<ImageSquareIcon size={12} weight="fill" className={styles.zoomIconSmall} />
<div className={styles.sliderWrapper}>
<Slider
key={sliderKey}
value={zoomRatio}
minValue={MIN_ZOOM}
maxValue={MAX_ZOOM}
factoryDefaultValue={1}
step={Math.min(0.01, (MAX_ZOOM - MIN_ZOOM) / 200)}
onValueChange={(z) => handleZoomChange(z)}
asValueChanges={(z) => handleZoomChange(z)}
defaultValue={1}
/>
</div>
<ImageSquareIcon size={24} weight="fill" className={styles.zoomIconLarge} />
</div>
</div>
{heightSliderEnabled && (
<div className={styles.sliderContainer}>
<div className={styles.sliderLabel}>
<Trans>Height</Trans>
</div>
<div
className={styles.heightSliderContainer}
role="none"
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<div className={styles.heightIconShort} aria-hidden />
<div className={styles.sliderWrapper}>
<Slider
key={heightSliderKey}
value={heightRatio}
minValue={effectiveMinHeightRatio}
maxValue={effectiveMaxHeightRatio}
factoryDefaultValue={1}
step={0.01}
onValueChange={(v) => handleHeightRatioChange(v)}
asValueChanges={(v) => handleHeightRatioChange(v)}
defaultValue={1}
/>
</div>
<div className={styles.heightIconTall} aria-hidden />
</div>
</div>
)}
</div>
<FocusRing offset={-2} enabled={!isProcessing}>
<button
type="button"
className={styles.rotateButton}
onClick={handleRotate}
disabled={isProcessing}
title={t`Rotate Clockwise`}
>
<ArrowClockwiseIcon size={24} weight="regular" className={styles.rotateIcon} />
</button>
</FocusRing>
</div>
</Modal.Content>
<Modal.Footer>
<div className={styles.footer}>
<Button variant="secondary" onClick={handleReset} disabled={isProcessing || !hasEdits}>
<Trans>Reset</Trans>
</Button>
<div className={styles.footerActions}>
<Button variant="secondary" onClick={handleCancel} disabled={isProcessing}>
<Trans>Cancel</Trans>
</Button>
{onSkip && (
<Button variant="secondary" onClick={handleSkip} disabled={isProcessing}>
<Trans>Skip Cropping</Trans>
</Button>
)}
<Button onClick={handleSave} disabled={exportDisabled} submitting={isProcessing}>
{saveButtonLabel}
</Button>
</div>
</div>
</Modal.Footer>
</Modal.Root>
);
},
);