Skip to content

Commit 95ea2bd

Browse files
authored
Merge pull request #75 from EduardoDePatta/feature/crop-image
[Feat] - Add Image Cropping Functionality
2 parents 30a5d47 + fd5566b commit 95ea2bd

File tree

8 files changed

+1244
-189
lines changed

8 files changed

+1244
-189
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"use client";
2+
interface CropOverlayProps {
3+
cropRect: { x: number; y: number; width: number; height: number } | null;
4+
onDone?: () => void;
5+
}
6+
7+
const CropOverlayComponent: React.FC<CropOverlayProps> = ({
8+
cropRect,
9+
onDone,
10+
}) => {
11+
if (!cropRect) return null;
12+
13+
const left = Math.min(cropRect.x, cropRect.x + cropRect.width);
14+
const top = Math.min(cropRect.y, cropRect.y + cropRect.height);
15+
const width = Math.abs(cropRect.width);
16+
const height = Math.abs(cropRect.height);
17+
const handleBaseClass =
18+
"absolute h-3.5 w-3.5 rounded-full border border-white bg-primary shadow pointer-events-auto";
19+
20+
return (
21+
<div
22+
className="absolute border-2 border-white/90 pointer-events-none"
23+
style={{
24+
left,
25+
top,
26+
width,
27+
height,
28+
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.45)",
29+
}}
30+
>
31+
<div className="absolute left-1/3 top-0 h-full w-px bg-white/70" />
32+
<div className="absolute left-2/3 top-0 h-full w-px bg-white/70" />
33+
<div className="absolute top-1/3 left-0 h-px w-full bg-white/70" />
34+
<div className="absolute top-2/3 left-0 h-px w-full bg-white/70" />
35+
36+
<div
37+
data-crop-area="true"
38+
className="absolute inset-0 pointer-events-auto cursor-move bg-transparent"
39+
/>
40+
41+
{onDone && (
42+
<button
43+
type="button"
44+
data-crop-action="done"
45+
onPointerDown={(event) => event.stopPropagation()}
46+
onClick={(event) => {
47+
event.stopPropagation();
48+
onDone();
49+
}}
50+
className="absolute right-2 top-2 pointer-events-auto rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground shadow-sm hover:opacity-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
51+
>
52+
Done
53+
</button>
54+
)}
55+
56+
<div
57+
data-crop-handle="nw"
58+
className={`${handleBaseClass} -left-2 -top-2 cursor-nwse-resize`}
59+
/>
60+
<div
61+
data-crop-handle="n"
62+
className={`${handleBaseClass} left-1/2 -top-2 -translate-x-1/2 cursor-ns-resize`}
63+
/>
64+
<div
65+
data-crop-handle="ne"
66+
className={`${handleBaseClass} -right-2 -top-2 cursor-nesw-resize`}
67+
/>
68+
<div
69+
data-crop-handle="e"
70+
className={`${handleBaseClass} -right-2 top-1/2 -translate-y-1/2 cursor-ew-resize`}
71+
/>
72+
<div
73+
data-crop-handle="se"
74+
className={`${handleBaseClass} -right-2 -bottom-2 cursor-nwse-resize`}
75+
/>
76+
<div
77+
data-crop-handle="s"
78+
className={`${handleBaseClass} left-1/2 -bottom-2 -translate-x-1/2 cursor-ns-resize`}
79+
/>
80+
<div
81+
data-crop-handle="sw"
82+
className={`${handleBaseClass} -left-2 -bottom-2 cursor-nesw-resize`}
83+
/>
84+
<div
85+
data-crop-handle="w"
86+
className={`${handleBaseClass} -left-2 top-1/2 -translate-y-1/2 cursor-ew-resize`}
87+
/>
88+
</div>
89+
);
90+
};
91+
92+
export { CropOverlayComponent };

