diff --git a/components/ds/CropOverlayComponent.tsx b/components/ds/CropOverlayComponent.tsx new file mode 100644 index 0000000..57c5ba9 --- /dev/null +++ b/components/ds/CropOverlayComponent.tsx @@ -0,0 +1,36 @@ +"use client"; +import { FollowingTooltipComponent } from "./FollowingTooltipComponent"; + +interface CropOverlayProps { + isCropping: boolean; + cropRect: { x: number; y: number; width: number; height: number } | null; + mousePosition: { x: number; y: number }; +} + +const CropOverlayComponent: React.FC = ({ + isCropping, + cropRect, + mousePosition, +}) => { + if (!isCropping || !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); + + return ( + <> +
+ + + ); +}; + +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..b485470 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, @@ -97,7 +99,7 @@ describe("Image Processing Functions", () => { const setOutput = jest.fn(); processImageFile({ - file: mockFile, + source: mockFile, format: "jpeg", preserveAspectRatio: true, quality: 0.8, @@ -121,7 +123,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 +137,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 +152,7 @@ describe("Image Processing Functions", () => { const setOutput = jest.fn(); handleResizeImage({ - file: mockFile, + source: mockFile, format: "jpeg", height: 400, width: 600, @@ -166,4 +168,79 @@ describe("Image Processing Functions", () => { done(); }, 0); }); + + it("should calculate the crop dimensions correctly", () => { + const imgMock = { + width: 1000, + height: 500, + } as HTMLImageElement; + + const currentImageRefMock = { + clientWidth: 500, + clientHeight: 250, + } 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 = { + width: 1000, + height: 500, + } as HTMLImageElement; + + const currentImageRefMock = { + clientWidth: 500, + clientHeight: 250, + } 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, + }); + }); + + 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..6ab9b00 100644 --- a/components/utils/resize-image.utils.ts +++ b/components/utils/resize-image.utils.ts @@ -72,7 +72,7 @@ export function resizeImage({ } interface ProcessImageFileOptions { - file: File; + source: File; setWidth: (width: number) => void; setHeight: (height: number) => void; setOutput: (output: string) => void; @@ -83,7 +83,7 @@ interface ProcessImageFileOptions { } export const processImageFile = ({ - file, + source, format, preserveAspectRatio, quality, @@ -92,73 +92,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 +199,7 @@ interface HandleResizeImage { } export const handleResizeImage = ({ - file, + source, format, height, preserveAspectRatio, @@ -176,20 +207,68 @@ 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 scaleX = img.width / currentImageRef.clientWidth; + const scaleY = img.height / currentImageRef.clientHeight; + + const x = Math.min(cropRect.x, cropRect.x + cropRect.width) * scaleX; + const y = Math.min(cropRect.y, cropRect.y + cropRect.height) * scaleY; + const width = Math.abs(cropRect.width) * scaleX; + const height = Math.abs(cropRect.height) * scaleY; + + return { x, y, width, 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..70f6946 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, useMemo, ChangeEvent, useRef } from "react"; import PageHeader from "@/components/PageHeader"; import { Card } from "@/components/ds/CardComponent"; import { Button } from "@/components/ds/ButtonComponent"; @@ -8,8 +8,10 @@ import { CMDK } from "@/components/CMDK"; import CallToActionGrid from "@/components/CallToActionGrid"; import Meta from "@/components/Meta"; import { + calculateCropDimensions, Format, handleResizeImage, + isPointInCropRect, processImageFile, updateHeight, updateWidth, @@ -21,6 +23,8 @@ import { ImageUploadComponent } from "@/components/ds/ImageUploadComponent"; import { cn } from "@/lib/utils"; import { 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; @@ -53,20 +57,63 @@ export default function ImageResize() { const [resizedImageInfo, setResizedImageInfo] = useState( {} ); + const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>({ + x: 0, + y: 0, + }); - function setOutputAndShowAnimation(output: string) { + const [isCropping, setIsCropping] = useState(false); + const [cropStart, setCropStart] = useState<{ x: number; y: number } | null>( + null + ); + const [cropRect, setCropRect] = useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + const [isDragging, setIsDragging] = useState(false); + const [isMovingCropRect, setIsMovingCropRect] = useState(false); + const [moveStartPoint, setMoveStartPoint] = useState<{ + x: number; + y: number; + } | null>(null); + const imageRef = useRef(null); + + const setOutputAndShowAnimation = useCallback((output: string) => { setOutput(output); setShowAnimation(true); setTimeout(() => { setShowAnimation(false); }, 500); - } + }, []); + + const resetCropStates = useCallback(() => { + setIsCropping(false); + setCropStart(null); + setCropRect(null); + setIsDragging(false); + setIsMovingCropRect(false); + setMoveStartPoint(null); + }, []); + + const updateResizedImageInfo = useCallback( + (width: number, height: number, format: Format, quality: number) => { + setResizedImageInfo({ + width: Math.round(width), + height: Math.round(height), + format, + quality, + }); + }, + [] + ); const handleFileSelect = useCallback( (file: File) => { setImageFile(file); processImageFile({ - file, + source: file, format, preserveAspectRatio, quality, @@ -82,20 +129,18 @@ export default function ImageResize() { setWidth, }); }, - [format, preserveAspectRatio, quality] + [format, preserveAspectRatio, quality, 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) => { @@ -105,11 +150,11 @@ export default function ImageResize() { } 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( @@ -120,17 +165,17 @@ export default function ImageResize() { } 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 handleResize = useCallback(() => { - if (imageFile) { + if (output) { handleResizeImage({ - file: imageFile, + source: output, format, height, preserveAspectRatio, @@ -142,7 +187,15 @@ export default function ImageResize() { }, }); } - }, [imageFile, width, height, format, quality, preserveAspectRatio]); + }, [ + output, + width, + height, + format, + quality, + preserveAspectRatio, + setOutputAndShowAnimation, + ]); const resizedLabel = useMemo(() => { const { height, width, format, quality } = resizedImageInfo; @@ -154,13 +207,38 @@ export default function ImageResize() { return "Click 'Resize' to see the dimensions"; }, [resizedImageInfo]); - const handleQualityInput = useCallback((e: ChangeEvent) => { - let value = parseFloat(e.target.value); - if (value > 1) { - value = 1; - } - setQuality(value); - }, []); + const handleQualityInput = useCallback( + (e: ChangeEvent) => { + let value = parseFloat(e.target.value); + if (value > 1) { + value = 1; + } + setQuality(value); + + if (format === "jpeg" && output) { + handleResizeImage({ + source: output, + format, + height, + preserveAspectRatio, + quality: value, + width, + setOutput: (output) => { + setOutputAndShowAnimation(output); + setResizedImageInfo({ width, height, format, quality: value }); + }, + }); + } + }, + [ + format, + output, + height, + preserveAspectRatio, + width, + setOutputAndShowAnimation, + ] + ); const qualityInput = useMemo(() => { if (format === "jpeg") { @@ -183,6 +261,254 @@ export default function ImageResize() { return null; }, [format, handleQualityInput, imageFile, quality]); + const handleCropModeToggle = useCallback(() => { + if (isCropping) { + resetCropStates(); + } else { + setIsCropping(true); + setCropStart(null); + setCropRect(null); + } + }, [isCropping, resetCropStates]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!isCropping || !imageRef.current) return; + + const rect = imageRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (cropRect && isPointInCropRect(x, y, cropRect)) { + startMovingCropRect(x, y, cropRect); + } else { + startNewCrop(x, y); + } + }, + [isCropping, cropRect] + ); + + const startMovingCropRect = ( + x: number, + y: number, + cropRect: { x: number; y: number; width: number; height: number } + ) => { + setIsMovingCropRect(true); + setMoveStartPoint({ x: x - cropRect.x, y: y - cropRect.y }); + }; + + const startNewCrop = (x: number, y: number) => { + setCropStart({ x, y }); + setIsDragging(true); + setCropRect(null); + }; + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!isCropping || !imageRef.current) return; + + const rect = imageRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + setMousePosition({ x: e.clientX, y: e.clientY }); + + if (isDragging && cropStart) { + updateCropRect(x, y, cropStart); + } else if (isMovingCropRect && cropRect && moveStartPoint) { + moveCropRect(x, y, rect, cropRect, moveStartPoint); + } + }, + [ + isCropping, + isDragging, + cropStart, + isMovingCropRect, + cropRect, + moveStartPoint, + ] + ); + + const updateCropRect = ( + x: number, + y: number, + cropStart: { x: number; y: number } + ) => { + const width = x - cropStart.x; + const height = y - cropStart.y; + setCropRect({ + x: cropStart.x, + y: cropStart.y, + width, + height, + }); + }; + + const moveCropRect = ( + x: number, + y: number, + rect: DOMRect, + cropRect: { x: number; y: number; width: number; height: number }, + moveStartPoint: { x: number; y: number } + ) => { + const newX = x - moveStartPoint.x; + const newY = y - moveStartPoint.y; + + const boundedX = Math.max( + 0, + Math.min(newX, rect.width - Math.abs(cropRect.width)) + ); + const boundedY = Math.max( + 0, + Math.min(newY, rect.height - Math.abs(cropRect.height)) + ); + + setCropRect({ + ...cropRect, + x: boundedX, + y: boundedY, + }); + }; + + const handleMouseUp = useCallback(() => { + if (isCropping) { + if (isDragging) { + setIsDragging(false); + setCropStart(null); + } + if (isMovingCropRect) { + setIsMovingCropRect(false); + setMoveStartPoint(null); + } + } + }, [isCropping, isDragging, isMovingCropRect]); + + const cropSvgImage = useCallback( + ( + img: HTMLImageElement, + x: number, + y: number, + width: number, + height: number + ) => { + const svg = ` + + + `; + const svgBlob = new Blob([svg], { type: "image/svg+xml" }); + const reader = new FileReader(); + reader.onload = () => { + const croppedDataUrl = reader.result as string; + setOutput(croppedDataUrl); + updateResizedImageInfo(width, height, format, quality); + resetCropStates(); + }; + reader.readAsDataURL(svgBlob); + }, + [format, quality, resetCropStates, updateResizedImageInfo] + ); + + const cropCanvasImage = useCallback( + ( + img: HTMLImageElement, + x: number, + y: number, + width: number, + height: number + ) => { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.drawImage( + img, + x, + y, + width, + height, + 0, + 0, + canvas.width, + canvas.height + ); + const croppedDataUrl = canvas.toDataURL(`image/${format}`, quality); + setOutput(croppedDataUrl); + updateResizedImageInfo(canvas.width, canvas.height, format, quality); + resetCropStates(); + } + }, + [format, quality, resetCropStates, updateResizedImageInfo] + ); + + const handleImageDoubleClick = useCallback(() => { + if (cropRect && imageRef.current) { + const currentImageRef = imageRef.current; + const img = new Image(); + img.src = output; + img.onload = () => { + const { x, y, width, height } = calculateCropDimensions( + img, + currentImageRef, + cropRect + ); + + if (format === "svg") { + cropSvgImage(img, x, y, width, height); + } else { + cropCanvasImage(img, x, y, width, height); + } + }; + } + }, [cropCanvasImage, cropRect, cropSvgImage, format, output]); + + const handleFormatChange = useCallback( + (value: string) => { + setFormat(value as Format); + if (output) { + handleResizeImage({ + source: output, + format: value as Format, + height, + preserveAspectRatio, + quality, + width, + setOutput: (newOutput) => { + setOutputAndShowAnimation(newOutput); + setResizedImageInfo({ + width, + height, + format: value as Format, + quality, + }); + }, + }); + } + }, + [ + output, + height, + preserveAspectRatio, + quality, + width, + setOutputAndShowAnimation, + ] + ); + + const mainActionButton = useMemo( + () => ( + + ), + [handleResize, imageFile, isCropping] + ); + return (
@@ -227,7 +553,7 @@ export default function ImageResize() { onChange={handleHeightChange} value={height ?? ""} className="mb-2" - disabled={!imageFile} + disabled={!imageFile || isCropping} />
@@ -237,7 +563,7 @@ export default function ImageResize() { id="preserve-aspect-ratio" checked={preserveAspectRatio} onCheckedChange={handleAspectRatioChange} - disabled={!imageFile} + disabled={!imageFile || isCropping} className="mr-1" />
); } - -const Divider = () => { - return
; -}; diff --git a/pages/utilities/image-to-base64.tsx b/pages/utilities/image-to-base64.tsx index 12f25f1..19a0522 100644 --- a/pages/utilities/image-to-base64.tsx +++ b/pages/utilities/image-to-base64.tsx @@ -11,6 +11,7 @@ import CallToActionGrid from "@/components/CallToActionGrid"; import Meta from "@/components/Meta"; import { ImageUploadComponent } from "@/components/ds/ImageUploadComponent"; import ImageToBase64SEO from "@/components/seo/ImageToBase64SEO"; +import { DividerComponent } from "../../components/ds/DividerComponent"; export default function ImageToBase64() { const [base64, setBase64] = useState(""); @@ -63,7 +64,8 @@ export default function ImageToBase64() { - + +