diff --git a/nextjs/matcha/package.json b/nextjs/matcha/package.json index dac7959..80fa505 100644 --- a/nextjs/matcha/package.json +++ b/nextjs/matcha/package.json @@ -17,7 +17,7 @@ "react": "19.2.0", "react-dom": "19.2.0", "react-easy-crop": "^5.5.6", - "react-image-crop": "^11.0.10" + "react-image-crop": "^11.0.10", }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/nextjs/matcha/src/components/common/ImageCropper.tsx b/nextjs/matcha/src/components/common/ImageCropper.tsx index 1421f09..e3a4b11 100644 --- a/nextjs/matcha/src/components/common/ImageCropper.tsx +++ b/nextjs/matcha/src/components/common/ImageCropper.tsx @@ -1,7 +1,10 @@ +"use client"; + import Cropper from "react-easy-crop"; import IconButton from "@/components/common/IconButton"; import Button from "@/components/common/Button"; import getImageUrl from '@/utils/getImageUrl'; +import type { Area } from '@/utils/cropImage'; const CROP_AREA_ASPECT = 9 / 16; @@ -20,7 +23,7 @@ export default function ImageCropper({ profilePicture: File | null; additionalPictures: (File | null)[]; currentCroppingIndex: number; - onCropComplete: (croppedArea: any, croppedAreaPixels: any) => void; + onCropComplete: (croppedArea: Area, croppedAreaPixels: Area) => void; submitImage: () => void; zoom: number; setZoom: (zoom: number) => void; @@ -29,10 +32,20 @@ export default function ImageCropper({ crop: { x: number; y: number }; setCrop: (crop: { x: number; y: number }) => void; }) { + // Get the current image based on index (0 = profile picture, 1+ = additional pictures) + const getCurrentImage = (): File | null => { + if (currentCroppingIndex === 0) { + return profilePicture; + } + return additionalPictures[currentCroppingIndex - 1] || null; + }; + + const imageUrl = getImageUrl(getCurrentImage()) || "none"; + return (
setRotation((rotation + 90) % 180)} + onClick={() => setRotation((rotation - 90 + 360) % 360)} size="small" - aria-label="Rotate right" + aria-label="Rotate left" > -
\-
+ + +
setRotation((rotation + 90) % 180)} + onClick={() => setRotation((rotation + 90) % 360)} size="small" aria-label="Rotate right" > -
-/
+ + +
@@ -64,4 +81,4 @@ export default function ImageCropper({
); -} \ No newline at end of file +} diff --git a/nextjs/matcha/src/utils/cropImage.ts b/nextjs/matcha/src/utils/cropImage.ts index d20f200..66bdd5e 100644 --- a/nextjs/matcha/src/utils/cropImage.ts +++ b/nextjs/matcha/src/utils/cropImage.ts @@ -1,104 +1,105 @@ -type Area = { - x: number - y: number - width: number - height: number +/** + * Create a cropped image from a source image URL + */ +export const createImage = (url: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', (error) => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); + image.src = url; + }); + +export function getRadianAngle(degreeValue: number): number { + return (degreeValue * Math.PI) / 180; } -type Point = { - x: number - y: number +export function rotateSize(width: number, height: number, rotation: number): { width: number; height: number } { + const rotRad = getRadianAngle(rotation); + return { + width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), + height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), + }; } -export const createImage = (url: string) => - new Promise((resolve, reject) => { - const image = new Image(); - image.addEventListener('load', () => resolve(image)); - image.addEventListener('error', (error) => reject(error)); - image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues on CodeSandbox - image.src = url; - }); - -export function getRadianAngle(degreeValue: number) { - return (degreeValue * Math.PI) / 180 +export interface Area { + width: number; + height: number; + x: number; + y: number; } -export function rotateSize(width: number, height: number, rotation: number) { - const rotRad = getRadianAngle(rotation); +export type ImageFormat = 'image/jpeg' | 'image/png' | 'image/webp'; - return { - width: - Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), - height: - Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), - }; +export interface CropOptions { + format?: ImageFormat; + quality?: number; } +/** + * Returns a cropped image as a data URL + * @param imageSrc - Source image URL + * @param pixelCrop - Crop area in pixels + * @param rotation - Rotation in degrees (0-360) + * @param flip - Flip options + * @param options - Output format and quality options + */ export default async function getCroppedImg( - imageSrc: string, - pixelCrop: Area, - rotation = 0, - flip = { horizontal: false, vertical: false } -) { - const image = await createImage(imageSrc) - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') - - if (!ctx) { - return null; - } - - const rotRad = getRadianAngle(rotation); - console.log('rotRad', rotRad); - - const { width: bBoxWidth, height: bBoxHeight } = rotateSize( - image.width as number, - image.height as number, - rotation - ); - - canvas.width = bBoxWidth; - canvas.height = bBoxHeight; - - ctx.translate(bBoxWidth / 2, bBoxHeight / 2); - ctx.rotate(rotRad);; - ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1); - ctx.translate(-image.width / 2, -image.height / 2); - - ctx.drawImage(image, 0, 0); - - const croppedCanvas = document.createElement('canvas'); - - const croppedCtx = croppedCanvas.getContext('2d'); - - if (!croppedCtx) { - return null; - } - - croppedCanvas.width = pixelCrop.width; - croppedCanvas.height = pixelCrop.height; - - croppedCtx.drawImage( - canvas, - pixelCrop.x, - pixelCrop.y, - pixelCrop.width, - pixelCrop.height, - 0, - 0, - pixelCrop.width, - pixelCrop.height - ); - - // return croppedCanvas.toDataURL('image/jpeg'); - - return new Promise((resolve, reject) => { - croppedCanvas.toBlob((file: Blob | null) => { - if (file) { - resolve(URL.createObjectURL(file)); - } else { - reject(new Error('Canvas is empty')); - } - }, 'image/jpeg'); - }); + imageSrc: string, + pixelCrop: Area, + rotation = 0, + flip = { horizontal: false, vertical: false }, + options: CropOptions = {} +): Promise { + const { format = 'image/jpeg', quality = 0.9 } = options; + + const image = await createImage(imageSrc); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + return null; + } + + const rotRad = getRadianAngle(rotation); + + const { width: bBoxWidth, height: bBoxHeight } = rotateSize( + image.width, + image.height, + rotation + ); + + canvas.width = bBoxWidth; + canvas.height = bBoxHeight; + + ctx.translate(bBoxWidth / 2, bBoxHeight / 2); + ctx.rotate(rotRad); + ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1); + ctx.translate(-image.width / 2, -image.height / 2); + + ctx.drawImage(image, 0, 0); + + const croppedCanvas = document.createElement('canvas'); + const croppedCtx = croppedCanvas.getContext('2d'); + + if (!croppedCtx) { + return null; + } + + croppedCanvas.width = pixelCrop.width; + croppedCanvas.height = pixelCrop.height; + + croppedCtx.drawImage( + canvas, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ); + + return croppedCanvas.toDataURL(format, quality); } diff --git a/nextjs/matcha/src/utils/getImageUrl.ts b/nextjs/matcha/src/utils/getImageUrl.ts index 2164bc6..d598760 100644 --- a/nextjs/matcha/src/utils/getImageUrl.ts +++ b/nextjs/matcha/src/utils/getImageUrl.ts @@ -1,3 +1,6 @@ +/** + * Convert a File object to a data URL for display + */ export default function getImageUrl(file: File | null): string | null { return file ? URL.createObjectURL(file) : null; -}; \ No newline at end of file +}