components/ds/DividerComponent.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"use client";
2+
interface DividerProps {
3+
margin?: "small" | "medium" | "large";
4+
}
5+
6+
const DividerComponent: React.FC<DividerProps> = ({ margin = "small" }) => {
7+
const marginClasses = {
8+
small: "my-2",
9+
medium: "my-6",
10+
large: "my-8",
11+
};
12+
13+
return <div className={`bg-border h-[1px] ${marginClasses[margin]}`}></div>;
14+
};
15+
16+
export { DividerComponent };
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"use client";
2+
interface TooltipProps {
3+
message: string;
4+
position: { x: number; y: number };
5+
}
6+
7+
const FollowingTooltipComponent: React.FC<TooltipProps> = ({
8+
message,
9+
position,
10+
}) => {
11+
return (
12+
<div
13+
className="fixed px-2 py-1 bg-gray-800 text-white text-xs rounded shadow-lg"
14+
style={{ left: position.x + 10, top: position.y - 30 }}
15+
>
16+
{message}
17+
</div>
18+
);
19+
};
20+
21+
export { FollowingTooltipComponent };

components/utils/resize-image.utils.test.ts

Lines changed: 139 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {
2+
calculateCropDimensions,
23
handleResizeImage,
4+
isPointInCropRect,
35
processImageFile,
46
resizeImage,
57
updateHeight,
@@ -15,11 +17,19 @@ describe("Image Processing Functions", () => {
1517
canvasMock = document.createElement("canvas");
1618
ctxMock = {
1719
drawImage: jest.fn(),
18-
toDataURL: jest.fn().mockReturnValue("data:image/png;base64,MOCK_DATA"),
1920
} as unknown as CanvasRenderingContext2D;
2021

2122
jest.spyOn(document, "createElement").mockReturnValue(canvasMock);
2223
jest.spyOn(canvasMock, "getContext").mockReturnValue(ctxMock);
24+
jest
25+
.spyOn(canvasMock, "toBlob")
26+
.mockImplementation((callback: BlobCallback) => {
27+
callback(new Blob(["MOCK_DATA"], { type: "image/png" }));
28+
});
29+
Object.defineProperty(URL, "createObjectURL", {
30+
writable: true,
31+
value: jest.fn(() => "blob:mock-url"),
32+
});
2333

2434
jest.spyOn(window, "FileReader").mockImplementation(
2535
() =>
@@ -59,7 +69,7 @@ describe("Image Processing Functions", () => {
5969
quality: 1,
6070
});
6171

62-
expect(result).toMatch(/^data:image\/png;base64,/);
72+
expect(result).toMatch(/^blob:/);
6373
expect(ctxMock.drawImage).toHaveBeenCalledWith(img, 0, 0, 500, 250);
6474
});
6575

@@ -74,7 +84,7 @@ describe("Image Processing Functions", () => {
7484
quality: 0.8,
7585
});
7686

77-
expect(result).toMatch(/^data:image\/jpeg;base64,/);
87+
expect(result).toMatch(/^blob:/);
7888
expect(ctxMock.drawImage).toHaveBeenCalledWith(img, 0, 0, 500, 250);
7989
});
8090

