diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 6c6c879b..267773f1 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -681,6 +681,11 @@ "crop_background_image": "Crop Background Image", "crop": "Crop", "cropping": "Cropping…", + "crop_area": "Crop area", + "resize_handle_nw": "Resize handle - northwest corner", + "resize_handle_ne": "Resize handle - northeast corner", + "resize_handle_sw": "Resize handle - southwest corner", + "resize_handle_se": "Resize handle - southeast corner", "zoom_in": "Zoom In", "zoom_out": "Zoom Out", "image_crop_failure": "Failed to crop image. Please try again.", diff --git a/src/renderer/src/components/image-cropper/image-cropper.scss b/src/renderer/src/components/image-cropper/image-cropper.scss index 38a292e6..5aec499c 100644 --- a/src/renderer/src/components/image-cropper/image-cropper.scss +++ b/src/renderer/src/components/image-cropper/image-cropper.scss @@ -105,4 +105,3 @@ justify-content: flex-end; } } - diff --git a/src/renderer/src/components/image-cropper/image-cropper.tsx b/src/renderer/src/components/image-cropper/image-cropper.tsx index 12a387d2..1d163b42 100644 --- a/src/renderer/src/components/image-cropper/image-cropper.tsx +++ b/src/renderer/src/components/image-cropper/image-cropper.tsx @@ -51,37 +51,59 @@ export function ImageCropper({ return imagePath.startsWith("local:") ? imagePath : `local:${imagePath}`; }; - - - const calculateInitialCropArea = useCallback(() => { + const calculateContainerBounds = useCallback(() => { const container = containerRef.current; - if (!container) return; + if (!container) return null; const containerRect = container.getBoundingClientRect(); - if (containerRect.width === 0 || containerRect.height === 0) return; + if (containerRect.width === 0 || containerRect.height === 0) return null; - const maxWidth = containerRect.width - 40; - const maxHeight = containerRect.height - 200; + return { + maxWidth: containerRect.width - 40, + maxHeight: containerRect.height - 200, + }; + }, []); - if (maxWidth <= 0 || maxHeight <= 0) return; + const calculateDisplayDimensions = useCallback( + (bounds: { maxWidth: number; maxHeight: number } | null) => { + if (!imageLoaded) return { width: 0, height: 0 }; - const imageAspect = imageSize.width / imageSize.height; - let displayWidth = imageSize.width * zoom; - let displayHeight = imageSize.height * zoom; + if (!bounds) { + return { + width: imageSize.width * zoom, + height: imageSize.height * zoom, + }; + } - if (displayWidth > maxWidth) { - displayWidth = maxWidth; - displayHeight = displayWidth / imageAspect; - } - if (displayHeight > maxHeight) { - displayHeight = maxHeight; - displayWidth = displayHeight * imageAspect; - } + const imageAspect = imageSize.width / imageSize.height; + let displayWidth = imageSize.width * zoom; + let displayHeight = imageSize.height * zoom; + + if (displayWidth > bounds.maxWidth) { + displayWidth = bounds.maxWidth; + displayHeight = displayWidth / imageAspect; + } + if (displayHeight > bounds.maxHeight) { + displayHeight = bounds.maxHeight; + displayWidth = displayHeight * imageAspect; + } + + return { width: displayWidth, height: displayHeight }; + }, + [imageLoaded, imageSize] + ); + + const calculateInitialCropArea = useCallback(() => { + const bounds = calculateContainerBounds(); + if (!bounds || bounds.maxWidth <= 0 || bounds.maxHeight <= 0) return; + + const { width: displayWidth, height: displayHeight } = + calculateDisplayDimensions(bounds); const effectiveAspectRatio = circular ? 1 : aspectRatio; let cropWidth: number; let cropHeight: number; - + if (effectiveAspectRatio) { const displayAspect = displayWidth / displayHeight; if (displayAspect > effectiveAspectRatio) { @@ -103,7 +125,17 @@ export function ImageCropper({ width: cropWidth, height: cropHeight, }); - }, [imageSize, aspectRatio, circular]); + }, [ + calculateContainerBounds, + calculateDisplayDimensions, + aspectRatio, + circular, + ]); + + const getDisplaySize = useCallback(() => { + const bounds = calculateContainerBounds(); + return calculateDisplayDimensions(bounds); + }, [calculateContainerBounds, calculateDisplayDimensions]); useEffect(() => { const img = new Image(); @@ -159,40 +191,6 @@ export function ImageCropper({ }; }, [imageLoaded, imageSize, calculateInitialCropArea]); - const getDisplaySize = useCallback(() => { - if (!imageLoaded) return { width: 0, height: 0 }; - - const container = containerRef.current; - if (!container) { - return { - width: imageSize.width * zoom, - height: imageSize.height * zoom, - }; - } - - const containerRect = container.getBoundingClientRect(); - const maxWidth = containerRect.width - 40; - const maxHeight = containerRect.height - 200; - - const imageAspect = imageSize.width / imageSize.height; - let displayWidth = imageSize.width * zoom; - let displayHeight = imageSize.height * zoom; - - if (displayWidth > maxWidth) { - displayWidth = maxWidth; - displayHeight = displayWidth / imageAspect; - } - if (displayHeight > maxHeight) { - displayHeight = maxHeight; - displayWidth = displayHeight * imageAspect; - } - - return { - width: displayWidth, - height: displayHeight, - }; - }, [imageLoaded, imageSize]); - const getRealCropArea = (): CropArea => { if (!imageLoaded || imageSize.width === 0 || imageSize.height === 0) { return cropArea; @@ -214,6 +212,27 @@ export function ImageCropper({ }; }; + const adjustAspectRatio = useCallback( + (width: number, height: number, ratio: number) => { + const currentRatio = width / height; + if (currentRatio > ratio) { + return { width, height: width / ratio }; + } + return { width: height * ratio, height }; + }, + [] + ); + + const calculateMinSize = useCallback( + (effectiveAspectRatio: number | undefined) => { + if (!effectiveAspectRatio) return minCropSize; + return effectiveAspectRatio > 1 + ? minCropSize * effectiveAspectRatio + : minCropSize / effectiveAspectRatio; + }, + [minCropSize] + ); + const constrainCropArea = useCallback( (area: CropArea): CropArea => { const displaySize = getDisplaySize(); @@ -222,32 +241,23 @@ export function ImageCropper({ const effectiveAspectRatio = circular ? 1 : aspectRatio; if (effectiveAspectRatio) { - const currentRatio = width / height; - if (currentRatio > effectiveAspectRatio) { - height = width / effectiveAspectRatio; - } else { - width = height * effectiveAspectRatio; - } - } - - let minSize = minCropSize; - if (effectiveAspectRatio) { - if (effectiveAspectRatio > 1) { - minSize = minCropSize * effectiveAspectRatio; - } else { - minSize = minCropSize / effectiveAspectRatio; - } + ({ width, height } = adjustAspectRatio( + width, + height, + effectiveAspectRatio + )); } + const minSize = calculateMinSize(effectiveAspectRatio); width = Math.max(minSize, Math.min(width, displaySize.width)); height = Math.max(minSize, Math.min(height, displaySize.height)); if (effectiveAspectRatio) { - if (width / height > effectiveAspectRatio) { - width = height * effectiveAspectRatio; - } else { - height = width / effectiveAspectRatio; - } + ({ width, height } = adjustAspectRatio( + width, + height, + effectiveAspectRatio + )); } x = Math.max(0, Math.min(x, displaySize.width - width)); @@ -255,7 +265,7 @@ export function ImageCropper({ return { x, y, width, height }; }, - [getDisplaySize, circular, aspectRatio, minCropSize] + [getDisplaySize, circular, aspectRatio, adjustAspectRatio, calculateMinSize] ); const getRelativeCoordinates = ( @@ -275,9 +285,7 @@ export function ImageCropper({ (coords: { x: number; y: number }) => { const newX = coords.x - dragStart.x; const newY = coords.y - dragStart.y; - setCropArea((prev) => - constrainCropArea({ ...prev, x: newX, y: newY }) - ); + setCropArea((prev) => constrainCropArea({ ...prev, x: newX, y: newY })); }, [dragStart, constrainCropArea] ); @@ -287,38 +295,49 @@ export function ImageCropper({ coords: { x: number; y: number }, cropStart: CropArea ) => { - if (resizeHandle === "resize-se") { - return { - deltaX: coords.x - (cropStart.x + cropStart.width), - deltaY: coords.y - (cropStart.y + cropStart.height), - newX: cropStart.x, - newY: cropStart.y, - }; - } - if (resizeHandle === "resize-sw") { - return { - deltaX: cropStart.x - coords.x, - deltaY: coords.y - (cropStart.y + cropStart.height), - newX: coords.x, - newY: cropStart.y, - }; - } - if (resizeHandle === "resize-ne") { - return { - deltaX: coords.x - (cropStart.x + cropStart.width), - deltaY: cropStart.y - coords.y, - newX: cropStart.x, - newY: coords.y, - }; - } - if (resizeHandle === "resize-nw") { - return { - deltaX: cropStart.x - coords.x, - deltaY: cropStart.y - coords.y, - newX: coords.x, - newY: coords.y, - }; + const handleMap: Record< + string, + ( + coords: { x: number; y: number }, + cropStart: CropArea + ) => { + deltaX: number; + deltaY: number; + newX: number; + newY: number; + } + > = { + "resize-se": (c, cs) => ({ + deltaX: c.x - (cs.x + cs.width), + deltaY: c.y - (cs.y + cs.height), + newX: cs.x, + newY: cs.y, + }), + "resize-sw": (c, cs) => ({ + deltaX: cs.x - c.x, + deltaY: c.y - (cs.y + cs.height), + newX: c.x, + newY: cs.y, + }), + "resize-ne": (c, cs) => ({ + deltaX: c.x - (cs.x + cs.width), + deltaY: cs.y - c.y, + newX: cs.x, + newY: c.y, + }), + "resize-nw": (c, cs) => ({ + deltaX: cs.x - c.x, + deltaY: cs.y - c.y, + newX: c.x, + newY: c.y, + }), + }; + + const handler = handleMap[resizeHandle]; + if (handler) { + return handler(coords, cropStart); } + return { deltaX: 0, deltaY: 0, @@ -327,6 +346,25 @@ export function ImageCropper({ }; }; + const adjustPositionForHandle = ( + resizeHandle: string, + cropStart: CropArea, + adjustedWidth: number, + adjustedHeight: number + ) => { + let adjustedX = cropStart.x; + let adjustedY = cropStart.y; + + if (resizeHandle === "resize-nw" || resizeHandle === "resize-sw") { + adjustedX = cropStart.x + cropStart.width - adjustedWidth; + } + if (resizeHandle === "resize-nw" || resizeHandle === "resize-ne") { + adjustedY = cropStart.y + cropStart.height - adjustedHeight; + } + + return { x: adjustedX, y: adjustedY }; + }; + const applyCircularConstraint = ( newWidth: number, newHeight: number, @@ -337,22 +375,15 @@ export function ImageCropper({ const deltaSize = size - Math.min(cropStart.width, cropStart.height); const adjustedWidth = cropStart.width + deltaSize; const adjustedHeight = cropStart.height + deltaSize; - let adjustedX = cropStart.x; - let adjustedY = cropStart.y; - if (resizeHandle === "resize-nw" || resizeHandle === "resize-sw") { - adjustedX = cropStart.x + cropStart.width - adjustedWidth; - } - if (resizeHandle === "resize-nw" || resizeHandle === "resize-ne") { - adjustedY = cropStart.y + cropStart.height - adjustedHeight; - } + const { x, y } = adjustPositionForHandle( + resizeHandle, + cropStart, + adjustedWidth, + adjustedHeight + ); - return { - width: adjustedWidth, - height: adjustedHeight, - x: adjustedX, - y: adjustedY, - }; + return { width: adjustedWidth, height: adjustedHeight, x, y }; }; const applyAspectRatioConstraint = ( @@ -362,32 +393,20 @@ export function ImageCropper({ resizeHandle: string, aspectRatio: number ) => { - const currentRatio = newWidth / newHeight; - let adjustedWidth = newWidth; - let adjustedHeight = newHeight; + const { width: adjustedWidth, height: adjustedHeight } = adjustAspectRatio( + newWidth, + newHeight, + aspectRatio + ); - if (currentRatio > aspectRatio) { - adjustedHeight = newWidth / aspectRatio; - } else { - adjustedWidth = newHeight * aspectRatio; - } + const { x, y } = adjustPositionForHandle( + resizeHandle, + cropStart, + adjustedWidth, + adjustedHeight + ); - let adjustedX = cropStart.x; - let adjustedY = cropStart.y; - - if (resizeHandle === "resize-nw" || resizeHandle === "resize-sw") { - adjustedX = cropStart.x + cropStart.width - adjustedWidth; - } - if (resizeHandle === "resize-nw" || resizeHandle === "resize-ne") { - adjustedY = cropStart.y + cropStart.height - adjustedHeight; - } - - return { - width: adjustedWidth, - height: adjustedHeight, - x: adjustedX, - y: adjustedY, - }; + return { width: adjustedWidth, height: adjustedHeight, x, y }; }; const handleResize = useCallback( @@ -481,7 +500,15 @@ export function ImageCropper({ handleResize(coords); } }, - [imageLoaded, isDragging, isResizing, cropStart, resizeHandle, handleDrag, handleResize] + [ + imageLoaded, + isDragging, + isResizing, + cropStart, + resizeHandle, + handleDrag, + handleResize, + ] ); const handleMouseUp = useCallback(() => { @@ -491,37 +518,30 @@ export function ImageCropper({ setCropStart(null); }, []); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - } - }, - [] - ); + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + } + }, []); const handleOverlayKeyDown = useCallback( (e: React.KeyboardEvent) => { const step = e.shiftKey ? 10 : 1; - let newX = cropArea.x; - let newY = cropArea.y; + const keyMap: Record< + string, + (area: CropArea) => { x: number; y: number } + > = { + ArrowLeft: (area) => ({ x: area.x - step, y: area.y }), + ArrowRight: (area) => ({ x: area.x + step, y: area.y }), + ArrowUp: (area) => ({ x: area.x, y: area.y - step }), + ArrowDown: (area) => ({ x: area.x, y: area.y + step }), + }; - if (e.key === "ArrowLeft") { - e.preventDefault(); - newX = cropArea.x - step; - } else if (e.key === "ArrowRight") { - e.preventDefault(); - newX = cropArea.x + step; - } else if (e.key === "ArrowUp") { - e.preventDefault(); - newY = cropArea.y - step; - } else if (e.key === "ArrowDown") { - e.preventDefault(); - newY = cropArea.y + step; - } else { - return; - } + const handler = keyMap[e.key]; + if (!handler) return; + e.preventDefault(); + const { x: newX, y: newY } = handler(cropArea); setCropArea((prev) => constrainCropArea({ ...prev, x: newX, y: newY })); }, [cropArea, constrainCropArea] @@ -540,7 +560,6 @@ export function ImageCropper({ return cleanup; }, [isDragging, isResizing, handleMouseMove, handleMouseUp]); - const handleCrop = async () => { setIsCropping(true); try { @@ -555,10 +574,7 @@ export function ImageCropper({ return (