/* * 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 . */ 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 { 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 { 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 { 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 = observer( ({ imageUrl, sourceMimeType, onCropComplete, onSkip, title, description, saveButtonLabel, errorMessage, aspectRatio, cropShape = 'rect', maxWidth, maxHeight, sizeLimitBytes, minHeightRatio, maxHeightRatio, }) => { const {t} = useLingui(); const imageRef = useRef(null); const cropperContainerRef = useRef(null); const transformRef = useRef({x: 0, y: 0}); const [displayDimensions, setDisplayDimensions] = useState(null); const [cropDimensions, setCropDimensions] = useState(null); const [dragBoundaries, setDragBoundaries] = useState({ 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({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 = 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 (
{description}
{t`Crop setLoadError(true)} onMouseDown={handleMouseDown} draggable={false} crossOrigin="anonymous" /> {cropDimensions && !isRound && (
)} {cropDimensions && isRound && (
)}
Zoom
{ e.stopPropagation(); }} >
handleZoomChange(z)} asValueChanges={(z) => handleZoomChange(z)} defaultValue={1} />
{heightSliderEnabled && (
Height
{ e.stopPropagation(); }} >
handleHeightRatioChange(v)} asValueChanges={(v) => handleHeightRatioChange(v)} defaultValue={1} />
)}
{onSkip && ( )}
); }, );