diff --git a/components/ds/CropOverlayComponent.tsx b/components/ds/CropOverlayComponent.tsx new file mode 100644 index 0000000..71c4e70 --- /dev/null +++ b/components/ds/CropOverlayComponent.tsx @@ -0,0 +1,92 @@ +"use client"; +interface CropOverlayProps { + cropRect: { x: number; y: number; width: number; height: number } | null; + onDone?: () => void; +} + +const CropOverlayComponent: React.FC = ({ + 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 ( +
+
+
+
+
+ +
+ + {onDone && ( + + )} + +
+
+
+
+
+
+
+
+
+ ); +}; + +export { CropOverlayComponent }; diff --git a/components/ds/DividerComponent.tsx b/components/ds/DividerComponent.tsx new file mode 100644 index 0000000..228a7da --- /dev/null +++ b/components/ds/DividerComponent.tsx @@ -0,0 +1,16 @@ +"use client"; +interface DividerProps { + margin?: "small" | "medium" | "large"; +} + +const DividerComponent: React.FC = ({ margin = "small" }) => { + const marginClasses = { + small: "my-2", + medium: "my-6", + large: "my-8", + }; + + return
; +}; + +export { DividerComponent }; diff --git a/components/ds/FollowingTooltipComponent.tsx b/components/ds/FollowingTooltipComponent.tsx new file mode 100644 index 0000000..f11cad4 --- /dev/null +++ b/components/ds/FollowingTooltipComponent.tsx @@ -0,0 +1,21 @@ +"use client"; +interface TooltipProps { + message: string; + position: { x: number; y: number }; +} + +const FollowingTooltipComponent: React.FC = ({ + message, + position, +}) => { + return ( +
+ {message} +
+ ); +}; + +export { FollowingTooltipComponent }; diff --git a/components/utils/resize-image.utils.test.ts b/components/utils/resize-image.utils.test.ts index b200d2f..75af23c 100644 --- a/components/utils/resize-image.utils.test.ts +++ b/components/utils/resize-image.utils.test.ts @@ -1,5 +1,7 @@ import { + calculateCropDimensions, handleResizeImage, + isPointInCropRect, processImageFile, resizeImage, updateHeight, @@ -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( () => @@ -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); }); @@ -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); }); @@ -97,7 +107,7 @@ describe("Image Processing Functions", () => { const setOutput = jest.fn(); processImageFile({ - file: mockFile, + source: mockFile, format: "jpeg", preserveAspectRatio: true, quality: 0.8, @@ -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(); }, }); @@ -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); @@ -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); @@ -150,7 +158,7 @@ describe("Image Processing Functions", () => { const setOutput = jest.fn(); handleResizeImage({ - file: mockFile, + source: mockFile, format: "jpeg", height: 400, width: 600, @@ -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); + }); }); diff --git a/components/utils/resize-image.utils.ts b/components/utils/resize-image.utils.ts index d7809eb..f52c8fd 100644 --- a/components/utils/resize-image.utils.ts +++ b/components/utils/resize-image.utils.ts @@ -8,6 +8,25 @@ interface ResizeImageOptions { preserveAspectRatio?: boolean; } +const canvasToObjectUrl = ( + canvas: HTMLCanvasElement, + format: Format, + quality?: number +) => + new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error("Canvas blob generation failed")); + return; + } + resolve(URL.createObjectURL(blob)); + }, + `image/${format}`, + quality + ); + }); + export function resizeImage({ img, format, @@ -17,15 +36,15 @@ export function resizeImage({ width, }: ResizeImageOptions): Promise { return new Promise((resolve, reject) => { + const normalizedFormat: Format = format ?? "png"; + if (format === "svg") { const svg = ` `; const svgBlob = new Blob([svg], { type: "image/svg+xml" }); - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.readAsDataURL(svgBlob); + resolve(URL.createObjectURL(svgBlob)); return; } @@ -66,13 +85,14 @@ export function resizeImage({ ctx.imageSmoothingQuality = "high"; ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - const dataURL = canvas.toDataURL(`image/${format}`, quality); - resolve(dataURL); + canvasToObjectUrl(canvas, normalizedFormat, quality) + .then(resolve) + .catch(reject); }); } interface ProcessImageFileOptions { - file: File; + source: File; setWidth: (width: number) => void; setHeight: (height: number) => void; setOutput: (output: string) => void; @@ -83,7 +103,7 @@ interface ProcessImageFileOptions { } export const processImageFile = ({ - file, + source, format, preserveAspectRatio, quality, @@ -92,73 +112,104 @@ export const processImageFile = ({ setWidth, done, }: ProcessImageFileOptions) => { - const reader = new FileReader(); - reader.onload = (e) => { - const img = new Image(); - img.src = e.target?.result as string; - img.onload = () => { - setWidth(img.width); - setHeight(img.height); - resizeImage({ - img, - width: img.width, - height: img.height, - format, - quality, - preserveAspectRatio, - }) - .then(setOutput) - .catch((error) => console.error(error)) - .finally(() => { - if (done) { - done(); - } - }); - }; + const img = new Image(); + const handleLoad = () => { + setWidth(img.width); + setHeight(img.height); + resizeImage({ + img, + width: img.width, + height: img.height, + format, + quality, + preserveAspectRatio, + }) + .then(setOutput) + .catch((error) => console.error(error)) + .finally(() => { + if (done) { + done(); + } + }); }; - reader.readAsDataURL(file); + + if (typeof source === "string") { + img.src = source; + img.onload = handleLoad; + } else { + const reader = new FileReader(); + reader.onload = (e) => { + img.src = e.target?.result as string; + img.onload = handleLoad; + }; + reader.readAsDataURL(source); + } }; interface UpdateWidthOptions { height: number; - file: File; + source: File | string; setWidth: (width: number) => void; } -export const updateWidth = ({ file, height, setWidth }: UpdateWidthOptions) => { +export const updateWidth = ({ + source, + height, + setWidth, +}: UpdateWidthOptions) => { const img = new Image(); - const reader = new FileReader(); - reader.onload = (e) => { - img.src = e.target?.result as string; - img.onload = () => { - const newWidth = Math.round(height * (img.width / img.height)); - setWidth(newWidth); - }; + + const handleLoad = () => { + const newWidth = Math.round(height * (img.width / img.height)); + setWidth(newWidth); }; - reader.readAsDataURL(file); + + if (typeof source === "string") { + img.src = source; + img.onload = handleLoad; + } else { + const reader = new FileReader(); + reader.onload = (e) => { + img.src = e.target?.result as string; + img.onload = handleLoad; + }; + reader.readAsDataURL(source); + } }; -interface UpdateWidthOption { +interface UpdateHeightOptions { width: number; - file: File; + source: File | string; setHeight: (height: number) => void; } -export const updateHeight = ({ file, setHeight, width }: UpdateWidthOption) => { +export const updateHeight = ({ + source, + setHeight, + width, +}: UpdateHeightOptions) => { const img = new Image(); - const reader = new FileReader(); - reader.onload = (e) => { - img.src = e.target?.result as string; - img.onload = () => { - const newHeight = Math.round(width / (img.width / img.height)); - setHeight(newHeight); - }; + + const handleLoad = () => { + const newHeight = Math.round(width / (img.width / img.height)); + setHeight(newHeight); }; - reader.readAsDataURL(file); + + if (typeof source === "string") { + img.src = source; + img.onload = handleLoad; + } else { + const reader = new FileReader(); + reader.onload = (e) => { + img.src = e.target?.result as string; + img.onload = handleLoad; + }; + reader.readAsDataURL(source); + } }; interface HandleResizeImage { - file: File; + source: File | string; width: number | undefined; height: number | undefined; format: Format; @@ -168,7 +219,7 @@ interface HandleResizeImage { } export const handleResizeImage = ({ - file, + source, format, height, preserveAspectRatio, @@ -176,20 +227,97 @@ export const handleResizeImage = ({ setOutput, width, }: HandleResizeImage) => { - const reader = new FileReader(); - reader.onload = (e) => { - const img = new Image(); - img.src = e.target?.result as string; - img.onload = () => { - resizeImage({ - img, - width, - height, - format, - quality, - preserveAspectRatio, - }).then(setOutput); - }; + const img = new Image(); + const handleLoad = () => { + resizeImage({ + img, + width, + height, + format, + quality, + preserveAspectRatio, + }).then(setOutput); }; - reader.readAsDataURL(file); + + if (typeof source === "string") { + img.src = source; + img.onload = handleLoad; + } else { + const reader = new FileReader(); + reader.onload = (e) => { + img.src = e.target?.result as string; + img.onload = handleLoad; + }; + reader.readAsDataURL(source); + } }; + +interface CropDimensions { + x: number; + y: number; + width: number; + height: number; +} + +export function calculateCropDimensions( + img: HTMLImageElement, + currentImageRef: HTMLImageElement, + cropRect: { x: number; y: number; width: number; height: number } +): CropDimensions { + const imageRect = currentImageRef.getBoundingClientRect(); + const renderedWidth = imageRect.width || currentImageRef.clientWidth; + const renderedHeight = imageRect.height || currentImageRef.clientHeight; + + const sourceWidth = + img.naturalWidth || currentImageRef.naturalWidth || img.width; + const sourceHeight = + img.naturalHeight || currentImageRef.naturalHeight || img.height; + + if (!renderedWidth || !renderedHeight || !sourceWidth || !sourceHeight) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + + const scaleX = sourceWidth / renderedWidth; + const scaleY = sourceHeight / renderedHeight; + + const normalizedX = Math.min(cropRect.x, cropRect.x + cropRect.width); + const normalizedY = Math.min(cropRect.y, cropRect.y + cropRect.height); + const normalizedWidth = Math.abs(cropRect.width); + const normalizedHeight = Math.abs(cropRect.height); + + const x = Math.max(0, Math.min(normalizedX * scaleX, sourceWidth)); + const y = Math.max(0, Math.min(normalizedY * scaleY, sourceHeight)); + const width = Math.max( + 0, + Math.min(normalizedWidth * scaleX, sourceWidth - x) + ); + const height = Math.max( + 0, + Math.min(normalizedHeight * scaleY, sourceHeight - y) + ); + + return { + x: Math.round(x), + y: Math.round(y), + width: Math.round(width), + height: Math.round(height), + }; +} +interface CropRect { + x: number; + y: number; + width: number; + height: number; +} + +export function isPointInCropRect( + x: number, + y: number, + cropRect: CropRect +): boolean { + const rectLeft = Math.min(cropRect.x, cropRect.x + cropRect.width); + const rectTop = Math.min(cropRect.y, cropRect.y + cropRect.height); + const rectRight = rectLeft + Math.abs(cropRect.width); + const rectBottom = rectTop + Math.abs(cropRect.height); + return x >= rectLeft && x <= rectRight && y >= rectTop && y <= rectBottom; +} diff --git a/pages/utilities/hex-to-rgb.tsx b/pages/utilities/hex-to-rgb.tsx index 4b998f2..3e1d8fc 100644 --- a/pages/utilities/hex-to-rgb.tsx +++ b/pages/utilities/hex-to-rgb.tsx @@ -21,6 +21,7 @@ import CallToActionGrid from "@/components/CallToActionGrid"; import Meta from "@/components/Meta"; import { cn } from "@/lib/utils"; import RgbToHexSEO from "@/components/seo/RgbToHexSEO"; +import { DividerComponent } from "../../components/ds/DividerComponent"; const DEFAULT_RGB: RGBValues = { r: "0", g: "0", b: "0" }; @@ -123,7 +124,7 @@ export default function HEXtoRGB(props: HEXtoRGBProps) {
- +
{(["r", "g", "b"] as (keyof RGBValues)[]).map((colorKey) => { @@ -154,7 +155,7 @@ export default function HEXtoRGB(props: HEXtoRGBProps) {
- + ); } - -const Divider = () => { - return
; -}; diff --git a/pages/utilities/image-resizer.tsx b/pages/utilities/image-resizer.tsx index df16eb0..f5f7874 100644 --- a/pages/utilities/image-resizer.tsx +++ b/pages/utilities/image-resizer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useMemo, ChangeEvent } from "react"; +import { useCallback, useState, ChangeEvent, useRef, useEffect } from "react"; import PageHeader from "@/components/PageHeader"; import { Card } from "@/components/ds/CardComponent"; import { Button } from "@/components/ds/ButtonComponent"; @@ -8,9 +8,10 @@ import { CMDK } from "@/components/CMDK"; import CallToActionGrid from "@/components/CallToActionGrid"; import Meta from "@/components/Meta"; import { + calculateCropDimensions, Format, handleResizeImage, - processImageFile, + isPointInCropRect, updateHeight, updateWidth, } from "@/components/utils/resize-image.utils"; @@ -19,11 +20,18 @@ import { Checkbox } from "@/components/ds/CheckboxComponent"; import { Input } from "@/components/ds/InputComponent"; import { ImageUploadComponent } from "@/components/ds/ImageUploadComponent"; import { cn } from "@/lib/utils"; -import { DownloadIcon } from "lucide-react"; +import { Crop, DownloadIcon } from "lucide-react"; import GitHubContribution from "@/components/GitHubContribution"; +import { CropOverlayComponent } from "@/components/ds/CropOverlayComponent"; +import { DividerComponent } from "../../components/ds/DividerComponent"; const MAX_DIMENSION = 1024 * 4; const MAX_FILE_SIZE = 40 * 1024 * 1024; +const MIN_CROP_SIZE = 24; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + interface FormatOption { value: Format; label: string; @@ -41,6 +49,64 @@ interface ResizedImageInfo { quality?: number; } +interface CropPoint { + x: number; + y: number; +} + +interface CropRect { + x: number; + y: number; + width: number; + height: number; +} + +type CropHandle = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; + +interface CropInteractionState { + mode: "move" | "resize"; + startPoint: CropPoint; + startRect: CropRect; + imageLeft: number; + imageTop: number; + imageWidth: number; + imageHeight: number; + handle?: CropHandle; +} + +const getImageRect = (imageElement: HTMLImageElement | null) => { + if (!imageElement) { + return null; + } + + const rect = imageElement.getBoundingClientRect(); + if (!rect.width || !rect.height) { + return null; + } + + return rect; +}; + +const getPointerPositionInRect = ( + clientX: number, + clientY: number, + rect: { + left: number; + top: number; + width: number; + height: number; + } +): CropPoint => ({ + x: clamp(clientX - rect.left, 0, rect.width), + y: clamp(clientY - rect.top, 0, rect.height), +}); + +const areCropRectsEqual = (left: CropRect, right: CropRect) => + left.x === right.x && + left.y === right.y && + left.width === right.width && + left.height === right.height; + export default function ImageResize() { const [imageFile, setImageFile] = useState(null); const [output, setOutput] = useState(""); @@ -54,97 +120,203 @@ export default function ImageResize() { {} ); - function setOutputAndShowAnimation(output: string) { + const [isCropping, setIsCropping] = useState(false); + const [cropRect, setCropRect] = useState(null); + const [isOriginalOutput, setIsOriginalOutput] = useState(false); + const imageRef = useRef(null); + const animationTimeoutRef = useRef(null); + const outputObjectUrlRef = useRef(null); + const cropInteractionRef = useRef(null); + const cropFrameRef = useRef(null); + const pendingCropRectRef = useRef(null); + + const clearScheduledCropUpdate = useCallback(() => { + if (cropFrameRef.current !== null) { + window.cancelAnimationFrame(cropFrameRef.current); + cropFrameRef.current = null; + } + pendingCropRectRef.current = null; + }, []); + + const clearCropInteraction = useCallback(() => { + cropInteractionRef.current = null; + }, []); + + const releaseOutputObjectUrl = useCallback(() => { + if (outputObjectUrlRef.current) { + URL.revokeObjectURL(outputObjectUrlRef.current); + outputObjectUrlRef.current = null; + } + }, []); + + useEffect( + () => () => { + if (animationTimeoutRef.current) { + window.clearTimeout(animationTimeoutRef.current); + } + clearScheduledCropUpdate(); + releaseOutputObjectUrl(); + }, + [clearScheduledCropUpdate, releaseOutputObjectUrl] + ); + + const setOutputAndShowAnimation = useCallback((output: string) => { setOutput(output); setShowAnimation(true); - setTimeout(() => { + if (animationTimeoutRef.current) { + window.clearTimeout(animationTimeoutRef.current); + } + animationTimeoutRef.current = window.setTimeout(() => { setShowAnimation(false); }, 500); - } + }, []); - const handleFileSelect = useCallback( - (file: File) => { - setImageFile(file); - processImageFile({ - file, + const resetCropStates = useCallback(() => { + setIsCropping(false); + setCropRect(null); + clearScheduledCropUpdate(); + clearCropInteraction(); + }, [clearCropInteraction, clearScheduledCropUpdate]); + + const updateResizedImageInfo = useCallback( + (width: number, height: number, format: Format, quality: number) => { + setResizedImageInfo({ + width: Math.round(width), + height: Math.round(height), format, - preserveAspectRatio, quality, - setHeight, - setOutput: (output) => { - setResizedImageInfo({ - width: undefined, - height: undefined, - format: undefined, - }); - setOutputAndShowAnimation(output); - }, - setWidth, }); }, - [format, preserveAspectRatio, quality] + [] + ); + + const handleFileSelect = useCallback( + (file: File) => { + releaseOutputObjectUrl(); + resetCropStates(); + setImageFile(file); + const objectUrl = URL.createObjectURL(file); + outputObjectUrlRef.current = objectUrl; + const img = new Image(); + img.onload = () => { + const originalWidth = img.naturalWidth || img.width; + const originalHeight = img.naturalHeight || img.height; + setWidth(originalWidth); + setHeight(originalHeight); + setResizedImageInfo({ + width: originalWidth, + height: originalHeight, + format: undefined, + }); + setIsOriginalOutput(true); + setOutputAndShowAnimation(objectUrl); + }; + img.onerror = () => { + releaseOutputObjectUrl(); + setImageFile(null); + setOutput(""); + }; + img.src = objectUrl; + }, + [releaseOutputObjectUrl, resetCropStates, setOutputAndShowAnimation] ); const handleAspectRatioChange = useCallback(() => { setPreserveAspectRatio((prev) => { const newValue = !prev; - - if (newValue && imageFile && width) { - updateHeight({ width, file: imageFile, setHeight }); + if (newValue && output && width) { + updateHeight({ source: output, setHeight, width }); } - return newValue; }); - }, [imageFile, width]); + }, [output, width]); const handleWidthChange = useCallback( (e: ChangeEvent) => { - let newWidth = parseInt(e.target.value); - if (newWidth > MAX_DIMENSION) { - newWidth = MAX_DIMENSION; + if (e.target.value === "") { + setWidth(undefined); + return; + } + + const parsedValue = Number(e.target.value); + + if (Number.isNaN(parsedValue)) { + setWidth(undefined); + return; } + + const newWidth = clamp(Math.round(parsedValue), 1, MAX_DIMENSION); setWidth(newWidth); - if (preserveAspectRatio && imageFile) { - updateHeight({ file: imageFile, setHeight, width: newWidth }); + if (preserveAspectRatio && output) { + updateHeight({ source: output, setHeight, width: newWidth }); } }, - [preserveAspectRatio, imageFile] + [preserveAspectRatio, output] ); const handleHeightChange = useCallback( (e: ChangeEvent) => { - let newHeight = parseInt(e.target.value); - if (newHeight > MAX_DIMENSION) { - newHeight = MAX_DIMENSION; + if (e.target.value === "") { + setHeight(undefined); + return; + } + + const parsedValue = Number(e.target.value); + + if (Number.isNaN(parsedValue)) { + setHeight(undefined); + return; } + + const newHeight = clamp(Math.round(parsedValue), 1, MAX_DIMENSION); setHeight(newHeight); - if (preserveAspectRatio && imageFile) { - updateWidth({ file: imageFile, height: newHeight, setWidth }); + if (preserveAspectRatio && output) { + updateWidth({ source: output, height: newHeight, setWidth }); } }, - [preserveAspectRatio, imageFile] + [preserveAspectRatio, output] + ); + + const commitTransformedOutput = useCallback( + (nextOutput: string) => { + releaseOutputObjectUrl(); + if (nextOutput.startsWith("blob:")) { + outputObjectUrlRef.current = nextOutput; + } + setIsOriginalOutput(false); + setOutputAndShowAnimation(nextOutput); + }, + [releaseOutputObjectUrl, setOutputAndShowAnimation] ); const handleResize = useCallback(() => { - if (imageFile) { + if (output) { handleResizeImage({ - file: imageFile, + source: output, format, height, preserveAspectRatio, quality, width, setOutput: (output) => { - setOutputAndShowAnimation(output); + commitTransformedOutput(output); setResizedImageInfo({ width, height, format, quality }); }, }); } - }, [imageFile, width, height, format, quality, preserveAspectRatio]); + }, [ + commitTransformedOutput, + output, + width, + height, + format, + quality, + preserveAspectRatio, + ]); - const resizedLabel = useMemo(() => { + const resizedLabel = (() => { const { height, width, format, quality } = resizedImageInfo; if (width && height && format) { @@ -152,36 +324,477 @@ export default function ImageResize() { return `${format.toUpperCase()} - ${width} x ${height} ${qualityLabel}`; } return "Click 'Resize' to see the dimensions"; - }, [resizedImageInfo]); + })(); + + const handleQualityInput = useCallback( + (e: ChangeEvent) => { + const parsedValue = Number(e.target.value); + if (Number.isNaN(parsedValue)) { + return; + } + const value = clamp(parsedValue, 0.1, 1); + setQuality(value); + + if (format === "jpeg" && output) { + handleResizeImage({ + source: output, + format, + height, + preserveAspectRatio, + quality: value, + width, + setOutput: (output) => { + commitTransformedOutput(output); + setResizedImageInfo({ width, height, format, quality: value }); + }, + }); + } + }, + [ + format, + output, + height, + preserveAspectRatio, + width, + commitTransformedOutput, + ] + ); + + const createInitialCropRect = useCallback( + (imageWidth: number, imageHeight: number): CropRect => { + const horizontalInset = Math.max(12, imageWidth * 0.08); + const verticalInset = Math.max(12, imageHeight * 0.08); + + const minWidth = Math.min(MIN_CROP_SIZE, imageWidth); + const minHeight = Math.min(MIN_CROP_SIZE, imageHeight); + + const cropWidth = clamp( + imageWidth - horizontalInset * 2, + minWidth, + imageWidth + ); + const cropHeight = clamp( + imageHeight - verticalInset * 2, + minHeight, + imageHeight + ); + + return { + x: (imageWidth - cropWidth) / 2, + y: (imageHeight - cropHeight) / 2, + width: cropWidth, + height: cropHeight, + }; + }, + [] + ); + + const handleCropModeToggle = useCallback(() => { + if (isCropping) { + clearCropInteraction(); + resetCropStates(); + } else { + if (!imageRef.current) { + return; + } + + const imageRect = imageRef.current.getBoundingClientRect(); + if (!imageRect.width || !imageRect.height) { + return; + } + + setIsCropping(true); + setCropRect(createInitialCropRect(imageRect.width, imageRect.height)); + clearCropInteraction(); + } + }, [ + clearCropInteraction, + createInitialCropRect, + isCropping, + resetCropStates, + ]); + + const getCropHandleFromTarget = useCallback( + (target: EventTarget | null): CropHandle | null => { + if (!(target instanceof HTMLElement)) { + return null; + } + + const handleElement = target.closest("[data-crop-handle]"); + if (!handleElement) { + return null; + } + + const handle = handleElement.getAttribute("data-crop-handle"); + if ( + handle === "n" || + handle === "s" || + handle === "e" || + handle === "w" || + handle === "ne" || + handle === "nw" || + handle === "se" || + handle === "sw" + ) { + return handle; + } + + return null; + }, + [] + ); - const handleQualityInput = useCallback((e: ChangeEvent) => { - let value = parseFloat(e.target.value); - if (value > 1) { - value = 1; + const queueCropRectUpdate = useCallback((nextRect: CropRect) => { + pendingCropRectRef.current = nextRect; + if (cropFrameRef.current !== null) { + return; } - setQuality(value); + + cropFrameRef.current = window.requestAnimationFrame(() => { + cropFrameRef.current = null; + const pendingRect = pendingCropRectRef.current; + pendingCropRectRef.current = null; + if (!pendingRect) { + return; + } + + setCropRect((currentRect) => { + if (!currentRect || !areCropRectsEqual(currentRect, pendingRect)) { + return pendingRect; + } + return currentRect; + }); + }); }, []); - const qualityInput = useMemo(() => { - if (format === "jpeg") { - return ( -
- - -
+ const flushPendingCropRect = useCallback(() => { + if (cropFrameRef.current !== null) { + window.cancelAnimationFrame(cropFrameRef.current); + cropFrameRef.current = null; + } + + const pendingRect = pendingCropRectRef.current; + pendingCropRectRef.current = null; + if (!pendingRect) { + return; + } + + setCropRect((currentRect) => { + if (!currentRect || !areCropRectsEqual(currentRect, pendingRect)) { + return pendingRect; + } + return currentRect; + }); + }, []); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + if (!isCropping || !cropRect) { + return; + } + + const imageRect = getImageRect(imageRef.current); + if (!imageRect) { + return; + } + + const position = getPointerPositionInRect( + e.clientX, + e.clientY, + imageRect + ); + + e.preventDefault(); + const handle = getCropHandleFromTarget(e.target); + + if (handle) { + e.currentTarget.setPointerCapture(e.pointerId); + cropInteractionRef.current = { + mode: "resize", + handle, + startPoint: position, + startRect: cropRect, + imageLeft: imageRect.left, + imageTop: imageRect.top, + imageWidth: imageRect.width, + imageHeight: imageRect.height, + }; + return; + } + + if (isPointInCropRect(position.x, position.y, cropRect)) { + e.currentTarget.setPointerCapture(e.pointerId); + cropInteractionRef.current = { + mode: "move", + startPoint: position, + startRect: cropRect, + imageLeft: imageRect.left, + imageTop: imageRect.top, + imageWidth: imageRect.width, + imageHeight: imageRect.height, + }; + } + }, + [cropRect, getCropHandleFromTarget, isCropping] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isCropping) { + return; + } + + const interaction = cropInteractionRef.current; + if (!interaction) { + return; + } + + const position = getPointerPositionInRect(e.clientX, e.clientY, { + left: interaction.imageLeft, + top: interaction.imageTop, + width: interaction.imageWidth, + height: interaction.imageHeight, + }); + + const imageWidth = interaction.imageWidth; + const imageHeight = interaction.imageHeight; + const minCropWidth = Math.min(MIN_CROP_SIZE, imageWidth); + const minCropHeight = Math.min(MIN_CROP_SIZE, imageHeight); + const dx = position.x - interaction.startPoint.x; + const dy = position.y - interaction.startPoint.y; + + if (interaction.mode === "move") { + const maxX = Math.max(0, imageWidth - interaction.startRect.width); + const maxY = Math.max(0, imageHeight - interaction.startRect.height); + queueCropRectUpdate({ + ...interaction.startRect, + x: clamp(interaction.startRect.x + dx, 0, maxX), + y: clamp(interaction.startRect.y + dy, 0, maxY), + }); + return; + } + + const { handle, startRect } = interaction; + if (!handle) { + return; + } + + let left = startRect.x; + let right = startRect.x + startRect.width; + let top = startRect.y; + let bottom = startRect.y + startRect.height; + + if (handle.includes("w")) { + left = clamp(left + dx, 0, right - minCropWidth); + } + if (handle.includes("e")) { + right = clamp(right + dx, left + minCropWidth, imageWidth); + } + if (handle.includes("n")) { + top = clamp(top + dy, 0, bottom - minCropHeight); + } + if (handle.includes("s")) { + bottom = clamp(bottom + dy, top + minCropHeight, imageHeight); + } + + queueCropRectUpdate({ + x: left, + y: top, + width: right - left, + height: bottom - top, + }); + }, + [isCropping, queueCropRectUpdate] + ); + + const stopPointerInteraction = useCallback(() => { + flushPendingCropRect(); + clearCropInteraction(); + }, [clearCropInteraction, flushPendingCropRect]); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + stopPointerInteraction(); + }, + [stopPointerInteraction] + ); + + const handlePointerCancel = useCallback(() => { + stopPointerInteraction(); + }, [stopPointerInteraction]); + + const applyCroppedOutput = useCallback( + (croppedDataUrl: string, cropWidth: number, cropHeight: number) => { + const normalizedWidth = Math.max(1, Math.round(cropWidth)); + const normalizedHeight = Math.max(1, Math.round(cropHeight)); + setWidth(normalizedWidth); + setHeight(normalizedHeight); + commitTransformedOutput(croppedDataUrl); + updateResizedImageInfo( + normalizedWidth, + normalizedHeight, + format, + quality ); + resetCropStates(); + }, + [ + format, + quality, + resetCropStates, + commitTransformedOutput, + updateResizedImageInfo, + ] + ); + + const cropSvgImage = useCallback( + ( + img: HTMLImageElement, + x: number, + y: number, + width: number, + height: number + ) => { + const sourceWidth = img.naturalWidth || img.width; + const sourceHeight = img.naturalHeight || img.height; + const svg = ` + + + `; + const svgBlob = new Blob([svg], { type: "image/svg+xml" }); + const croppedObjectUrl = URL.createObjectURL(svgBlob); + applyCroppedOutput(croppedObjectUrl, width, height); + }, + [applyCroppedOutput] + ); + + const cropCanvasImage = useCallback( + ( + img: HTMLImageElement, + x: number, + y: number, + width: number, + height: number + ) => { + const normalizedWidth = Math.max(1, Math.round(width)); + const normalizedHeight = Math.max(1, Math.round(height)); + const canvas = document.createElement("canvas"); + canvas.width = normalizedWidth; + canvas.height = normalizedHeight; + + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.drawImage( + img, + Math.round(x), + Math.round(y), + normalizedWidth, + normalizedHeight, + 0, + 0, + canvas.width, + canvas.height + ); + canvas.toBlob( + (blob) => { + if (!blob) { + return; + } + const croppedObjectUrl = URL.createObjectURL(blob); + applyCroppedOutput(croppedObjectUrl, canvas.width, canvas.height); + }, + `image/${format}`, + quality + ); + } + }, + [applyCroppedOutput, format, quality] + ); + + const handleApplyCrop = useCallback(() => { + if (!isCropping || !imageRef.current) { + return; } - return null; - }, [format, handleQualityInput, imageFile, quality]); + + const activeCropRect = pendingCropRectRef.current ?? cropRect; + if (!activeCropRect) { + return; + } + + const currentImageRef = imageRef.current; + const { x, y, width, height } = calculateCropDimensions( + currentImageRef, + currentImageRef, + activeCropRect + ); + + if (width < 1 || height < 1) { + return; + } + + if (format === "svg") { + cropSvgImage(currentImageRef, x, y, width, height); + } else { + cropCanvasImage(currentImageRef, x, y, width, height); + } + }, [cropCanvasImage, cropRect, cropSvgImage, format, isCropping]); + + const handleFormatChange = useCallback( + (value: string) => { + setFormat(value as Format); + if (output) { + handleResizeImage({ + source: output, + format: value as Format, + height, + preserveAspectRatio, + quality, + width, + setOutput: (newOutput) => { + commitTransformedOutput(newOutput); + setResizedImageInfo({ + width, + height, + format: value as Format, + quality, + }); + }, + }); + } + }, + [ + output, + height, + preserveAspectRatio, + quality, + width, + commitTransformedOutput, + ] + ); + + const cropSelectionLabel = (() => { + if (!cropRect) { + return ""; + } + + return `x:${Math.round(cropRect.x)} y:${Math.round(cropRect.y)} ${Math.round( + cropRect.width + )}x${Math.round(cropRect.height)}`; + })(); + + const downloadExtension = (() => { + if (!isOriginalOutput || !imageFile) { + return format; + } + + const fileNamePart = imageFile.name.split(".").pop()?.toLowerCase(); + return fileNamePart || format; + })(); return (
@@ -216,7 +829,7 @@ export default function ImageResize() { onChange={handleWidthChange} value={width ?? ""} className="mb-2" - disabled={!imageFile} + disabled={!imageFile || isCropping} />
@@ -227,7 +840,7 @@ export default function ImageResize() { onChange={handleHeightChange} value={height ?? ""} className="mb-2" - disabled={!imageFile} + disabled={!imageFile || isCropping} />
@@ -237,7 +850,7 @@ export default function ImageResize() { id="preserve-aspect-ratio" checked={preserveAspectRatio} onCheckedChange={handleAspectRatioChange} - disabled={!imageFile} + disabled={!imageFile || isCropping} className="mr-1" />
- +
- + setFormat(value as Format)} - disabled={!imageFile} + onSelect={handleFormatChange} + disabled={!imageFile || isCropping} />
-
{qualityInput}
+ {format === "jpeg" && ( +
+ + +
+ )}
- + -
+
+ {!isCropping && ( + + )} + + {isCropping && ( + <> + + + )} +
+ {isCropping && ( +

+ Crop mode is active. Drag inside the box to move it, use handles + to resize, then click Done in the crop box. + {cropSelectionLabel ? ` Current: ${cropSelectionLabel}` : ""} +

+ )} {output && ( <> - +
-
- Resized output +
+
+ Resized output + {isCropping && cropRect && ( + + )} +
@@ -318,7 +1000,3 @@ export default function ImageResize() { ); } - -const Divider = () => { - return
; -}; diff --git a/pages/utilities/jwt-parser.tsx b/pages/utilities/jwt-parser.tsx index ad69572..7aca727 100644 --- a/pages/utilities/jwt-parser.tsx +++ b/pages/utilities/jwt-parser.tsx @@ -11,6 +11,7 @@ import CallToActionGrid from "@/components/CallToActionGrid"; import Meta from "@/components/Meta"; import { decodeJWT } from "@/components/utils/jwt-parser.utils"; import GitHubContribution from "@/components/GitHubContribution"; +import { DividerComponent } from "../../components/ds/DividerComponent"; export default function JWTParser() { const [input, setInput] = useState(""); @@ -74,7 +75,7 @@ export default function JWTParser() { value={input} /> - +
@@ -87,7 +88,7 @@ export default function JWTParser() {
- +
@@ -100,7 +101,7 @@ export default function JWTParser() {
- +
@@ -126,7 +127,3 @@ export default function JWTParser() { ); } - -const Divider = () => { - return
; -};