@@ -97,7 +107,7 @@ describe("Image Processing Functions", () => {
97107
const setOutput = jest.fn();
98108

99109
processImageFile({
100-
file: mockFile,
110+
source: mockFile,
101111
format: "jpeg",
102112
preserveAspectRatio: true,
103113
quality: 0.8,
@@ -107,9 +117,7 @@ describe("Image Processing Functions", () => {
107117
done: () => {
108118
expect(setWidth).toHaveBeenCalledWith(1000);
109119
expect(setHeight).toHaveBeenCalledWith(500);
110-
expect(setOutput).toHaveBeenCalledWith(
111-
expect.stringMatching(/^data:image\/jpeg;base64,/)
112-
);
120+
expect(setOutput).toHaveBeenCalledWith(expect.stringMatching(/^blob:/));
113121
done();
114122
},
115123
});
@@ -121,7 +129,7 @@ describe("Image Processing Functions", () => {
121129
});
122130
const setWidth = jest.fn();
123131

124-
updateWidth({ file: mockFile, height: 200, setWidth });
132+
updateWidth({ source: mockFile, height: 200, setWidth });
125133

126134
setTimeout(() => {
127135
expect(setWidth).toHaveBeenCalledWith(400);
@@ -135,7 +143,7 @@ describe("Image Processing Functions", () => {
135143
});
136144
const setHeight = jest.fn();
137145

138-
updateHeight({ file: mockFile, width: 300, setHeight });
146+
updateHeight({ source: mockFile, width: 300, setHeight });
139147

140148
setTimeout(() => {
141149
expect(setHeight).toHaveBeenCalledWith(150);
@@ -150,7 +158,7 @@ describe("Image Processing Functions", () => {
150158
const setOutput = jest.fn();
151159

152160
handleResizeImage({
153-
file: mockFile,
161+
source: mockFile,
154162
format: "jpeg",
155163
height: 400,
156164
width: 600,
@@ -160,10 +168,128 @@ describe("Image Processing Functions", () => {
160168
});
161169

162170
setTimeout(() => {
163-
expect(setOutput).toHaveBeenCalledWith(
164-
expect.stringMatching(/^data:image\/jpeg;base64,/)
165-
);
171+
expect(setOutput).toHaveBeenCalledWith(expect.stringMatching(/^blob:/));
166172
done();
167173
}, 0);
168174
});
175+
176+
it("should calculate the crop dimensions correctly", () => {
177+
const imgMock = {
178+
naturalWidth: 1000,
179+
naturalHeight: 500,
180+
width: 1000,
181+
height: 500,
182+
} as HTMLImageElement;
183+
184+
const currentImageRefMock = {
185+
clientWidth: 500,
186+
clientHeight: 250,
187+
getBoundingClientRect: jest.fn(() => ({
188+
width: 500,
189+
height: 250,
190+
})),
191+
} as unknown as HTMLImageElement;
192+
193+
const cropRect = { x: 50, y: 50, width: 100, height: 50 };
194+
195+
const result = calculateCropDimensions(
196+
imgMock,
197+
currentImageRefMock,
198+
cropRect
199+
);
200+
201+
expect(result).toEqual({
202+
x: 100,
203+
y: 100,
204+
width: 200,
205+
height: 100,
206+
});
207+
});
208+
209+
it("should handle negative width and height values in cropRect", () => {
210+
const imgMock = {
211+
naturalWidth: 1000,
212+
naturalHeight: 500,
213+
width: 1000,
214+
height: 500,
215+
} as HTMLImageElement;
216+
217+
const currentImageRefMock = {
218+
clientWidth: 500,
219+
clientHeight: 250,
220+
getBoundingClientRect: jest.fn(() => ({
221+
width: 500,
222+
height: 250,
223+
})),
224+
} as unknown as HTMLImageElement;
225+
226+
const cropRect = { x: 150, y: 150, width: -100, height: -50 };
227+
228+
const result = calculateCropDimensions(
229+
imgMock,
230+
currentImageRefMock,
231+
cropRect
232+
);
233+
234+
expect(result).toEqual({
235+
x: 100,
236+
y: 200,
237+
width: 200,
238+
height: 100,
239+
});
240+
});
241+
242+
it("should clamp crop dimensions to image boundaries", () => {
243+
const imgMock = {
244+
naturalWidth: 1000,
245+
naturalHeight: 500,
246+
width: 1000,
247+
height: 500,
248+
} as HTMLImageElement;
249+
250+
const currentImageRefMock = {
251+
clientWidth: 500,
252+
clientHeight: 250,
253+
getBoundingClientRect: jest.fn(() => ({
254+
width: 500,
255+
height: 250,
256+
})),
257+
} as unknown as HTMLImageElement;
258+
259+
const cropRect = { x: -10, y: -20, width: 600, height: 400 };
260+
261+
const result = calculateCropDimensions(
262+
imgMock,
263+
currentImageRefMock,
264+
cropRect
265+
);
266+
267+
expect(result).toEqual({
268+
x: 0,
269+
y: 0,
270+
width: 1000,
271+
height: 500,
272+
});
273+
});
274+
275+
const cropRect = { x: 50, y: 50, width: 100, height: 50 };
276+
277+
it("should return true for a point inside the crop rectangle", () => {
278+
const result = isPointInCropRect(75, 75, cropRect);
279+
expect(result).toBe(true);
280+
});
281+
282+
it("should return false for a point outside the crop rectangle", () => {
283+
const result = isPointInCropRect(200, 200, cropRect);
284+
expect(result).toBe(false);
285+
});
286+
287+
it("should handle negative width and height in crop rectangle", () => {
288+
const cropRectNegative = { x: 150, y: 150, width: -100, height: -50 };
289+
const result = isPointInCropRect(75, 75, cropRectNegative);
290+
expect(result).toBe(false);
291+
292+
const resultInside = isPointInCropRect(125, 125, cropRectNegative);
293+
expect(resultInside).toBe(true);
294+
});
169295
});

0 commit comments

Comments
 (0)