Skip to content

Commit 3d90a6a

Browse files
committed
feat: create crop overlay component
1 parent 67528f3 commit 3d90a6a

File tree

4 files changed

+739
-281
lines changed

4 files changed

+739
-281
lines changed

components/ds/CropOverlayComponent.tsx

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,91 @@
11
"use client";
2-
import { FollowingTooltipComponent } from "./FollowingTooltipComponent";
3-
42
interface CropOverlayProps {
5-
isCropping: boolean;
63
cropRect: { x: number; y: number; width: number; height: number } | null;
7-
mousePosition: { x: number; y: number };
4+
onDone?: () => void;
85
}
96

107
const CropOverlayComponent: React.FC<CropOverlayProps> = ({
11-
isCropping,
128
cropRect,
13-
mousePosition,
9+
onDone,
1410
}) => {
15-
if (!isCropping || !cropRect) return null;
11+
if (!cropRect) return null;
1612

1713
const left = Math.min(cropRect.x, cropRect.x + cropRect.width);
1814
const top = Math.min(cropRect.y, cropRect.y + cropRect.height);
1915
const width = Math.abs(cropRect.width);
2016
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";
2119

2220
return (
23-
<>
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+
/>
2484
<div
25-
className="absolute border-2 border-dashed border-black bg-white bg-opacity-30 pointer-events-none"
26-
style={{ left, top, width, height }}
27-
></div>
28-
<FollowingTooltipComponent
29-
message="Double-click inside the overlay to accept"
30-
position={mousePosition}
85+
data-crop-handle="w"
86+
className={`${handleBaseClass} -left-2 top-1/2 -translate-y-1/2 cursor-ew-resize`}
3187
/>
32-
</>
88+
</div>
3389
);
3490
};
3591

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

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,14 +171,20 @@ describe("Image Processing Functions", () => {
171171

172172
it("should calculate the crop dimensions correctly", () => {
173173
const imgMock = {
174+
naturalWidth: 1000,
175+
naturalHeight: 500,
174176
width: 1000,
175177
height: 500,
176178
} as HTMLImageElement;
177179

178180
const currentImageRefMock = {
179181
clientWidth: 500,
180182
clientHeight: 250,
181-
} as HTMLImageElement;
183+
getBoundingClientRect: jest.fn(() => ({
184+
width: 500,
185+
height: 250,
186+
})),
187+
} as unknown as HTMLImageElement;
182188

183189
const cropRect = { x: 50, y: 50, width: 100, height: 50 };
184190

@@ -198,14 +204,20 @@ describe("Image Processing Functions", () => {
198204

199205
it("should handle negative width and height values in cropRect", () => {
200206
const imgMock = {
207+
naturalWidth: 1000,
208+
naturalHeight: 500,
201209
width: 1000,
202210
height: 500,
203211
} as HTMLImageElement;
204212

205213
const currentImageRefMock = {
206214
clientWidth: 500,
207215
clientHeight: 250,
208-
} as HTMLImageElement;
216+
getBoundingClientRect: jest.fn(() => ({
217+
width: 500,
218+
height: 250,
219+
})),
220+
} as unknown as HTMLImageElement;
209221

210222
const cropRect = { x: 150, y: 150, width: -100, height: -50 };
211223

@@ -223,6 +235,39 @@ describe("Image Processing Functions", () => {
223235
});
224236
});
225237

238+
it("should clamp crop dimensions to image boundaries", () => {
239+
const imgMock = {
240+
naturalWidth: 1000,
241+
naturalHeight: 500,
242+
width: 1000,
243+
height: 500,
244+
} as HTMLImageElement;
245+
246+
const currentImageRefMock = {
247+
clientWidth: 500,
248+
clientHeight: 250,
249+
getBoundingClientRect: jest.fn(() => ({
250+
width: 500,
251+
height: 250,
252+
})),
253+
} as unknown as HTMLImageElement;
254+
255+
const cropRect = { x: -10, y: -20, width: 600, height: 400 };
256+
257+
const result = calculateCropDimensions(
258+
imgMock,
259+
currentImageRefMock,
260+
cropRect
261+
);
262+
263+
expect(result).toEqual({
264+
x: 0,
265+
y: 0,
266+
width: 1000,
267+
height: 500,
268+
});
269+
});
270+
226271
const cropRect = { x: 50, y: 50, width: 100, height: 50 };
227272

228273
it("should return true for a point inside the crop rectangle", () => {

components/utils/resize-image.utils.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -244,15 +244,44 @@ export function calculateCropDimensions(
244244
currentImageRef: HTMLImageElement,
245245
cropRect: { x: number; y: number; width: number; height: number }
246246
): CropDimensions {
247-
const scaleX = img.width / currentImageRef.clientWidth;
248-
const scaleY = img.height / currentImageRef.clientHeight;
247+
const imageRect = currentImageRef.getBoundingClientRect();
248+
const renderedWidth = imageRect.width || currentImageRef.clientWidth;
249+
const renderedHeight = imageRect.height || currentImageRef.clientHeight;
249250

250-
const x = Math.min(cropRect.x, cropRect.x + cropRect.width) * scaleX;
251-
const y = Math.min(cropRect.y, cropRect.y + cropRect.height) * scaleY;
252-
const width = Math.abs(cropRect.width) * scaleX;
253-
const height = Math.abs(cropRect.height) * scaleY;
251+
const sourceWidth =
252+
img.naturalWidth || currentImageRef.naturalWidth || img.width;
253+
const sourceHeight =
254+
img.naturalHeight || currentImageRef.naturalHeight || img.height;
254255

255-
return { x, y, width, height };
256+
if (!renderedWidth || !renderedHeight || !sourceWidth || !sourceHeight) {
257+
return { x: 0, y: 0, width: 0, height: 0 };
258+
}
259+
260+
const scaleX = sourceWidth / renderedWidth;
261+
const scaleY = sourceHeight / renderedHeight;
262+
263+
const normalizedX = Math.min(cropRect.x, cropRect.x + cropRect.width);
264+
const normalizedY = Math.min(cropRect.y, cropRect.y + cropRect.height);
265+
const normalizedWidth = Math.abs(cropRect.width);
266+
const normalizedHeight = Math.abs(cropRect.height);
267+
268+
const x = Math.max(0, Math.min(normalizedX * scaleX, sourceWidth));
269+
const y = Math.max(0, Math.min(normalizedY * scaleY, sourceHeight));
270+
const width = Math.max(
271+
0,
272+
Math.min(normalizedWidth * scaleX, sourceWidth - x)
273+
);
274+
const height = Math.max(
275+
0,
276+
Math.min(normalizedHeight * scaleY, sourceHeight - y)
277+
);
278+
279+
return {
280+
x: Math.round(x),
281+
y: Math.round(y),
282+
width: Math.round(width),
283+
height: Math.round(height),
284+
};
256285
}
257286
interface CropRect {
258287
x: number;

0 commit comments

Comments
 (0)