Skip to content
Merged
92 changes: 92 additions & 0 deletions components/ds/CropOverlayComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"use client";
interface CropOverlayProps {
cropRect: { x: number; y: number; width: number; height: number } | null;
onDone?: () => void;
}

const CropOverlayComponent: React.FC<CropOverlayProps> = ({
cropRect,
onDone,
}) => {
if (!cropRect) return null;

const left = Math.min(cropRect.x, cropRect.x + cropRect.width);
const top = Math.min(cropRect.y, cropRect.y + cropRect.height);
const width = Math.abs(cropRect.width);
const height = Math.abs(cropRect.height);
const handleBaseClass =
"absolute h-3.5 w-3.5 rounded-full border border-white bg-primary shadow pointer-events-auto";

return (
<div
className="absolute border-2 border-white/90 pointer-events-none"
style={{
left,
top,
width,
height,
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.45)",
}}
>
<div className="absolute left-1/3 top-0 h-full w-px bg-white/70" />
<div className="absolute left-2/3 top-0 h-full w-px bg-white/70" />
<div className="absolute top-1/3 left-0 h-px w-full bg-white/70" />
<div className="absolute top-2/3 left-0 h-px w-full bg-white/70" />

<div
data-crop-area="true"
className="absolute inset-0 pointer-events-auto cursor-move bg-transparent"
/>

{onDone && (
<button
type="button"
data-crop-action="done"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onDone();
}}
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"
>
Done
</button>
)}

<div
data-crop-handle="nw"
className={`${handleBaseClass} -left-2 -top-2 cursor-nwse-resize`}
/>
<div
data-crop-handle="n"
className={`${handleBaseClass} left-1/2 -top-2 -translate-x-1/2 cursor-ns-resize`}
/>
<div
data-crop-handle="ne"
className={`${handleBaseClass} -right-2 -top-2 cursor-nesw-resize`}
/>
<div
data-crop-handle="e"
className={`${handleBaseClass} -right-2 top-1/2 -translate-y-1/2 cursor-ew-resize`}
/>
<div
data-crop-handle="se"
className={`${handleBaseClass} -right-2 -bottom-2 cursor-nwse-resize`}
/>
<div
data-crop-handle="s"
className={`${handleBaseClass} left-1/2 -bottom-2 -translate-x-1/2 cursor-ns-resize`}
/>
<div
data-crop-handle="sw"
className={`${handleBaseClass} -left-2 -bottom-2 cursor-nesw-resize`}
/>
<div
data-crop-handle="w"
className={`${handleBaseClass} -left-2 top-1/2 -translate-y-1/2 cursor-ew-resize`}
/>
</div>
);
};

export { CropOverlayComponent };
16 changes: 16 additions & 0 deletions components/ds/DividerComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"use client";
interface DividerProps {
margin?: "small" | "medium" | "large";
}

const DividerComponent: React.FC<DividerProps> = ({ margin = "small" }) => {
const marginClasses = {
small: "my-2",
medium: "my-6",
large: "my-8",
};

return <div className={`bg-border h-[1px] ${marginClasses[margin]}`}></div>;
};

export { DividerComponent };
21 changes: 21 additions & 0 deletions components/ds/FollowingTooltipComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";
interface TooltipProps {
message: string;
position: { x: number; y: number };
}

const FollowingTooltipComponent: React.FC<TooltipProps> = ({
message,
position,
}) => {
return (
<div
className="fixed px-2 py-1 bg-gray-800 text-white text-xs rounded shadow-lg"
style={{ left: position.x + 10, top: position.y - 30 }}
>
{message}
</div>
);
};

export { FollowingTooltipComponent };
152 changes: 139 additions & 13 deletions components/utils/resize-image.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
calculateCropDimensions,
handleResizeImage,
isPointInCropRect,
processImageFile,
resizeImage,
updateHeight,
Expand All @@ -15,11 +17,19 @@ describe("Image Processing Functions", () => {
canvasMock = document.createElement("canvas");
ctxMock = {
drawImage: jest.fn(),
toDataURL: jest.fn().mockReturnValue("data:image/png;base64,MOCK_DATA"),
} as unknown as CanvasRenderingContext2D;

jest.spyOn(document, "createElement").mockReturnValue(canvasMock);
jest.spyOn(canvasMock, "getContext").mockReturnValue(ctxMock);
jest
.spyOn(canvasMock, "toBlob")
.mockImplementation((callback: BlobCallback) => {
callback(new Blob(["MOCK_DATA"], { type: "image/png" }));
});
Object.defineProperty(URL, "createObjectURL", {
writable: true,
value: jest.fn(() => "blob:mock-url"),
});

jest.spyOn(window, "FileReader").mockImplementation(
() =>
Expand Down Expand Up @@ -59,7 +69,7 @@ describe("Image Processing Functions", () => {
quality: 1,
});

expect(result).toMatch(/^data:image\/png;base64,/);
expect(result).toMatch(/^blob:/);
expect(ctxMock.drawImage).toHaveBeenCalledWith(img, 0, 0, 500, 250);
});

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

expect(result).toMatch(/^data:image\/jpeg;base64,/);
expect(result).toMatch(/^blob:/);
expect(ctxMock.drawImage).toHaveBeenCalledWith(img, 0, 0, 500, 250);
});

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

