diff --git a/src/renderer/src/components/image-cropper/image-cropper.tsx b/src/renderer/src/components/image-cropper/image-cropper.tsx index 1d163b42..c8670684 100644 --- a/src/renderer/src/components/image-cropper/image-cropper.tsx +++ b/src/renderer/src/components/image-cropper/image-cropper.tsx @@ -58,9 +58,15 @@ export function ImageCropper({ const containerRect = container.getBoundingClientRect(); if (containerRect.width === 0 || containerRect.height === 0) return null; + const computedStyle = globalThis.getComputedStyle(container); + const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0; + const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0; + const paddingTop = Number.parseFloat(computedStyle.paddingTop) || 0; + const paddingBottom = Number.parseFloat(computedStyle.paddingBottom) || 0; + return { - maxWidth: containerRect.width - 40, - maxHeight: containerRect.height - 200, + maxWidth: containerRect.width - paddingLeft - paddingRight, + maxHeight: containerRect.height - paddingTop - paddingBottom, }; }, []); @@ -212,16 +218,17 @@ 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 enforceAspectRatio = ( + width: number, + height: number, + ratio: number + ): { width: number; height: number } => { + const currentRatio = width / height; + if (currentRatio > ratio) { + return { width, height: width / ratio }; + } + return { width: height * ratio, height }; + }; const calculateMinSize = useCallback( (effectiveAspectRatio: number | undefined) => { @@ -233,39 +240,78 @@ export function ImageCropper({ [minCropSize] ); + const getImageWrapperBounds = useCallback(() => { + const imageWrapper = imageRef.current?.parentElement; + if (!imageWrapper) return { width: 0, height: 0 }; + + const rect = imageWrapper.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }, []); + const constrainCropArea = useCallback( (area: CropArea): CropArea => { const displaySize = getDisplaySize(); + const wrapperBounds = getImageWrapperBounds(); + const actualBounds = { + width: + wrapperBounds.width > 0 ? wrapperBounds.width : displaySize.width, + height: + wrapperBounds.height > 0 ? wrapperBounds.height : displaySize.height, + }; + let { x, y, width, height } = area; - const effectiveAspectRatio = circular ? 1 : aspectRatio; - - if (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) { - ({ width, height } = adjustAspectRatio( + ({ width, height } = enforceAspectRatio( width, height, effectiveAspectRatio )); + + const maxWidth = Math.min( + actualBounds.width, + actualBounds.height * effectiveAspectRatio + ); + const maxHeight = Math.min( + actualBounds.height, + actualBounds.width / effectiveAspectRatio + ); + + width = Math.max(minSize, Math.min(width, maxWidth)); + height = Math.max(minSize, Math.min(height, maxHeight)); + + ({ width, height } = enforceAspectRatio( + width, + height, + effectiveAspectRatio + )); + + width = Math.min(width, actualBounds.width); + height = Math.min(height, actualBounds.height); + ({ width, height } = enforceAspectRatio( + width, + height, + effectiveAspectRatio + )); + } else { + width = Math.max(minSize, Math.min(width, actualBounds.width)); + height = Math.max(minSize, Math.min(height, actualBounds.height)); } - x = Math.max(0, Math.min(x, displaySize.width - width)); - y = Math.max(0, Math.min(y, displaySize.height - height)); + x = Math.max(0, Math.min(x, actualBounds.width - width)); + y = Math.max(0, Math.min(y, actualBounds.height - height)); return { x, y, width, height }; }, - [getDisplaySize, circular, aspectRatio, adjustAspectRatio, calculateMinSize] + [ + getDisplaySize, + getImageWrapperBounds, + circular, + aspectRatio, + calculateMinSize, + ] ); const getRelativeCoordinates = ( @@ -275,10 +321,10 @@ export function ImageCropper({ if (!imageWrapper) return null; const rect = imageWrapper.getBoundingClientRect(); - return { - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }; + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + return { x, y }; }; const handleDrag = useCallback( @@ -393,11 +439,48 @@ export function ImageCropper({ resizeHandle: string, aspectRatio: number ) => { - const { width: adjustedWidth, height: adjustedHeight } = adjustAspectRatio( - newWidth, - newHeight, - aspectRatio + const deltaX = Math.abs(newWidth - cropStart.width); + const deltaY = Math.abs(newHeight - cropStart.height); + + let adjustedWidth: number; + let adjustedHeight: number; + + if (deltaX > deltaY) { + adjustedWidth = newWidth; + adjustedHeight = newWidth / aspectRatio; + } else { + adjustedHeight = newHeight; + adjustedWidth = newHeight * aspectRatio; + } + + const wrapperBounds = getImageWrapperBounds(); + const displaySize = getDisplaySize(); + const actualBounds = { + width: wrapperBounds.width > 0 ? wrapperBounds.width : displaySize.width, + height: + wrapperBounds.height > 0 ? wrapperBounds.height : displaySize.height, + }; + + const maxWidth = Math.min( + actualBounds.width, + actualBounds.height * aspectRatio ); + const maxHeight = Math.min( + actualBounds.height, + actualBounds.width / aspectRatio + ); + + adjustedWidth = Math.min(adjustedWidth, maxWidth); + adjustedHeight = Math.min(adjustedHeight, maxHeight); + + const finalRatio = adjustedWidth / adjustedHeight; + if (Math.abs(finalRatio - aspectRatio) > 0.001) { + if (finalRatio > aspectRatio) { + adjustedHeight = adjustedWidth / aspectRatio; + } else { + adjustedWidth = adjustedHeight * aspectRatio; + } + } const { x, y } = adjustPositionForHandle( resizeHandle, @@ -575,7 +658,15 @@ export function ImageCropper({ return (
-
+
{imageLoaded && ( void; + +const loadImageAndProcess = async ( imagePath: string, - cropArea: { - x: number; - y: number; - width: number; - height: number; - }, - outputFormat: string = "image/png" -): Promise { + cropArea: CropArea, + outputFormat: string, + drawCallback: DrawImageCallback +): Promise => { return new Promise((resolve, reject) => { const img = new Image(); @@ -27,35 +24,25 @@ export async function cropImage( return; } - canvas.width = cropArea.width; - canvas.height = cropArea.height; + drawCallback(ctx, img, cropArea); - ctx.drawImage( - img, - cropArea.x, - cropArea.y, - cropArea.width, - cropArea.height, - 0, - 0, - cropArea.width, - cropArea.height - ); + const convertBlobToUint8Array = async ( + blob: Blob + ): Promise => { + const buffer = await blob.arrayBuffer(); + return new Uint8Array(buffer); + }; - canvas.toBlob( - (blob) => { - if (!blob) { - reject(new Error("Failed to create blob from canvas")); - return; - } + const handleBlob = (blob: Blob | null) => { + if (!blob) { + reject(new Error("Failed to create blob from canvas")); + return; + } - blob.arrayBuffer().then((buffer) => { - resolve(new Uint8Array(buffer)); - }); - }, - outputFormat, - 0.95 - ); + convertBlobToUint8Array(blob).then(resolve).catch(reject); + }; + + canvas.toBlob(handleBlob, outputFormat, 0.95); }; img.onerror = () => { @@ -64,6 +51,73 @@ export async function cropImage( img.src = imagePath.startsWith("local:") ? imagePath : `local:${imagePath}`; }); +}; + +const setCanvasDimensions = ( + canvas: HTMLCanvasElement, + width: number, + height: number +): void => { + canvas.width = width; + canvas.height = height; +}; + +type DrawImageParams = { + sourceX: number; + sourceY: number; + sourceWidth: number; + sourceHeight: number; + destWidth: number; + destHeight: number; +}; + +const drawCroppedImage = ( + ctx: CanvasRenderingContext2D, + img: HTMLImageElement, + params: DrawImageParams +): void => { + ctx.drawImage( + img, + params.sourceX, + params.sourceY, + params.sourceWidth, + params.sourceHeight, + 0, + 0, + params.destWidth, + params.destHeight + ); +}; + +/** + * Crops an image using HTML5 Canvas API + * @param imagePath - Path to the image file + * @param cropArea - Crop area coordinates and dimensions + * @param outputFormat - Output image format (default: 'image/png') + * @returns Promise resolving to cropped image as Uint8Array + */ +export async function cropImage( + imagePath: string, + cropArea: CropArea, + outputFormat: string = "image/png" +): Promise { + return loadImageAndProcess( + imagePath, + cropArea, + outputFormat, + (ctx, img, area) => { + const canvas = ctx.canvas; + setCanvasDimensions(canvas, area.width, area.height); + drawCroppedImage(ctx, img, { + sourceX: area.x, + sourceY: area.y, + sourceWidth: area.width, + sourceHeight: area.height, + destWidth: area.width, + destHeight: area.height, + }); + } + ); } /** @@ -75,56 +129,30 @@ export async function cropImage( */ export async function cropImageToCircle( imagePath: string, - cropArea: { - x: number; - y: number; - width: number; - height: number; - }, + cropArea: CropArea, outputFormat: string = "image/png" ): Promise { - return new Promise((resolve, reject) => { - const img = new Image(); - - img.onload = () => { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - - if (!ctx) { - reject(new Error("Failed to get canvas context")); - return; - } - - const size = Math.min(cropArea.width, cropArea.height); - canvas.width = size; - canvas.height = size; + return loadImageAndProcess( + imagePath, + cropArea, + outputFormat, + (ctx, img, area) => { + const size = Math.min(area.width, area.height); + const canvas = ctx.canvas; + setCanvasDimensions(canvas, size, size); ctx.beginPath(); ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); ctx.clip(); - ctx.drawImage(img, cropArea.x, cropArea.y, size, size, 0, 0, size, size); - - canvas.toBlob( - (blob) => { - if (!blob) { - reject(new Error("Failed to create blob from canvas")); - return; - } - - blob.arrayBuffer().then((buffer) => { - resolve(new Uint8Array(buffer)); - }); - }, - outputFormat, - 0.95 - ); - }; - - img.onerror = () => { - reject(new Error("Failed to load image")); - }; - - img.src = imagePath.startsWith("local:") ? imagePath : `local:${imagePath}`; - }); + drawCroppedImage(ctx, img, { + sourceX: area.x, + sourceY: area.y, + sourceWidth: size, + sourceHeight: size, + destWidth: size, + destHeight: size, + }); + } + ); }