Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion nextjs/matcha/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 25 additions & 8 deletions nextjs/matcha/src/components/common/ImageCropper.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;
Expand All @@ -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";
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory leak: Object URLs created with URL.createObjectURL() should be revoked with URL.revokeObjectURL() when no longer needed to prevent memory leaks. In this component, a new object URL is created on every render when getCurrentImage() changes, but previous URLs are never cleaned up. Consider using a useEffect hook to manage the URL lifecycle:

useEffect(() => {
  const currentImage = getCurrentImage();
  const url = currentImage ? URL.createObjectURL(currentImage) : "none";
  return () => {
    if (url !== "none") {
      URL.revokeObjectURL(url);
    }
  };
}, [currentCroppingIndex, profilePicture, additionalPictures]);

Copilot uses AI. Check for mistakes.

return (<div className="flex flex-col">
<div className="relative z-10 w-48 h-80 bg-gray-200">
<Cropper
image={getImageUrl(currentCroppingIndex == 0 ? profilePicture : (additionalPictures[currentCroppingIndex - 1] || null)) || "none"}
image={imageUrl}
crop={crop}
zoom={zoom}
maxZoom={3}
Expand All @@ -45,23 +58,27 @@ export default function ImageCropper({
</div>
<div className="flex w-full flex-row justify-between">
<IconButton
onClick={() => setRotation((rotation + 90) % 180)}
onClick={() => setRotation((rotation - 90 + 360) % 360)}
size="small"
aria-label="Rotate right"
aria-label="Rotate left"
>
<div>\-</div>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h1.168a2 2 0 011.88 1.316l.957 2.871a1 1 0 01-.95 1.313H3m0-5.5l1.172-1.172a4 4 0 015.656 0L10 9m-7 1v11a1 1 0 001 1h5a1 1 0 001-1v-3m-7-8V3a1 1 0 011-1h5a1 1 0 011 1v4" />
</svg>
</IconButton>
<IconButton
onClick={() => setRotation((rotation + 90) % 180)}
onClick={() => setRotation((rotation + 90) % 360)}
size="small"
aria-label="Rotate right"
>
<div>-/</div>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10h-1.168a2 2 0 00-1.88 1.316l-.957 2.871a1 1 0 00.95 1.313H21m0-5.5l-1.172-1.172a4 4 0 00-5.656 0L14 9m7 1v11a1 1 0 01-1 1h-5a1 1 0 01-1-1v-3m7-8V3a1 1 0 00-1-1h-5a1 1 0 00-1 1v4" />
</svg>
</IconButton>
</div>
<div className="p-1 flex flex-col items-center">

<Button onClick={submitImage}>Confirmer</Button>
</div>
</div>);
}
}
187 changes: 94 additions & 93 deletions nextjs/matcha/src/utils/cropImage.ts
Original file line number Diff line number Diff line change
@@ -1,104 +1,105 @@
type Area = {
x: number
y: number
width: number
height: number
/**
* Create a cropped image from a source image URL
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Create a cropped image from a source image URL" but this function only loads an image, it doesn't crop it. The actual cropping is done by getCroppedImg(). Consider updating to: "Load an image from a URL".

Suggested change
* Create a cropped image from a source image URL
* Load an image from a URL

Copilot uses AI. Check for mistakes.
*/
export const createImage = (url: string): Promise<HTMLImageElement> =>
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<HTMLImageElement>((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<string | null> {
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);
}
5 changes: 4 additions & 1 deletion nextjs/matcha/src/utils/getImageUrl.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/**
* Convert a File object to a data URL for display
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Convert a File object to a data URL" but URL.createObjectURL() creates a blob URL (e.g., blob:...), not a data URL (e.g., data:image/png;base64,...). Consider updating the comment to accurately describe the function's behavior: "Convert a File object to a blob URL for display".

Suggested change
* Convert a File object to a data URL for display
* Convert a File object to a blob URL for display

Copilot uses AI. Check for mistakes.
*/
export default function getImageUrl(file: File | null): string | null {
return file ? URL.createObjectURL(file) : null;
};
}
Loading