960 lines
28 KiB
TypeScript
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>
|
|
);
|
|
},
|
|
);
|