refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -17,23 +17,22 @@
* 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 React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import styles from '~/components/modals/ImageCropModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Slider} from '~/components/uikit/Slider';
import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
interface CropGifWorkerMessage {
type: number;
result?: Uint8Array;
error?: string;
}
const logger = new Logger('ImageCropModal');
interface Point {
x: number;
@@ -50,9 +49,7 @@ interface DragBoundaries {
right: number;
}
export type GifBytes = Uint8Array;
interface GifCropOptions {
interface AnimatedImageCropOptions {
x: number;
y: number;
width: number;
@@ -62,59 +59,12 @@ interface GifCropOptions {
resizeHeight?: number | null;
}
async function cropGifWithWorker(gif: GifBytes, options: GifCropOptions): Promise<GifBytes> {
const {x, y, width, height, imageRotation = 0, resizeWidth = null, resizeHeight = null} = options;
return new Promise<GifBytes>((resolve, reject) => {
const worker = new Worker(new URL('../../workers/gifCrop.worker.ts', import.meta.url), {
type: 'module',
});
worker.addEventListener(
'message',
(event: MessageEvent<CropGifWorkerMessage>) => {
const msg = event.data;
if (msg.type === 1) {
worker.terminate();
if (msg.result) {
resolve(msg.result);
} else {
reject(new Error('Empty result from worker'));
}
} else if (msg.type === 2) {
worker.terminate();
reject(new Error(msg.error || 'Unknown error from worker'));
}
},
{once: true},
);
worker.addEventListener('error', (error) => {
worker.terminate();
reject(error);
});
const transferables: Array<Transferable> = [];
if (gif.buffer) {
transferables.push(gif.buffer);
}
worker.postMessage(
{
type: 0,
gif,
x,
y,
width,
height,
imageRotation,
resizeWidth,
resizeHeight,
},
transferables,
);
});
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 {
@@ -370,7 +320,7 @@ async function exportStaticImage(
return blob;
}
async function exportAnimatedGif(
async function exportAnimatedImage(
image: HTMLImageElement,
displayDimensions: Size,
cropDimensions: Size,
@@ -380,6 +330,7 @@ async function exportAnimatedGif(
maxH: number,
maxBytes: number,
src: string,
mimeType: string,
): Promise<Blob> {
const scale = image.naturalWidth / displayDimensions.width;
const cropNativeWidth = cropDimensions.width * scale;
@@ -398,10 +349,18 @@ async function exportAnimatedGif(
const response = await fetch(src);
if (!response.ok) {
throw new Error('Failed to fetch GIF data');
throw new Error('Failed to fetch animated image data');
}
const buffer = await response.arrayBuffer();
const gifBytes = new Uint8Array(buffer);
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)),
@@ -414,23 +373,23 @@ async function exportAnimatedGif(
};
snapCropOptionsToImageBounds(cropOptions, image);
const resultBytes = await cropGifWithWorker(gifBytes, cropOptions);
const resultBlob = new Blob([new Uint8Array(resultBytes)], {type: 'image/gif'});
const resultBytes = await cropAnimatedImageWithWorker(imageBytes, format, cropOptions);
const resultBlob = new Blob([new Uint8Array(resultBytes)], {type: mimeType});
if (resultBlob.size === 0) {
throw new Error('Empty GIF blob returned');
throw new Error('Empty animated image blob returned');
}
if (resultBlob.size > maxBytes) {
throw new Error(
`GIF size ${(resultBlob.size / 1024).toFixed(1)} KB exceeds max ${(maxBytes / 1024).toFixed(0)} KB`,
`Animated image size ${(resultBlob.size / 1024).toFixed(1)} KB exceeds max ${(maxBytes / 1024).toFixed(0)} KB`,
);
}
return resultBlob;
}
function snapCropOptionsToImageBounds(options: GifCropOptions, image: HTMLImageElement): void {
function snapCropOptionsToImageBounds(options: AnimatedImageCropOptions, image: HTMLImageElement): void {
const EPS = 2;
const naturalWidth = image.naturalWidth;
const naturalHeight = image.naturalHeight;
@@ -508,32 +467,34 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
maxHeightRatio,
}) => {
const {t} = useLingui();
const imageRef = React.useRef<HTMLImageElement | null>(null);
const cropperContainerRef = React.useRef<HTMLDivElement | null>(null);
const transformRef = React.useRef<Point>({x: 0, y: 0});
const imageRef = useRef<HTMLImageElement | null>(null);
const cropperContainerRef = useRef<HTMLDivElement | null>(null);
const transformRef = useRef<Point>({x: 0, y: 0});
const [displayDimensions, setDisplayDimensions] = React.useState<Size | null>(null);
const [cropDimensions, setCropDimensions] = React.useState<Size | null>(null);
const [dragBoundaries, setDragBoundaries] = React.useState<DragBoundaries>({
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] = React.useState(1);
const [rotation, setRotation] = React.useState(0);
const [hasEdits, setHasEdits] = React.useState(false);
const [isProcessing, setIsProcessing] = React.useState(false);
const [isDragging, setIsDragging] = React.useState(false);
const [dragStart, setDragStart] = React.useState<Point>({x: 0, y: 0});
const [loadError, setLoadError] = React.useState(false);
const [sliderKey, setSliderKey] = React.useState(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] = React.useState(1);
const [heightSliderKey, setHeightSliderKey] = React.useState(0);
const [heightRatio, setHeightRatio] = useState(1);
const [heightSliderKey, setHeightSliderKey] = useState(0);
const isRound = cropShape === 'round';
const isGif = sourceMimeType.toLowerCase() === 'image/gif';
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;
@@ -542,14 +503,14 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
const effectiveMaxHeightRatio = !isRound ? (maxHeightRatio ?? 1) : 1;
const heightSliderEnabled = !isRound && effectiveMinHeightRatio < effectiveMaxHeightRatio;
const applyTransform = React.useCallback((x: number, y: number, rotationDeg: number) => {
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 = React.useCallback(
const recalculateLayout = useCallback(
(nextHeightRatio: number, resetTransform: boolean) => {
const img = imageRef.current;
const container = cropperContainerRef.current;
@@ -611,11 +572,11 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
],
);
const handleImageLoad = React.useCallback(() => {
const handleImageLoad = useCallback(() => {
recalculateLayout(heightRatio, true);
}, [heightRatio, recalculateLayout]);
const handleMouseDown: React.MouseEventHandler<HTMLImageElement> = React.useCallback((event) => {
const handleMouseDown: React.MouseEventHandler<HTMLImageElement> = useCallback((event) => {
event.preventDefault();
const {x, y} = transformRef.current;
setIsDragging(true);
@@ -625,7 +586,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
});
}, []);
const handleMouseMove = React.useCallback(
const handleMouseMove = useCallback(
(event: MouseEvent) => {
if (!isDragging || !displayDimensions || !cropDimensions) return;
const newX = event.clientX - dragStart.x;
@@ -648,14 +609,14 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
],
);
const handleMouseUp = React.useCallback(() => {
const handleMouseUp = useCallback(() => {
if (!isDragging) return;
setIsDragging(false);
const transform = transformRef.current;
setHasEdits(hasEditsFrom(zoomRatio, rotation, transform, heightRatio));
}, [heightRatio, isDragging, rotation, zoomRatio]);
const handleZoomChange = React.useCallback(
const handleZoomChange = useCallback(
(ratio: number) => {
if (!displayDimensions || !cropDimensions) return;
@@ -681,7 +642,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
[MIN_ZOOM, MAX_ZOOM, applyTransform, cropDimensions, displayDimensions, heightRatio, rotation],
);
const handleHeightRatioChange = React.useCallback(
const handleHeightRatioChange = useCallback(
(value: number) => {
if (!heightSliderEnabled) return;
recalculateLayout(value, false);
@@ -689,7 +650,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
[heightSliderEnabled, recalculateLayout],
);
const handleRotate = React.useCallback(() => {
const handleRotate = useCallback(() => {
if (!displayDimensions || !cropDimensions) return;
const nextRotation = (rotation + 90) % 360;
@@ -708,7 +669,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
setHasEdits(hasEditsFrom(zoomRatio, nextRotation, clamped, heightRatio));
}, [applyTransform, cropDimensions, displayDimensions, heightRatio, rotation, zoomRatio]);
const handleReset = React.useCallback(() => {
const handleReset = useCallback(() => {
if (!displayDimensions || !cropDimensions) return;
recalculateLayout(1, true);
@@ -716,7 +677,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
setHeightSliderKey((k) => k + 1);
}, [cropDimensions, displayDimensions, recalculateLayout]);
const handleSave = React.useCallback(async () => {
const handleSave = useCallback(async () => {
const img = imageRef.current;
if (!img || !displayDimensions || !cropDimensions) return;
@@ -728,8 +689,8 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
height: displayDimensions.height * zoomRatio,
};
const outBlob = isGif
? await exportAnimatedGif(
const outBlob = isAnimated
? await exportAnimatedImage(
img,
scaledDisplayDimensions,
cropDimensions,
@@ -739,6 +700,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
maxHeight,
sizeLimitBytes,
imageUrl,
sourceMimeType,
)
: await exportStaticImage(
img,
@@ -754,7 +716,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
onCropComplete(outBlob);
ModalActionCreators.pop();
} catch (error) {
console.error('Error cropping image:', error);
logger.error('Error cropping image:', error);
const message = error instanceof Error && error.message ? error.message : errorMessage;
ToastActionCreators.createToast({
type: 'error',
@@ -768,7 +730,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
displayDimensions,
errorMessage,
imageUrl,
isGif,
isAnimated,
maxHeight,
maxWidth,
onCropComplete,
@@ -777,16 +739,16 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
zoomRatio,
]);
const handleSkip = React.useCallback(() => {
const handleSkip = useCallback(() => {
if (onSkip) onSkip();
ModalActionCreators.pop();
}, [onSkip]);
const handleCancel = React.useCallback(() => {
const handleCancel = useCallback(() => {
ModalActionCreators.pop();
}, []);
React.useEffect(() => {
useEffect(() => {
const onMouseMove = (e: MouseEvent) => handleMouseMove(e);
const onMouseUp = () => handleMouseUp();
window.addEventListener('mousemove', onMouseMove);
@@ -800,7 +762,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
};
}, [handleImageLoad, handleMouseMove, handleMouseUp]);
React.useEffect(() => {
useEffect(() => {
let isSliderDragging = false;
const onSliderDragStart = () => {
@@ -830,7 +792,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
};
}, []);
const imageStyle: React.CSSProperties = React.useMemo(() => {
const imageStyle: React.CSSProperties = useMemo(() => {
if (!displayDimensions) return {};
const width = displayDimensions.width * zoomRatio;
const height = displayDimensions.height * zoomRatio;
@@ -855,7 +817,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
<img
ref={imageRef}
src={imageUrl}
alt="crop"
alt={t`Crop preview`}
className={styles.image}
style={{
opacity: displayDimensions ? 1 : 0,
@@ -964,7 +926,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
className={styles.rotateButton}
onClick={handleRotate}
disabled={isProcessing}
title={t`Rotate clockwise`}
title={t`Rotate Clockwise`}
>
<ArrowClockwiseIcon size={24} weight="regular" className={styles.rotateIcon} />
</button>