processImageFile({
file: mockFile,
source: mockFile,
format: "jpeg",
preserveAspectRatio: true,
quality: 0.8,
Expand All @@ -107,9 +117,7 @@ describe("Image Processing Functions", () => {
done: () => {
expect(setWidth).toHaveBeenCalledWith(1000);
expect(setHeight).toHaveBeenCalledWith(500);
expect(setOutput).toHaveBeenCalledWith(
expect.stringMatching(/^data:image\/jpeg;base64,/)
);
expect(setOutput).toHaveBeenCalledWith(expect.stringMatching(/^blob:/));
done();
},
});
Expand All @@ -121,7 +129,7 @@ describe("Image Processing Functions", () => {
});
const setWidth = jest.fn();

updateWidth({ file: mockFile, height: 200, setWidth });
updateWidth({ source: mockFile, height: 200, setWidth });

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

updateHeight({ file: mockFile, width: 300, setHeight });
updateHeight({ source: mockFile, width: 300, setHeight });

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

handleResizeImage({
file: mockFile,
source: mockFile,
format: "jpeg",
height: 400,
width: 600,
Expand All @@ -160,10 +168,128 @@ describe("Image Processing Functions", () => {
});

setTimeout(() => {
expect(setOutput).toHaveBeenCalledWith(
expect.stringMatching(/^data:image\/jpeg;base64,/)
);
expect(setOutput).toHaveBeenCalledWith(expect.stringMatching(/^blob:/));
done();
}, 0);
});

it("should calculate the crop dimensions correctly", () => {
const imgMock = {
naturalWidth: 1000,
naturalHeight: 500,
width: 1000,
height: 500,
} as HTMLImageElement;

const currentImageRefMock = {
clientWidth: 500,
clientHeight: 250,
getBoundingClientRect: jest.fn(() => ({
width: 500,
height: 250,
})),
} as unknown as HTMLImageElement;

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

const result = calculateCropDimensions(
imgMock,
currentImageRefMock,
cropRect
);

expect(result).toEqual({
x: 100,
y: 100,
width: 200,
height: 100,
});
});

it("should handle negative width and height values in cropRect", () => {
const imgMock = {
naturalWidth: 1000,
naturalHeight: 500,
width: 1000,
height: 500,
} as HTMLImageElement;

const currentImageRefMock = {
clientWidth: 500,
clientHeight: 250,
getBoundingClientRect: jest.fn(() => ({
width: 500,
height: 250,
})),
} as unknown as HTMLImageElement;

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

const result = calculateCropDimensions(
imgMock,
currentImageRefMock,
cropRect
);

expect(result).toEqual({
x: 100,
y: 200,
width: 200,
height: 100,
});
});

it("should clamp crop dimensions to image boundaries", () => {
const imgMock = {
naturalWidth: 1000,
naturalHeight: 500,
width: 1000,
height: 500,
} as HTMLImageElement;

const currentImageRefMock = {
clientWidth: 500,
clientHeight: 250,
getBoundingClientRect: jest.fn(() => ({
width: 500,
height: 250,
})),
} as unknown as HTMLImageElement;

const cropRect = { x: -10, y: -20, width: 600, height: 400 };

const result = calculateCropDimensions(
imgMock,
currentImageRefMock,
cropRect
);

expect(result).toEqual({
x: 0,
y: 0,
width: 1000,
height: 500,
});
});

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

it("should return true for a point inside the crop rectangle", () => {
const result = isPointInCropRect(75, 75, cropRect);
expect(result).toBe(true);
});

it("should return false for a point outside the crop rectangle", () => {
const result = isPointInCropRect(200, 200, cropRect);
expect(result).toBe(false);
});

it("should handle negative width and height in crop rectangle", () => {
const cropRectNegative = { x: 150, y: 150, width: -100, height: -50 };
const result = isPointInCropRect(75, 75, cropRectNegative);
expect(result).toBe(false);

const resultInside = isPointInCropRect(125, 125, cropRectNegative);
expect(resultInside).toBe(true);
});
});
Loading