diff --git a/src/app/(tools)/layout.tsx b/src/app/(tools)/layout.tsx index c70d623..0c13c3d 100644 --- a/src/app/(tools)/layout.tsx +++ b/src/app/(tools)/layout.tsx @@ -1,26 +1,16 @@ import Link from "next/link"; +import { Footer } from "@/components/shared/footer"; +import { ArrowLeftIcon } from "@/components/shared/icons"; + function BackButton() { return (
- - - + Back
@@ -33,21 +23,12 @@ export default function ToolsLayout({ children: React.ReactNode; }) { return ( -
+
-
+
{children}
- +
); } diff --git a/src/app/(tools)/rounded-border/page.tsx b/src/app/(tools)/rounded-border/page.tsx index 08246b2..4bc6ccb 100644 --- a/src/app/(tools)/rounded-border/page.tsx +++ b/src/app/(tools)/rounded-border/page.tsx @@ -6,5 +6,5 @@ export const metadata = { }; export default function RoundedToolPage() { - return ; + return ; } diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index a5dbf34..e0fa4c2 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -1,15 +1,28 @@ "use client"; -import { usePlausible } from "next-plausible"; + import { useEffect, useMemo, useRef, useState } from "react"; -import { useLocalStorage } from "@/hooks/use-local-storage"; -import { UploadBox } from "@/components/shared/upload-box"; -import { OptionSelector } from "@/components/shared/option-selector"; +import { useRouter } from "next/navigation"; +import { usePlausible } from "next-plausible"; + import { BorderRadiusSelector } from "@/components/border-radius-selector"; -import { - useFileUploader, - type FileUploaderResult, -} from "@/hooks/use-file-uploader"; +import { ErrorMessage } from "@/components/shared/error-message"; +import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; +import { ClipboardPasteIcon, DownloadIcon } from "@/components/shared/icons"; +import { OptionSelector } from "@/components/shared/option-selector"; +import { PageTitle } from "@/components/shared/page-title"; +import { PreviewScale } from "@/components/shared/preview-scale"; +import { UploadBox } from "@/components/shared/upload-box"; + +import { useFileFetcher } from "@/hooks/use-file-fetcher"; +import { useFileUploader } from "@/hooks/use-file-uploader"; +import { useKeyDown } from "@/hooks/use-keydown"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { isInteractiveElementFocused } from "@/lib/dom-utils"; + +import { type ImageMetadata } from "@/lib/file-utils"; +import { type FileFetcherResult } from "@/hooks/use-file-fetcher"; +import { type FileUploaderResult } from "@/hooks/use-file-uploader"; type Radius = number; @@ -76,38 +89,66 @@ function useImageConverter(props: { } interface ImageRendererProps { - imageContent: string; - radius: Radius; background: BackgroundOption; + imageContainer: React.RefObject | null; + imageContent: string; + imageMetadata: { width: number; height: number; name: string }; + radius: Radius | null; + setPreviewScale: (scale: number | null) => void; } const ImageRenderer = ({ + background, + imageContainer, imageContent, + imageMetadata, radius, - background, + setPreviewScale, }: ImageRendererProps) => { - const containerRef = useRef(null); + const [effectiveBorderRadius, setEffectiveBorderRadius] = useState< + number | null + >(radius); + const [internalScale, setInternalScale] = useState(1); useEffect(() => { - if (containerRef.current) { - const imgElement = containerRef.current.querySelector("img"); - if (imgElement) { - imgElement.style.borderRadius = `${radius}px`; - } - } - }, [imageContent, radius]); + if (!imageContainer?.current || !imageContent) return; + const container = imageContainer.current; + + const updatePreviewScaleFactor = () => { + const imageContainerWidth = container.clientWidth; + + const previewScaleFactor = Math.min( + imageContainerWidth / imageMetadata.width, + imageContainerWidth / imageMetadata.height, + 1, // Prevent upscaling + ); + + setEffectiveBorderRadius((radius ?? 1) * previewScaleFactor); + setPreviewScale(previewScaleFactor < 1 ? previewScaleFactor : null); + setInternalScale(previewScaleFactor); + }; + + updatePreviewScaleFactor(); + const resizeObserver = new ResizeObserver(updatePreviewScaleFactor); + resizeObserver.observe(container); + + return () => resizeObserver.disconnect(); + }, [imageContent, imageMetadata, radius, imageContainer, setPreviewScale]); return ( -
-
+
Preview
); @@ -143,24 +184,58 @@ function SaveAsPngButton({ plausible("convert-image-to-png"); void convertToPng(); }} - className="rounded-lg bg-green-700 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-green-800 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-opacity-75" + className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg bg-blue-600 px-4 py-2 text-left text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" > + Save as PNG
); } -function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { - const { imageContent, imageMetadata, handleFileUploadEvent, cancel } = - props.fileUploaderProps; - const [radius, setRadius] = useLocalStorage("roundedTool_radius", 2); +type RoundedToolCoreProps = { + fileUploaderProps: FileUploaderResult; + fileFetcherProps: FileFetcherResult; + error: string | null; + onError: (error: string | null) => void; +}; + +function RoundedToolCore({ + fileUploaderProps, + fileFetcherProps, + error, + onError, +}: RoundedToolCoreProps) { + const router = useRouter(); + const [radius, setRadius] = useLocalStorage( + "roundedTool_radius", + 2, + ); const [isCustomRadius, setIsCustomRadius] = useState(false); const [background, setBackground] = useLocalStorage( "roundedTool_background", "transparent", ); + const imageContainerRef = useRef(null); + const [imageMetadata, setImageMetadata] = useState( + fileUploaderProps.imageMetadata, + ); + const [imageContent, setImageContent] = useState( + fileUploaderProps.imageContent, + ); + const [previewScale, setPreviewScale] = useState(null); + + const SubtitleIcon = ; + + const cancel = () => { + fileUploaderProps.cancel(); + fileFetcherProps.cancel(); + setImageMetadata(null); + setImageContent(""); + setPreviewScale(null); + }; + const handleRadiusChange = (value: number | "custom") => { if (value === "custom") { setIsCustomRadius(true); @@ -170,42 +245,114 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { } }; + useEffect(() => { + // Grab metadata and content from method of file upload + let metadata: ImageMetadata | null = null; + let content: string | null = null; + + if (fileUploaderProps.imageMetadata) { + metadata = fileUploaderProps.imageMetadata; + content = fileUploaderProps.imageContent; + } else { + metadata = fileFetcherProps.imageMetadata; + content = fileFetcherProps.imageContent; + } + + if (metadata) { + setImageMetadata(metadata); + setImageContent(content); + onError(null); + } else { + setImageMetadata(null); + setImageContent(""); + } + }, [ + fileUploaderProps.imageMetadata, + fileUploaderProps.imageContent, + fileFetcherProps.imageMetadata, + fileFetcherProps.imageContent, + onError, + ]); + + // Run the cancel function when the user presses the Escape key on the preview screen, + // and navigate back to the home screen when the user presses Escape on the initial tool screen + useKeyDown("Escape", () => { + if (imageMetadata && imageContent) { + // Preview screen + if (isInteractiveElementFocused("ALL", ["option", "select"])) { + // if any option selector buttons are focused, blur them instead of going back to initial tool screen + (document.activeElement as HTMLButtonElement).blur(); + } else { + // otherwise, cancel/go back to initial tool screen + cancel(); + } + } else { + // Initial tool screen + if (isInteractiveElementFocused("INPUT")) return; // ignore if input is focused + router.push("/"); // otherwise, return to home screen + } + }); + if (!imageMetadata) { return ( - +
+ + + + + {error && } +
); } return ( -
-
+
+ {/* Preview Section */} +
+ } /> -

+

{imageMetadata.name}

+ {/* Size Information */}
- Original Size - + + Actual Size + + {imageMetadata.width} × {imageMetadata.height}
+ {/* Radius & Background Controls */} option.charAt(0).toUpperCase() + option.slice(1) } + className="w-full" /> -
+
@@ -239,16 +387,25 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { ); } -export function RoundedTool() { - const fileUploaderProps = useFileUploader(); +export function RoundedTool({ title }: { title: string }) { + const [error, setError] = useState(null); + const fileUploaderProps = useFileUploader({ onError: setError }); + const fileFetcherProps = useFileFetcher({ onError: setError }); return ( - + + ); } diff --git a/src/app/(tools)/square-image/page.tsx b/src/app/(tools)/square-image/page.tsx index 7a695c8..16a412f 100644 --- a/src/app/(tools)/square-image/page.tsx +++ b/src/app/(tools)/square-image/page.tsx @@ -7,5 +7,5 @@ export const metadata = { }; export default function SquareToolPage() { - return ; + return ; } diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index 8264309..8979190 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -1,32 +1,226 @@ "use client"; +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { usePlausible } from "next-plausible"; -import { useLocalStorage } from "@/hooks/use-local-storage"; -import { UploadBox } from "@/components/shared/upload-box"; -import { OptionSelector } from "@/components/shared/option-selector"; + +import { ErrorMessage } from "@/components/shared/error-message"; +import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; import { FileDropzone } from "@/components/shared/file-dropzone"; -import { - type FileUploaderResult, - useFileUploader, -} from "@/hooks/use-file-uploader"; -import { useEffect, useState } from "react"; +import { ClipboardPasteIcon, DownloadIcon } from "@/components/shared/icons"; +import { OptionSelector } from "@/components/shared/option-selector"; +import { PageTitle } from "@/components/shared/page-title"; +import { PreviewScale } from "@/components/shared/preview-scale"; +import { UploadBox } from "@/components/shared/upload-box"; + +import { useFileFetcher } from "@/hooks/use-file-fetcher"; +import { useFileUploader } from "@/hooks/use-file-uploader"; +import { useKeyDown } from "@/hooks/use-keydown"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { isInteractiveElementFocused } from "@/lib/dom-utils"; + +import { type FileFetcherResult } from "@/hooks/use-file-fetcher"; +import { type FileUploaderResult } from "@/hooks/use-file-uploader"; +import { type ImageMetadata } from "@/lib/file-utils"; + +interface ImageRendererProps { + backgroundColor: "black" | "white" | "transparent"; + imageContainer: React.RefObject | null; + imageContent: string | null; + imageMetadata: { width: number; height: number; name: string }; + objectFit: "contain" | "cover"; + setPreviewScale: (scale: number | null) => void; +} + +const ImageRenderer = ({ + backgroundColor, + imageContainer, + imageContent, + imageMetadata, + objectFit, + setPreviewScale, +}: ImageRendererProps) => { + const [internalScale, setInternalScale] = useState(1); -function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { - const { imageContent, imageMetadata, handleFileUploadEvent, cancel } = - props.fileUploaderProps; + useEffect(() => { + if (!imageContainer?.current || !imageContent) return; + const container = imageContainer.current; + + const updatePreviewScaleFactor = () => { + const imageContainerWidth = container.clientWidth; + + const previewScaleFactor = + objectFit === "contain" + ? Math.min( + imageContainerWidth / imageMetadata.width, + imageContainerWidth / imageMetadata.height, + 1, // Prevent upscaling + ) + : Math.min( + imageContainerWidth / + Math.min(imageMetadata.width, imageMetadata.height), + 1, // Prevent upscaling + ); + + setPreviewScale(previewScaleFactor < 1 ? previewScaleFactor : null); + setInternalScale(previewScaleFactor); + }; + + updatePreviewScaleFactor(); + const resizeObserver = new ResizeObserver(updatePreviewScaleFactor); + resizeObserver.observe(container); + + return () => resizeObserver.disconnect(); + }, [imageContainer, imageContent, imageMetadata, objectFit, setPreviewScale]); + + return imageContent ? ( + Preview + ) : null; +}; + +function SaveSquareImageButton({ + imageContent, + imageMetadata, +}: { + imageContent: string | null; + imageMetadata: { width: number; height: number; name: string }; +}) { + const handleSaveImage = () => { + if (imageContent && imageMetadata) { + const link = document.createElement("a"); + link.href = imageContent; + const originalFileName = imageMetadata.name; + const fileNameWithoutExtension = + originalFileName.substring(0, originalFileName.lastIndexOf(".")) || + originalFileName; + link.download = `${fileNameWithoutExtension}-squared.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + }; + + const plausible = usePlausible(); + + return ( + + ); +} + +type SquareToolCoreProps = { + fileUploaderProps: FileUploaderResult; + fileFetcherProps: FileFetcherResult; + error: string | null; + onError: (error: string | null) => void; +}; + +function SquareToolCore({ + fileUploaderProps, + fileFetcherProps, + error, + onError, +}: SquareToolCoreProps) { + const router = useRouter(); + + const [objectFit, setObjectFit] = useLocalStorage<"contain" | "cover">( + "squareTool_objectFit", + "contain", + ); const [backgroundColor, setBackgroundColor] = useLocalStorage< - "black" | "white" + "black" | "white" | "transparent" >("squareTool_backgroundColor", "white"); const [squareImageContent, setSquareImageContent] = useState( null, ); + const imageContainerRef = useRef(null); + const [imageMetadata, setImageMetadata] = useState( + fileUploaderProps.imageMetadata, + ); + const [imageContent, setImageContent] = useState( + fileUploaderProps.imageContent, + ); + const [previewScale, setPreviewScale] = useState(null); + + const SubtitleIcon = ; + + const cancel = () => { + fileUploaderProps.cancel(); + fileFetcherProps.cancel(); + setImageMetadata(null); + setImageContent(""); + setPreviewScale(null); + }; + + useEffect(() => { + // Grab metadata and content from method of file upload + let metadata: ImageMetadata | null = null; + let content: string | null = null; + + if (fileUploaderProps.imageMetadata) { + metadata = fileUploaderProps.imageMetadata; + content = fileUploaderProps.imageContent; + } else { + metadata = fileFetcherProps.imageMetadata; + content = fileFetcherProps.imageContent; + } + + if (metadata) { + setImageMetadata(metadata); + setImageContent(content); + onError(null); + } else { + setImageMetadata(null); + setImageContent(""); + } + }, [ + fileUploaderProps.imageMetadata, + fileUploaderProps.imageContent, + fileFetcherProps.imageMetadata, + fileFetcherProps.imageContent, + onError, + ]); + useEffect(() => { if (imageContent && imageMetadata) { const canvas = document.createElement("canvas"); - const size = Math.max(imageMetadata.width, imageMetadata.height); + const size = + objectFit === "contain" + ? Math.max(imageMetadata.width, imageMetadata.height) + : Math.min(imageMetadata.width, imageMetadata.height); canvas.width = size; canvas.height = size; @@ -39,114 +233,195 @@ function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { // Load and center the image const img = new Image(); + img.onload = () => { - const x = (size - imageMetadata.width) / 2; - const y = (size - imageMetadata.height) / 2; - ctx.drawImage(img, x, y); + const imgAspectRatio = img.width / img.height; + + let w = size; + let h = size; + let x = 0; + let y = 0; + + if (objectFit === "contain") { + // Original logic + w = imageMetadata.width; + h = imageMetadata.height; + x = (size - w) / 2; + y = (size - h) / 2; + } else if (objectFit === "cover") { + if (imgAspectRatio > 1) { + // Image is wider than square, crop horizontally + h = size; + w = imgAspectRatio * h; + x = (size - w) / 2; + y = 0; + } else { + // Image is taller than square, crop vertically + w = size; + h = w / imgAspectRatio; + y = (size - h) / 2; + x = 0; + } + } + + ctx.drawImage(img, x, y, w, h); setSquareImageContent(canvas.toDataURL("image/png")); }; + img.src = imageContent; } - }, [imageContent, imageMetadata, backgroundColor]); + }, [backgroundColor, imageContent, imageMetadata, objectFit]); - const handleSaveImage = () => { - if (squareImageContent && imageMetadata) { - const link = document.createElement("a"); - link.href = squareImageContent; - const originalFileName = imageMetadata.name; - const fileNameWithoutExtension = - originalFileName.substring(0, originalFileName.lastIndexOf(".")) || - originalFileName; - link.download = `${fileNameWithoutExtension}-squared.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + // Run the cancel function when the user presses the Escape key on the preview screen, + // and navigate back to the home screen when the user presses Escape on the initial tool screen + useKeyDown("Escape", () => { + if (imageMetadata && imageContent) { + // Preview screen + if (isInteractiveElementFocused("ALL", ["option", "select"])) { + // if any option selector buttons are focused, blur them instead of going back to initial tool screen + (document.activeElement as HTMLButtonElement).blur(); + } else { + // otherwise, cancel/go back to initial tool screen + cancel(); + } + } else { + // Initial tool screen + if (isInteractiveElementFocused("INPUT")) return; // ignore if input is focused + router.push("/"); // otherwise, return to home screen } - }; - - const plausible = usePlausible(); + }); if (!imageMetadata) { return ( - +
+ + + + + {error && } +
); } return (
-
- {squareImageContent && ( - Preview - )} -

+ {/* Preview Section */} +

+ + } + imageContent={squareImageContent} + imageMetadata={imageMetadata} + objectFit={objectFit} + setPreviewScale={setPreviewScale} + /> +

{imageMetadata.name}

+ {/* Size Information */}
- Original - + + Original Size + + {imageMetadata.width} × {imageMetadata.height}
- Square Size - - {Math.max(imageMetadata.width, imageMetadata.height)} ×{" "} - {Math.max(imageMetadata.width, imageMetadata.height)} + + Square Size + + + {objectFit === "contain" + ? Math.max(imageMetadata.width, imageMetadata.height) + : Math.min(imageMetadata.width, imageMetadata.height)} + {" × "} + {objectFit === "contain" + ? Math.max(imageMetadata.width, imageMetadata.height) + : Math.min(imageMetadata.width, imageMetadata.height)}
+ {/* Object Fit Controls */} + + option.charAt(0).toUpperCase() + option.slice(1) + } + className="w-full" + /> + + {/* Background Controls */} option.charAt(0).toUpperCase() + option.slice(1) } + className="w-full" /> -
+
- +
); } -export function SquareTool() { - const fileUploaderProps = useFileUploader(); +export function SquareTool({ title }: { title: string }) { + const [error, setError] = useState(null); + const fileUploaderProps = useFileUploader({ onError: setError }); + const fileFetcherProps = useFileFetcher({ onError: setError }); return ( - + + ); } diff --git a/src/app/(tools)/svg-to-png/page.tsx b/src/app/(tools)/svg-to-png/page.tsx index adb8f49..6e17106 100644 --- a/src/app/(tools)/svg-to-png/page.tsx +++ b/src/app/(tools)/svg-to-png/page.tsx @@ -1,10 +1,10 @@ import { SVGTool } from "./svg-tool"; export const metadata = { - title: "SVG to PNG converter - QuickPic", + title: "SVG to PNG Converter - QuickPic", description: "Convert SVGs to PNGs. Also makes them bigger.", }; export default function SVGToolPage() { - return ; + return ; } diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index 137d778..79419ab 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -1,19 +1,48 @@ "use client"; -import { usePlausible } from "next-plausible"; + import { useEffect, useMemo, useRef, useState } from "react"; -import { useLocalStorage } from "@/hooks/use-local-storage"; +import { useRouter } from "next/navigation"; +import { usePlausible } from "next-plausible"; +import { ErrorMessage } from "@/components/shared/error-message"; +import { FetchFromUrlForm } from "@/components/shared/fetch-from-url-form"; +import { FileDropzone } from "@/components/shared/file-dropzone"; +import { ClipboardPasteIcon, DownloadIcon } from "@/components/shared/icons"; +import { OptionSelector } from "@/components/shared/option-selector"; +import { PageTitle } from "@/components/shared/page-title"; +import { PreviewScale } from "@/components/shared/preview-scale"; import { UploadBox } from "@/components/shared/upload-box"; import { SVGScaleSelector } from "@/components/svg-scale-selector"; +import { useFileFetcher } from "@/hooks/use-file-fetcher"; +import { useFileUploader } from "@/hooks/use-file-uploader"; +import { useKeyDown } from "@/hooks/use-keydown"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { isInteractiveElementFocused } from "@/lib/dom-utils"; +import { formatNumber } from "@/lib/math-utils"; + +import { type FileFetcherResult } from "@/hooks/use-file-fetcher"; +import { type FileUploaderResult } from "@/hooks/use-file-uploader"; +import { type ImageMetadata } from "@/lib/file-utils"; + export type Scale = "custom" | number; function scaleSvg(svgContent: string, scale: number) { const parser = new DOMParser(); const svgDoc = parser.parseFromString(svgContent, "image/svg+xml"); const svgElement = svgDoc.documentElement; - const width = parseInt(svgElement.getAttribute("width") ?? "300"); - const height = parseInt(svgElement.getAttribute("height") ?? "150"); + const viewBox = svgElement.getAttribute("viewBox"); + let width = parseInt(svgElement.getAttribute("width") ?? ""); + let height = parseInt(svgElement.getAttribute("height") ?? ""); + + // If width and height are not expliclitly defined, extract them from the viewBox attribute + if ((!width || !height) && viewBox) { + const viewBoxValues = viewBox.split(" ").map(parseFloat); + if (viewBoxValues.length === 4) { + width = viewBoxValues[2]!; + height = viewBoxValues[3]!; + } + } const scaledWidth = width * scale; const scaledHeight = height * scale; @@ -26,10 +55,10 @@ function scaleSvg(svgContent: string, scale: number) { function useSvgConverter(props: { canvas: HTMLCanvasElement | null; - svgContent: string; - scale: number; fileName?: string; imageMetadata: { width: number; height: number; name: string }; + scale: number; + svgContent: string; }) { const { width, height, scaledSvg } = useMemo(() => { const scaledSvg = scaleSvg(props.svgContent, props.scale); @@ -76,24 +105,59 @@ function useSvgConverter(props: { } interface SVGRendererProps { - svgContent: string; + backgroundColor: "dark" | "light"; + imageContainer: React.RefObject | null; + imageContent: string; + imageMetadata: { width: number; height: number; name: string }; + setPreviewScale: (scale: number | null) => void; } -function SVGRenderer({ svgContent }: SVGRendererProps) { - const containerRef = useRef(null); +function SVGRenderer({ + backgroundColor, + imageContainer, + imageContent, + imageMetadata, + setPreviewScale, +}: SVGRendererProps) { + const [internalScale, setInternalScale] = useState(1); useEffect(() => { - if (containerRef.current) { - containerRef.current.innerHTML = svgContent; - const svgElement = containerRef.current.querySelector("svg"); - if (svgElement) { - svgElement.setAttribute("width", "100%"); - svgElement.setAttribute("height", "100%"); - } - } - }, [svgContent]); + if (!imageContainer?.current || !imageContent) return; + const container = imageContainer.current; + + const updatePreviewScaleFactor = () => { + const imageContainerWidth = container.clientWidth; + + const previewScaleFactor = Math.min( + imageContainerWidth / imageMetadata.width, + imageContainerWidth / imageMetadata.height, + 1, // Prevent upscaling + ); + + setPreviewScale(previewScaleFactor < 1 ? previewScaleFactor : null); + setInternalScale(previewScaleFactor); + }; - return
; + updatePreviewScaleFactor(); + const resizeObserver = new ResizeObserver(updatePreviewScaleFactor); + resizeObserver.observe(container); + + return () => resizeObserver.disconnect(); + }, [imageContent, imageMetadata, imageContainer, setPreviewScale]); + + return imageContent ? ( + Preview + ) : null; } function SaveAsPngButton({ @@ -123,49 +187,161 @@ function SaveAsPngButton({ plausible("convert-svg-to-png"); void convertToPng(); }} - className="rounded-lg bg-green-700 px-4 py-2 text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-green-800 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-opacity-75" + className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg bg-blue-600 px-4 py-2 text-left text-sm font-semibold text-white shadow-md transition-colors duration-200 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-75" > + Save as PNG
); } -import { - type FileUploaderResult, - useFileUploader, -} from "@/hooks/use-file-uploader"; -import { FileDropzone } from "@/components/shared/file-dropzone"; - -function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) { - const { rawContent, imageMetadata, handleFileUploadEvent, cancel } = - props.fileUploaderProps; - +type SVGToolCoreProps = { + fileUploaderProps: FileUploaderResult; + fileFetcherProps: FileFetcherResult; + error: string | null; + onError: (error: string | null) => void; +}; + +function SVGToolCore({ + fileUploaderProps, + fileFetcherProps, + error, + onError, +}: SVGToolCoreProps) { + const router = useRouter(); const [scale, setScale] = useLocalStorage("svgTool_scale", 1); - const [customScale, setCustomScale] = useLocalStorage( + + const [customScale, setCustomScale] = useLocalStorage( "svgTool_customScale", 1, ); + const [previewBackgroundColor, setPreviewBackgroundColor] = useLocalStorage< + "dark" | "light" + >("svgTool_previewBackgroundColor", "dark"); + + const imageContainerRef = useRef(null); + const [imageMetadata, setImageMetadata] = useState( + fileUploaderProps.imageMetadata, + ); + const [imageContent, setImageContent] = useState( + fileUploaderProps.imageContent, + ); + const [rawContent, setRawContent] = useState( + fileUploaderProps.rawContent, + ); + const [previewScale, setPreviewScale] = useState(null); + // Get the actual numeric scale value - const effectiveScale = scale === "custom" ? customScale : scale; + const effectiveScale = scale === "custom" ? (customScale ?? 1) : scale; + + const SubtitleIcon = ; + + const cancel = () => { + fileUploaderProps.cancel(); + fileFetcherProps.cancel(); + setImageMetadata(null); + setImageContent(""); + setRawContent(""); + setPreviewScale(null); + }; + + useEffect(() => { + // Grab metadata and content from method of file upload + // Make sure SVG metadata is normalized in case width/height are not explicitly defined + let metadata: ImageMetadata | null = null; + let content: string | null = null; + let raw: string | null = null; + + if (fileUploaderProps.imageMetadata) { + metadata = fileUploaderProps.imageMetadata; + content = fileUploaderProps.imageContent; + raw = fileUploaderProps.rawContent; + } else { + metadata = fileFetcherProps.imageMetadata; + content = fileFetcherProps.imageContent; + raw = fileFetcherProps.rawContent; + } - if (!imageMetadata) + if (metadata) { + setImageMetadata(metadata); + setImageContent(content); + setRawContent(raw); + onError(null); + } else { + setImageMetadata(null); + setImageContent(""); + setRawContent(""); + } + }, [ + fileUploaderProps.imageMetadata, + fileUploaderProps.imageContent, + fileUploaderProps.rawContent, + fileFetcherProps.imageMetadata, + fileFetcherProps.imageContent, + fileFetcherProps.rawContent, + onError, + ]); + + // Run the cancel function when the user presses the Escape key on the preview screen, + // and navigate back to the home screen when the user presses Escape on the initial tool screen + useKeyDown("Escape", () => { + if (imageMetadata && imageContent) { + // Preview screen + if (isInteractiveElementFocused("ALL", ["option", "select"])) { + // if any option selector buttons are focused, blur them instead of going back to initial tool screen + (document.activeElement as HTMLButtonElement).blur(); + } else { + // otherwise, cancel/go back to initial tool screen + cancel(); + } + } else { + // Initial tool screen + if (isInteractiveElementFocused("INPUT")) return; // ignore if input is focused + router.push("/"); // otherwise, return to home screen + } + }); + + if (!imageMetadata || !imageContent) return ( - +
+ + + + + {error && } +
); return (
{/* Preview Section */} -
- -

+

+ + } + imageContent={imageContent} + imageMetadata={imageMetadata} + setPreviewScale={setPreviewScale} + /> +

{imageMetadata.name}

@@ -173,17 +349,22 @@ function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) { {/* Size Information */}
- Original - + + Original + + {imageMetadata.width} × {imageMetadata.height}
- Scaled - - {imageMetadata.width * effectiveScale} ×{" "} - {imageMetadata.height * effectiveScale} + + {`Scaled (${formatNumber(effectiveScale)}×)`} + + + {Math.floor(imageMetadata.width * effectiveScale)} + {" × "} + {Math.floor(imageMetadata.height * effectiveScale)}
@@ -191,18 +372,30 @@ function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) { {/* Scale Controls */} + {/* Preview Background Color Controls */} + + option.charAt(0).toUpperCase() + option.slice(1) + } + className="w-full" + /> + {/* Action Buttons */} -
+
@@ -216,15 +409,28 @@ function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) { ); } -export function SVGTool() { - const fileUploaderProps = useFileUploader(); +export function SVGTool({ title }: { title: string }) { + const [error, setError] = useState(null); + const fileUploaderProps = useFileUploader({ + accept: [".svg", "image/svg+xml"], + onError: setError, + }); + const fileFetcherProps = useFileFetcher({ onError: setError }); + return ( - + + ); } diff --git a/src/app/globals.css b/src/app/globals.css index 6a8b964..51f96d1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -12,3 +12,18 @@ body { background: var(--background); font-family: Arial, Helvetica, sans-serif; } + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@layer base { + .checkerboard { + background: repeating-conic-gradient(#ffffff06 0% 25%, #ffffff0d 0% 50%) 0 / 10px 10px; + } +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 5d093b4..2b072e3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,42 +1,58 @@ import Link from "next/link"; +import { Footer } from "@/components/shared/footer"; +import { + ArrowUpRightIcon, + CornerRounderIcon, + SquareImageIcon, + SvgToPngIcon, +} from "@/components/shared/icons"; + export default function Home() { return ( -
-
+
+
Hi. I'm{" "} Theo + . I built these tools because I was annoyed they did not exist.
-
- - SVG to PNG converter - - - Square image generator - - - Corner Rounder - +
+ + + SVG to PNG Converter + + + + Square Image Generator + + + + Corner Rounder + +
- +
); } diff --git a/src/components/border-radius-selector.tsx b/src/components/border-radius-selector.tsx index 8ff2ed8..fd00189 100644 --- a/src/components/border-radius-selector.tsx +++ b/src/components/border-radius-selector.tsx @@ -1,108 +1,277 @@ -import React, { useRef, useEffect } from "react"; +import React, { useEffect, useRef } from "react"; + +import { Select } from "@/components/shared/select"; + +import { useMediaQuery } from "@/hooks/use-media-query"; + +type CustomValueChangeEventContext = + | { + e: React.KeyboardEvent; + context: "keyboard"; + } + | { + e: React.MouseEvent; + context: "button-up" | "button-down"; + }; interface BorderRadiusSelectorProps { - title: string; + customValue?: number | null; + onChange: (value: number | "custom") => void; + onCustomValueChange?: (value: number | null) => void; options: number[]; selected: number | "custom"; - onChange: (value: number | "custom") => void; - customValue?: number; - onCustomValueChange?: (value: number) => void; + title: string; } export function BorderRadiusSelector({ - title, - options, - selected, - onChange, customValue, + onChange, onCustomValueChange, + options, + selected, + title, }: BorderRadiusSelectorProps) { + const isXsScreen = useMediaQuery("(max-width: 29rem)"); const containerRef = useRef(null); const selectedRef = useRef(null); const highlightRef = useRef(null); + const customValueInputRef = useRef(null); + const customValueIncrementRef = useRef(null); + const customValueDecrementRef = useRef(null); - useEffect(() => { - if (selectedRef.current && highlightRef.current && containerRef.current) { - const container = containerRef.current; - const selected = selectedRef.current; - const highlight = highlightRef.current; + // handle basic validation on the change event + const handleInputChange = (e: React.ChangeEvent) => { + const rawInput = e.target.value; - const containerRect = container.getBoundingClientRect(); - const selectedRect = selected.getBoundingClientRect(); + // If the input is a valid number, set state (empty string should become null) + if (/^(\d+\.?\d*|\.\d*)?$/.test(rawInput)) { + onCustomValueChange?.(rawInput === "" ? null : parseFloat(rawInput)); + } + }; - highlight.style.left = `${selectedRect.left - containerRect.left}px`; - highlight.style.width = `${selectedRect.width}px`; + // but handle most of the validation on blur, which allows the user to clear the input + // completely, so they can type anything—including values that begin with "0", like "0.25" + const handleInputBlur = () => { + // Empty input and invalid (NaN) input should cause the input to be reset to 1 + if ( + customValue === null || + customValue === undefined || + isNaN(customValue) + ) { + onCustomValueChange?.(1); + return; } - }, [selected]); - const handleInputChange = (e: React.ChangeEvent) => { - const value = Math.min(999, Math.max(0, parseInt(e.target.value) || 0)); - onCustomValueChange?.(value); + // Remove leading and trailing whitespace + let normalizedValue = customValue.toString().trim(); + + // Decimal values should always have a single leading zero + if (normalizedValue.startsWith(".")) + normalizedValue = "0" + normalizedValue; + + // Decimal values should always have only one leading zero + if (/^0+\d+$/.test(normalizedValue)) + normalizedValue = String(parseFloat(normalizedValue)); + + // Decimal values should never have trailing zeroes + if (normalizedValue.includes(".")) + normalizedValue = parseFloat(normalizedValue).toString(); + + // Values of 0 should be normalized to 1, while still allowing values arbitrarily close to 0 + if (normalizedValue === "0") normalizedValue = "1"; + + // Values of 1000 and above should be normalized to 999, while still allowing values arbitrarily close to 1000 + if (+normalizedValue >= 1000) normalizedValue = "999"; + + // Parse the normalized value as a number, clamp it to the range 0 < value < 1000, and set state + const clampedValue = Math.min( + 1000 - Number.EPSILON, + Math.max(Number.MIN_VALUE, parseFloat(normalizedValue)), + ); + onCustomValueChange?.(clampedValue); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + // a generalized handler for custom value changes which can operate in different contexts—keyboard/keydown + // on the custom value input itself, or pointer/click on the custom value input's increment/decrement buttons + const handleCustomValueStep = ({ + e, + context, + }: CustomValueChangeEventContext) => { + if (context === "keyboard" && e.key !== "ArrowUp" && e.key !== "ArrowDown") + return; e.preventDefault(); - const currentValue = customValue ?? 0; + const currentValue = customValue ?? 0.1; let step = 1; if (e.shiftKey) step = 10; if (e.altKey) step = 0.1; const newValue = - e.key === "ArrowUp" ? currentValue + step : currentValue - step; + (context === "keyboard" && e.key === "ArrowUp") || context === "button-up" + ? (currentValue || 0) + step + : (currentValue || 0) - step; const clampedValue = Math.min( - 999, - Math.max(0, Number(newValue.toFixed(1))), + e.altKey ? 999.9 : 999, + Math.max(e.altKey ? 0.1 : 1, Number(newValue.toFixed(1))), ); + onCustomValueChange?.(clampedValue); }; + useEffect(() => { + if (!selectedRef.current || !highlightRef.current || !containerRef.current) + return; + + // prevent layout thrashing + requestAnimationFrame(() => { + const container = containerRef.current!; + const selected = selectedRef.current!; + const highlight = highlightRef.current!; + + const containerRect = container.getBoundingClientRect(); + const selectedRect = selected.getBoundingClientRect(); + + highlight.style.left = `${selectedRect.left - containerRect.left}px`; + highlight.style.width = `${selectedRect.width}px`; + }); + + // though isXsScreen is not a real dependency, the selected scale option must + // be re-rerendered when it changes, because otherwise the highlight will be + // missing/not calculated when the user resizes the window and the scale selector + // switches between its two different display modes + }, [selected, isXsScreen]); + return ( -
+
{title} -
-
-
+ {isXsScreen ? ( + + handleCustomValueStep({ e, context: "keyboard" }) + } + className="focus-visible:ring-offset-none h-10 w-full rounded-lg bg-white/5 px-3 py-2 pr-[50px] text-sm font-medium text-white/60 transition-colors duration-200 placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-50 group-focus-within:text-white group-focus-within:ring-2 group-focus-within:ring-white/30 group-hover:text-white [&::-webkit-inner-spin-button]:opacity-0 [&::-webkit-inner-spin-button]:[-webkit-appearance:none] [&::-webkit-outer-spin-button]:opacity-0 [&::-webkit-outer-spin-button]:[-webkit-appearance:none]" placeholder="Enter radius" /> - px + + px + + +
)} diff --git a/src/components/shared/error-message.tsx b/src/components/shared/error-message.tsx new file mode 100644 index 0000000..523ec28 --- /dev/null +++ b/src/components/shared/error-message.tsx @@ -0,0 +1,19 @@ +import { WarningIcon } from "@/components/shared/icons"; + +type ErrorMessageProps = { + error: string; +}; + +export function ErrorMessage({ error }: ErrorMessageProps) { + return ( + + ); +} diff --git a/src/components/shared/fetch-from-url-form.tsx b/src/components/shared/fetch-from-url-form.tsx new file mode 100644 index 0000000..b17a3c5 --- /dev/null +++ b/src/components/shared/fetch-from-url-form.tsx @@ -0,0 +1,69 @@ +import { LinkIcon } from "@/components/shared/icons"; + +function Spinner() { + return ( + + ); +} + +type FetchFromUrlFormProps = { + accept: + | ".svg" + | "image/svg+xml" + | "image/*" + | ".jpg" + | ".jpeg" + | ".png" + | ".webp"; + error: string | null; + handleSubmit: (payload: FormData) => void; + pending: boolean; +}; + +export function FetchFromUrlForm({ + accept, + error, + handleSubmit, + pending, +}: FetchFromUrlFormProps) { + return ( +
+
+
+ + +
+ + +
+
+ ); +} diff --git a/src/components/shared/file-dropzone.tsx b/src/components/shared/file-dropzone.tsx index fc0ad70..e2fc774 100644 --- a/src/components/shared/file-dropzone.tsx +++ b/src/components/shared/file-dropzone.tsx @@ -1,10 +1,15 @@ import React, { useCallback, useState, useRef } from "react"; +import { UploadIcon } from "@/components/shared/icons"; + +import { type FileTypeString, generateFileTypesString } from "@/lib/file-utils"; + interface FileDropzoneProps { children: React.ReactNode; acceptedFileTypes: string[]; dropText: string; setCurrentFile: (file: File) => void; + onError?: (error: string | null) => void; } export function FileDropzone({ @@ -12,16 +17,17 @@ export function FileDropzone({ acceptedFileTypes, dropText, setCurrentFile, + onError, }: FileDropzoneProps) { const [isDragging, setIsDragging] = useState(false); const dragCounter = useRef(0); - const handleDrag = useCallback((e: React.DragEvent) => { + const handleDrag = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - }, []); + }; - const handleDragIn = useCallback((e: React.DragEvent) => { + const handleDragIn = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounter.current++; @@ -29,9 +35,9 @@ export function FileDropzone({ if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) { setIsDragging(true); } - }, []); + }; - const handleDragOut = useCallback((e: React.DragEvent) => { + const handleDragOut = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); dragCounter.current--; @@ -39,7 +45,7 @@ export function FileDropzone({ if (dragCounter.current === 0) { setIsDragging(false); } - }, []); + }; const handleDrop = useCallback( (e: React.DragEvent) => { @@ -53,8 +59,9 @@ export function FileDropzone({ const droppedFile = files[0]; if (!droppedFile) { - alert("How did you do a drop with no files???"); - throw new Error("No files dropped"); + if (onError) onError("How did you do a drop with no files???"); + else alert("How did you do a drop with no files???"); + return; } if ( @@ -63,15 +70,25 @@ export function FileDropzone({ droppedFile.name.toLowerCase().endsWith(type.replace("*", "")), ) ) { - alert("Invalid file type. Please upload a supported file type."); - throw new Error("Invalid file"); + const acceptedFileTypesString = generateFileTypesString( + acceptedFileTypes as FileTypeString[], + ); + if (onError) + onError( + `Uploaded file has invalid type. Valid types are: ${acceptedFileTypesString}.`, + ); + else + alert( + `Uploaded file has invalid type. Valid types are: ${acceptedFileTypesString}.`, + ); + return; } // Happy path setCurrentFile(droppedFile); } }, - [acceptedFileTypes, setCurrentFile], + [acceptedFileTypes, setCurrentFile, onError], ); return ( @@ -80,13 +97,14 @@ export function FileDropzone({ onDragLeave={handleDragOut} onDragOver={handleDrag} onDrop={handleDrop} - className="h-full w-full" + className="size-full" > {isDragging && ( -
-
-
-

{dropText}

+
+
+
+ +

{dropText}

)} diff --git a/src/components/shared/footer.tsx b/src/components/shared/footer.tsx new file mode 100644 index 0000000..fa8e3fc --- /dev/null +++ b/src/components/shared/footer.tsx @@ -0,0 +1,21 @@ +import { ArrowUpRightIcon, GitHubIcon } from "@/components/shared/icons"; + +export function Footer() { + return ( + + ); +} diff --git a/src/components/shared/icons.tsx b/src/components/shared/icons.tsx new file mode 100644 index 0000000..77f397d --- /dev/null +++ b/src/components/shared/icons.tsx @@ -0,0 +1,186 @@ +const defaultProps: React.SVGProps = { + xmlns: "http://www.w3.org/2000/svg", + width: "24", + height: "24", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2", + strokeLinecap: "round", + strokeLinejoin: "round", + className: "size-4 shrink-0", + role: "img", + "aria-hidden": "true", +}; + +interface IconProps extends React.SVGProps { + className?: string; + strokeWidth?: number; +} + +export function ArrowLeftIcon({ className, strokeWidth }: IconProps) { + return ( + + + + ); +} + +export function ArrowUpRightIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + ); +} + +export function ClipboardPasteIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + + ); +} + +export function CornerRounderIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + ); +} + +export function DownloadIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + + ); +} + +export function EyeIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + ); +} + +export function GitHubIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + ); +} + +export function LinkIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + ); +} + +export function SquareImageIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + + + + + + + ); +} + +export function SvgToPngIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + + + ); +} + +export function UploadIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + + ); +} + +export function WarningIcon({ className, strokeWidth }: IconProps) { + return ( + + + + + + ); +} diff --git a/src/components/shared/option-selector.tsx b/src/components/shared/option-selector.tsx index b77ce5e..2072b19 100644 --- a/src/components/shared/option-selector.tsx +++ b/src/components/shared/option-selector.tsx @@ -2,66 +2,96 @@ import { useEffect, useRef } from "react"; +import { Select } from "@/components/shared/select"; + +import { useMediaQuery } from "@/hooks/use-media-query"; + interface OptionSelectorProps { - title: string; + className?: string; + formatOption?: (option: T) => string; + onChange: (value: T) => void; options: T[]; selected: T; - onChange: (value: T) => void; - formatOption?: (option: T) => string; + title: string; } export function OptionSelector({ - title, + className, + formatOption = (option) => `${option}`, + onChange, options, selected, - onChange, - formatOption = (option) => `${option}`, + title, }: OptionSelectorProps) { + const isXsScreen = useMediaQuery("(max-width: 29rem)"); const containerRef = useRef(null); const selectedRef = useRef(null); const highlightRef = useRef(null); useEffect(() => { - if (selectedRef.current && highlightRef.current && containerRef.current) { - const container = containerRef.current; - const selected = selectedRef.current; - const highlight = highlightRef.current; + if (!selectedRef.current || !highlightRef.current || !containerRef.current) + return; + + // prevent layout thrashing + requestAnimationFrame(() => { + const container = containerRef.current!; + const selected = selectedRef.current!; + const highlight = highlightRef.current!; const containerRect = container.getBoundingClientRect(); const selectedRect = selected.getBoundingClientRect(); highlight.style.left = `${selectedRect.left - containerRect.left}px`; highlight.style.width = `${selectedRect.width}px`; - } - }, [selected]); + }); + + // though isXsScreen is not a real dependency, the selected scale option must + // be re-rerendered when it changes, because otherwise the highlight will be + // missing/not calculated when the user resizes the window and the scale selector + // switches between its two different display modes + }, [selected, isXsScreen]); return ( -
+
{title} -
-
-
+ {isXsScreen ? ( +
diff --git a/src/components/svg-scale-selector.tsx b/src/components/svg-scale-selector.tsx index f2eb628..a481ea4 100644 --- a/src/components/svg-scale-selector.tsx +++ b/src/components/svg-scale-selector.tsx @@ -1,102 +1,274 @@ -import React, { useRef, useEffect } from "react"; +import React, { useEffect, useRef } from "react"; + +import { Select } from "@/components/shared/select"; + +import { useMediaQuery } from "@/hooks/use-media-query"; + +type CustomValueChangeEventContext = + | { + e: React.KeyboardEvent; + context: "keyboard"; + } + | { + e: React.MouseEvent; + context: "button-up" | "button-down"; + }; interface SVGScaleSelectorProps { - title: string; + customValue?: number | null; + onChange: (value: number | "custom") => void; + onCustomValueChange?: (value: number | null) => void; options: number[]; selected: number | "custom"; - onChange: (value: number | "custom") => void; - customValue?: number; - onCustomValueChange?: (value: number) => void; + title: string; } export function SVGScaleSelector({ - title, - options, - selected, - onChange, customValue, + onChange, onCustomValueChange, + options, + selected, + title, }: SVGScaleSelectorProps) { + const isXsScreen = useMediaQuery("(max-width: 29rem)"); const containerRef = useRef(null); const selectedRef = useRef(null); const highlightRef = useRef(null); + const customValueInputRef = useRef(null); + const customValueIncrementRef = useRef(null); + const customValueDecrementRef = useRef(null); + + // handle basic validation on the change event + const handleInputChange = (e: React.ChangeEvent) => { + const rawInput = e.target.value; + + // If the input is a valid number, set state (empty string should become null) + if (/^(\d+\.?\d*|\.\d*)?$/.test(rawInput)) { + onCustomValueChange?.(rawInput === "" ? null : parseFloat(rawInput)); + } + }; + + // but handle most of the validation on blur, which allows the user to clear the input + // completely, so they can type anything—including values that begin with "0", like "0.25" + const handleInputBlur = () => { + // Empty input and invalid (NaN) input should cause the input to be reset to 1 + if ( + customValue === null || + customValue === undefined || + isNaN(customValue) + ) { + onCustomValueChange?.(1); + return; + } + + // Remove leading and trailing whitespace + let normalizedValue = customValue.toString().trim(); + + // Decimal values should always have a single leading zero + if (normalizedValue.startsWith(".")) + normalizedValue = "0" + normalizedValue; + + // Decimal values should always have only one leading zero + if (/^0+\d+$/.test(normalizedValue)) + normalizedValue = String(parseFloat(normalizedValue)); + + // Decimal values should never have trailing zeroes + if (normalizedValue.includes(".")) + normalizedValue = parseFloat(normalizedValue).toString(); + + // Values of 0 should be normalized to 1 + if (normalizedValue === "0") normalizedValue = "1"; + + // Parse the normalized value as a number, clamp it to the range 0 < value <= 64, and set state + const clampedValue = Math.min( + 64, + Math.max(Number.MIN_VALUE, parseFloat(normalizedValue)), + ); + onCustomValueChange?.(clampedValue); + }; + + // a generalized handler for custom value changes which can operate in different contexts—keyboard/keydown + // on the custom value input itself, or pointer/click on the custom value input's increment/decrement buttons + const handleCustomValueStep = ({ + e, + context, + }: CustomValueChangeEventContext) => { + if (context === "keyboard" && e.key !== "ArrowUp" && e.key !== "ArrowDown") + return; + + e.preventDefault(); + const currentValue = customValue ?? 0.1; + let step = 1; + + if (e.shiftKey) step = 10; + if (e.altKey) step = 0.1; + + const newValue = + (context === "keyboard" && e.key === "ArrowUp") || context === "button-up" + ? (currentValue || 0) + step + : (currentValue || 0) - step; + + const clampedValue = Math.min( + 64, + Math.max(e.altKey ? 0.1 : 1, Number(newValue.toFixed(1))), + ); + + onCustomValueChange?.(clampedValue); + }; useEffect(() => { - if (selectedRef.current && highlightRef.current && containerRef.current) { - const container = containerRef.current; - const selected = selectedRef.current; - const highlight = highlightRef.current; + if (!selectedRef.current || !highlightRef.current || !containerRef.current) + return; + + // prevent layout thrashing + requestAnimationFrame(() => { + const container = containerRef.current!; + const selected = selectedRef.current!; + const highlight = highlightRef.current!; const containerRect = container.getBoundingClientRect(); const selectedRect = selected.getBoundingClientRect(); highlight.style.left = `${selectedRect.left - containerRect.left}px`; highlight.style.width = `${selectedRect.width}px`; - } - }, [selected]); + }); + + // though isXsScreen is not a real dependency, the selected scale option must + // be re-rerendered when it changes, because otherwise the highlight will be + // missing/not calculated when the user resizes the window and the scale selector + // switches between its two different display modes + }, [selected, isXsScreen]); return ( -
+
{title} -
-
-
+ {isXsScreen ? ( + + handleCustomValueStep({ e, context: "keyboard" }) + } + className="focus-visible:ring-offset-none h-10 w-full rounded-lg bg-white/5 px-3 py-2 pr-10 text-sm font-medium text-white/60 transition-colors duration-200 placeholder:text-gray-500 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30 disabled:cursor-not-allowed disabled:opacity-50 group-focus-within:text-white group-focus-within:ring-2 group-focus-within:ring-white/30 group-hover:text-white [&::-webkit-inner-spin-button]:opacity-0 [&::-webkit-inner-spin-button]:[-webkit-appearance:none] [&::-webkit-outer-spin-button]:opacity-0 [&::-webkit-outer-spin-button]:[-webkit-appearance:none]" + placeholder="Enter scale" + /> + + × + - ))} -
- {selected === "custom" && ( - { - const value = Math.min(64, parseFloat(e.target.value)); - onCustomValueChange?.(value); - }} - onKeyDown={(e) => { - if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; - - e.preventDefault(); - const currentValue = customValue ?? 0; - let step = 1; - - if (e.shiftKey) step = 10; - if (e.altKey) step = 0.1; - - const newValue = - e.key === "ArrowUp" ? currentValue + step : currentValue - step; - - const clampedValue = Math.min( - 64, - Math.max(0, Number(newValue.toFixed(1))), - ); - onCustomValueChange?.(clampedValue); - }} - className="w-24 rounded-lg bg-white/5 px-3 py-1.5 text-sm text-white" - placeholder="Enter scale" - /> + +
)}
diff --git a/src/hooks/use-callback-ref.ts b/src/hooks/use-callback-ref.ts new file mode 100644 index 0000000..6958c01 --- /dev/null +++ b/src/hooks/use-callback-ref.ts @@ -0,0 +1,15 @@ +import { useEffect, useMemo, useRef } from "react"; + +// inspired by Radix UI: https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/use-callback-ref.tsx +// converts a callback function into a stable reference that avoids triggering re-renders or re-running effects/re-registering event listeners +export function useCallbackRef< + T extends (...args: Parameters) => ReturnType, +>(callback?: T): T { + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }); + + return useMemo(() => ((...args) => callbackRef.current?.(...args)) as T, []); +} diff --git a/src/hooks/use-clipboard-paste.ts b/src/hooks/use-clipboard-paste.ts index f95aa69..a445af1 100644 --- a/src/hooks/use-clipboard-paste.ts +++ b/src/hooks/use-clipboard-paste.ts @@ -2,14 +2,18 @@ import { useEffect, useCallback } from "react"; +import { type FileTypeString, generateFileTypesString } from "@/lib/file-utils"; + interface UseClipboardPasteProps { + acceptedFileTypes: FileTypeString[]; + onError?: (error: string) => void; onPaste: (file: File) => void; - acceptedFileTypes: string[]; } export function useClipboardPaste({ - onPaste, acceptedFileTypes, + onError, + onPaste, }: UseClipboardPasteProps) { const handlePaste = useCallback( async (event: ClipboardEvent) => { @@ -17,6 +21,8 @@ export function useClipboardPaste({ if (!items) return; for (const item of Array.from(items)) { + if (item.kind === "string") break; + if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (!file) continue; @@ -28,15 +34,39 @@ export function useClipboardPaste({ file.name.toLowerCase().endsWith(type.replace("*", "")), ); + const acceptedFileTypesString = + generateFileTypesString(acceptedFileTypes); + + event.preventDefault(); + if (isAcceptedType) { - event.preventDefault(); onPaste(file); break; + } else { + if (onError) + onError( + `Pasted image has invalid type. Valid types are: ${acceptedFileTypesString}.`, + ); + else + alert( + `Pasted image has invalid type. Valid types are: ${acceptedFileTypesString}.`, + ); + break; } + } else { + if (onError) + onError( + `Pasted file is not an image. Please upload a valid image file.`, + ); + else + alert( + `Pasted file is not an image. Please upload a valid image file.`, + ); + break; } } }, - [onPaste, acceptedFileTypes], + [acceptedFileTypes, onError, onPaste], ); useEffect(() => { diff --git a/src/hooks/use-file-fetcher.ts b/src/hooks/use-file-fetcher.ts new file mode 100644 index 0000000..0fc1ada --- /dev/null +++ b/src/hooks/use-file-fetcher.ts @@ -0,0 +1,175 @@ +import { useActionState, useEffect } from "react"; +import { useProcessFile } from "./use-process-file"; +import { HTTP_ERROR_CODES, validateUrl } from "@/lib/fetch-utils"; + +export type FileFetcherResult = { + /** The processed image content as a data URL (for regular images) or object URL (for SVGs) */ + imageContent: string; + /** The raw file content as a string */ + rawContent: string; + /** Metadata about the uploaded image including dimensions and filename */ + imageMetadata: { + width: number; + height: number; + name: string; + } | null; + /** Handler for form state change */ + handleFetchFile: (payload: FormData) => void; + /** Pending state while submitting form */ + pending: boolean; + /** Error message in case of a failed fetch */ + error: string | null; + /** Resets the upload state */ + cancel: () => void; +}; + +type FileFetcherFormState = Pick; + +type FileFetcherOptions = { + onError?: (error: string) => void; +}; + +export function useFileFetcher({ onError }: FileFetcherOptions = {}) { + const { imageContent, rawContent, imageMetadata, processFile, cancel } = + useProcessFile({ onError }); + + const [state, handleFetchFile, pending] = useActionState< + FileFetcherFormState, + FormData + >( + async (_, formData: FormData): Promise => { + const input = formData.get("url") as string; + const accept = formData.get("accept") as string | null; + + if (!input) return { error: "URL is required." }; + if (!accept) return { error: "Image has invalid or missing MIME type." }; + + const validUrl = validateUrl(input); + if (!validUrl) + return { error: "Invalid URL. Please check and try again." }; + + try { + const response = await fetch(validUrl); + + if (!response.ok) { + switch (response.status) { + case 400: + return { + error: "Invalid request. Please check the URL and try again.", + }; + case 403: + return { + error: + "Forbidden—you don't have permission to access this resource.", + }; + case 404: + return { error: "Image not found. Please check the URL." }; + case 500: + return { error: "Server error. Please try again later." }; + case 503: + return { error: "Service unavailable. Please try again later." }; + default: + let errorMessage = response.status + ""; + if (HTTP_ERROR_CODES.has(response.status)) + errorMessage += ` ${HTTP_ERROR_CODES.get(response.status)}`; + return { + error: `Failed to retrieve image (Error: ${errorMessage}).`, + }; + } + } + + const fileType = response.headers.get("Content-Type") ?? ""; + + if (!fileType.startsWith("image/")) { + return { + error: `Requested file is not an image. Please upload a valid image file.`, + }; + } + + let fileExt = "", + fileContent: string | ArrayBuffer; + + if (fileType.startsWith("image/")) { + fileExt = fileType.split("/").pop() ?? ""; + if (fileExt === "jpeg") fileExt = "jpg"; // normalize jpg extension + if (fileExt === "svg+xml") fileExt = "svg"; // normalize svg extension + } + + const isSvg = fileType === "image/svg+xml"; + const isValidType = + accept === "image/*" || + fileType.startsWith(accept) || + (accept.includes(".svg") && isSvg); + + if (!isValidType) { + return { + error: `Requested image has invalid type. Valid types are: ${accept}.`, + }; + } + + const fileName = + new URL(validUrl).pathname.split("/").pop()?.split(".")[0] ?? "image"; + const fileNameWithExt = `${fileName}.${fileExt}`; + + if (isSvg) { + fileContent = await response.text(); + } else { + fileContent = await response.arrayBuffer(); + } + + const tempFile = new File([fileContent], fileNameWithExt, { + type: fileType, + }); + + await processFile(tempFile); + return { error: null }; // Success, reset error state + } catch (err) { + if ( + err instanceof TypeError && + err.message.includes("Failed to fetch") + ) { + return { + error: + "CORS Error: The requested resource does not allow cross-origin requests.", + }; + } else if (err instanceof Error) { + if (err.message.includes("ERR_CERT_AUTHORITY_INVALID")) { + return { + error: + "Invalid certificate. Try using http:// instead of https://.", + }; + } else if ( + err.message.includes("ERR_FAILED") && + err.message.includes("301") + ) { + return { error: "The requested image has been moved permanently." }; + } else if (err.message.includes("Failed to fetch")) { + return { + error: "Network error—the resource may be down or unreachable.", + }; + } + } + + return { error: "Unknown error. Please try again." }; + } + }, + { error: null }, // Initial state, no form error + ); + + useEffect(() => { + if (state.error) { + if (onError) onError(state.error); + else alert(state.error); + } + }, [state.error, onError]); + + return { + imageContent, + rawContent, + imageMetadata, + handleFetchFile, + pending, + error: state.error, + cancel, + }; +} diff --git a/src/hooks/use-file-uploader.ts b/src/hooks/use-file-uploader.ts index 74e2372..93a3191 100644 --- a/src/hooks/use-file-uploader.ts +++ b/src/hooks/use-file-uploader.ts @@ -1,50 +1,8 @@ -import { useCallback } from "react"; -import { type ChangeEvent, useState } from "react"; import { useClipboardPaste } from "./use-clipboard-paste"; +import { useProcessFile } from "./use-process-file"; -const parseSvgFile = (content: string, fileName: string) => { - const parser = new DOMParser(); - const svgDoc = parser.parseFromString(content, "image/svg+xml"); - const svgElement = svgDoc.documentElement; - const width = parseInt(svgElement.getAttribute("width") ?? "300"); - const height = parseInt(svgElement.getAttribute("height") ?? "150"); - - // Convert SVG content to a data URL - const svgBlob = new Blob([content], { type: "image/svg+xml" }); - const svgUrl = URL.createObjectURL(svgBlob); - - return { - content: svgUrl, - metadata: { - width, - height, - name: fileName, - }, - }; -}; - -const parseImageFile = ( - content: string, - fileName: string, -): Promise<{ - content: string; - metadata: { width: number; height: number; name: string }; -}> => { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => { - resolve({ - content, - metadata: { - width: img.width, - height: img.height, - name: fileName, - }, - }); - }; - img.src = content; - }); -}; +import { type ChangeEvent } from "react"; +import { type FileTypeString } from "@/lib/file-utils"; export type FileUploaderResult = { /** The processed image content as a data URL (for regular images) or object URL (for SVGs) */ @@ -64,6 +22,11 @@ export type FileUploaderResult = { cancel: () => void; }; +type FileUploaderOptions = { + accept?: FileTypeString | FileTypeString[]; + onError?: (error: string) => void; +}; + /** * A hook for handling file uploads, particularly images and SVGs * @returns {FileUploaderResult} An object containing: @@ -73,71 +36,45 @@ export type FileUploaderResult = { * - handleFileUpload: Function to handle file input change events * - cancel: Function to reset the upload state */ -export const useFileUploader = (): FileUploaderResult => { - const [imageContent, setImageContent] = useState(""); - const [rawContent, setRawContent] = useState(""); - const [imageMetadata, setImageMetadata] = useState<{ - width: number; - height: number; - name: string; - } | null>(null); - - const processFile = (file: File) => { - const reader = new FileReader(); - reader.onload = async (e) => { - const content = e.target?.result as string; - setRawContent(content); +export const useFileUploader = ({ + accept, + onError, +}: FileUploaderOptions = {}): FileUploaderResult => { + const { imageContent, rawContent, imageMetadata, processFile, cancel } = + useProcessFile({ onError }); - if (file.type === "image/svg+xml") { - const { content: svgContent, metadata } = parseSvgFile( - content, - file.name, - ); - setImageContent(svgContent); - setImageMetadata(metadata); - } else { - const { content: imgContent, metadata } = await parseImageFile( - content, - file.name, - ); - setImageContent(imgContent); - setImageMetadata(metadata); - } - }; + const acceptedFileTypes: FileTypeString[] = accept + ? Array.isArray(accept) + ? accept + : [accept] + : ["image/*", ".jpg", ".jpeg", ".png", ".webp", ".svg"]; - if (file.type === "image/svg+xml") { - reader.readAsText(file); - } else { - reader.readAsDataURL(file); - } + const handleFileUpload = (file: File) => { + void processFile(file); }; const handleFileUploadEvent = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (file) { - processFile(file); + void processFile(file); } }; - const handleFilePaste = useCallback((file: File) => { - processFile(file); - }, []); + const handleFilePaste = (file: File) => { + void processFile(file); + }; useClipboardPaste({ + acceptedFileTypes, + onError, onPaste: handleFilePaste, - acceptedFileTypes: ["image/*", ".jpg", ".jpeg", ".png", ".webp", ".svg"], }); - const cancel = () => { - setImageContent(""); - setImageMetadata(null); - }; - return { imageContent, rawContent, imageMetadata, - handleFileUpload: processFile, + handleFileUpload, handleFileUploadEvent, cancel, }; diff --git a/src/hooks/use-keydown.ts b/src/hooks/use-keydown.ts new file mode 100644 index 0000000..bf2a8a1 --- /dev/null +++ b/src/hooks/use-keydown.ts @@ -0,0 +1,84 @@ +import { useEffect, useMemo } from "react"; +import { useCallbackRef } from "@/hooks/use-callback-ref"; + +type Key = "Enter" | "Escape" | "Space" | "ArrowDown" | "ArrowUp"; // Add more keys as needed +type ModifierKey = "Alt" | "Command" | "Control" | "Meta" | "Shift"; +type EventCallback = (event: Event) => void; +type EventTarget = Window | Document | Element; + +interface EventOptions { + capture?: boolean; + condition?: boolean; + excludeModifiers?: ModifierKey[]; + modifiers?: ModifierKey[]; + preventDefault?: boolean; + stopPropagation?: boolean; + target?: EventTarget; +} + +const modifiersMap = new Map([ + ["Alt", "altKey"], + ["Command", "metaKey"], + ["Control", "ctrlKey"], + ["Meta", "metaKey"], + ["Shift", "shiftKey"], +]); + +export function useKeyDown( + keys: Key | Key[], + cb: EventCallback, + options: EventOptions = {}, +) { + const { + capture = true, + condition = true, + excludeModifiers = [], + modifiers = [], + preventDefault = false, + stopPropagation = false, + target = globalThis?.document, + } = options; + + const keyList = useMemo(() => (Array.isArray(keys) ? keys : [keys]), [keys]); + + // inspired by Radix UI: https://github.com/radix-ui/primitives/blob/main/packages/react/use-escape-keydown/src/use-escape-keydown.tsx + // provides a stable reference to the latest version of the callback function without re-registering event listeners + const callbackRef = useCallbackRef(cb); + + useEffect(() => { + if (condition === false) return; + + const handleKeyDown = (event: Event) => { + const isKeyMatch = keyList.includes((event as KeyboardEvent).code as Key); + const areModifiersPressed = modifiers.every( + (m) => + (event as KeyboardEvent)[modifiersMap.get(m)! as keyof KeyboardEvent], + ); + const areExcludedModifiersPressed = excludeModifiers.some( + (m) => + (event as KeyboardEvent)[modifiersMap.get(m)! as keyof KeyboardEvent], + ); + + if (isKeyMatch && areModifiersPressed && !areExcludedModifiersPressed) { + if (stopPropagation) event.stopPropagation(); + if (preventDefault) event.preventDefault(); + callbackRef(event as KeyboardEvent); + } + }; + + target.addEventListener("keydown", handleKeyDown, { capture }); + + return () => + target.removeEventListener("keydown", handleKeyDown, { capture }); + }, [ + callbackRef, + capture, + condition, + excludeModifiers, + keyList, + modifiers, + preventDefault, + stopPropagation, + target, + ]); +} diff --git a/src/hooks/use-media-query.ts b/src/hooks/use-media-query.ts new file mode 100644 index 0000000..7bde338 --- /dev/null +++ b/src/hooks/use-media-query.ts @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from "react"; + +export function useMediaQuery(query: string) { + const subscribe = (cb: () => void) => { + const mediaQueryList = window.matchMedia(query); + mediaQueryList.addEventListener("change", cb); + return () => mediaQueryList.removeEventListener("change", cb); + }; + + const getSnapshot = () => window.matchMedia(query).matches; + + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/src/hooks/use-process-file.ts b/src/hooks/use-process-file.ts new file mode 100644 index 0000000..54c9dbd --- /dev/null +++ b/src/hooks/use-process-file.ts @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { + parseSvgFile, + parseImageFile, + readFileContent, +} from "@/lib/file-utils"; + +export type ProcessFileResult = { + /** The processed image content as a data URL (for regular images) or object URL (for SVGs) */ + imageContent: string; + /** The raw file content as a string */ + rawContent: string; + /** Metadata about the uploaded image including dimensions and filename */ + imageMetadata: { + width: number; + height: number; + name: string; + } | null; + /** Function that reads file content and sets the above three values */ + processFile: (file: File) => Promise; + cancel: () => void; +}; + +type ProcessFileOptions = { + onError?: (error: string) => void; +}; + +/** + * A hook for centralizing file (e.g. svg and raster images) processing logic + * in a reusable form factor for use in other, more specialized hooks like + * useFileUploader and useFileFetcher. + * @param {ProcessFileOptions} [options] - Optional configuration object + * @param {function(string): void} [options.onError] - Optional error handler + * @returns {ProcessFileResult} An object cont aining: + * - imageContent: Use this as the src for an img tag + * - rawContent: The raw file content as a string (useful for SVG tags) + * - imageMetadata: Width, height, and name of the image + * - processFile: Function to process files and set the above three values + * - cancel: Function to reset the upload state + */ +export function useProcessFile({ + onError, +}: ProcessFileOptions = {}): ProcessFileResult { + const [imageContent, setImageContent] = useState(""); + const [rawContent, setRawContent] = useState(""); + const [imageMetadata, setImageMetadata] = useState<{ + width: number; + height: number; + name: string; + } | null>(null); + + const processFile = async (file: File): Promise => { + try { + const content = await readFileContent(file); + setRawContent(content); + + if (file.type === "image/svg+xml") { + const { content: parsedSvgContent, metadata } = parseSvgFile( + content, + file.name, + ); + + setImageContent(parsedSvgContent); + setImageMetadata(metadata); + } else { + const { content: parsedImageContent, metadata } = await parseImageFile( + content, + file.name, + ); + + setImageContent(parsedImageContent); + setImageMetadata(metadata); + } + } catch (error) { + if (error instanceof Error) { + if (onError) onError(error.message); + else alert(error.message); + } + } + }; + + const cancel = () => { + setImageContent(""); + setRawContent(""); + setImageMetadata(null); + }; + + return { + imageContent, + rawContent, + imageMetadata, + processFile, + cancel, + }; +} diff --git a/src/lib/dom-utils.ts b/src/lib/dom-utils.ts new file mode 100644 index 0000000..baf9e32 --- /dev/null +++ b/src/lib/dom-utils.ts @@ -0,0 +1,47 @@ +type InteractiveElementTagName = + | "ALL" + | "INPUT" + | "TEXTAREA" + | "SELECT" + | "BUTTON" + | "A"; + +const defaultTagNames: InteractiveElementTagName[] = [ + "INPUT", + "TEXTAREA", + "SELECT", + "BUTTON", + "A", +]; +/** + * Checks whether any elements in the document are currently focused. + * By default, all types of interactive elements are checked, but the + * set of elements to check can be overriden by passing an element's + * tag name as a string, or an array of valid tag name strings. + */ +export function isInteractiveElementFocused( + tagNames: + | InteractiveElementTagName + | InteractiveElementTagName[] = defaultTagNames, + classNames?: string | string[], +): boolean { + const tagNamesToTest = + tagNames === "ALL" + ? defaultTagNames + : Array.isArray(tagNames) + ? tagNames + : [tagNames]; + const classNamesToTest = Array.isArray(classNames) + ? classNames + : [classNames]; + const activeEl = document.activeElement; + + return ( + activeEl instanceof HTMLElement && + (activeEl.isContentEditable || + tagNamesToTest.includes(activeEl.tagName as InteractiveElementTagName) || + classNamesToTest.some( + (className) => className && activeEl.classList.contains(className), + )) + ); +} diff --git a/src/lib/fetch-utils.ts b/src/lib/fetch-utils.ts new file mode 100644 index 0000000..3cca954 --- /dev/null +++ b/src/lib/fetch-utils.ts @@ -0,0 +1,80 @@ +export const HTTP_ERROR_CODES = new Map([ + [400, "Bad Request"], + [401, "Unauthorized"], + [402, "Payment Required"], + [403, "Forbidden"], + [404, "Not Found"], + [405, "Method Not Allowed"], + [406, "Not Acceptable"], + [407, "Proxy Authentication Required"], + [408, "Request Timeout"], + [409, "Conflict"], + [410, "Gone"], + [411, "Length Required"], + [412, "Precondition Failed"], + [413, "Payload Too Large"], + [414, "URI Too Long"], + [415, "Unsupported Media Type"], + [416, "Range Not Satisfiable"], + [417, "Expectation Failed"], + [418, "I'm a teapot"], + [421, "Misdirected Request"], + [422, "Unprocessable Entity"], + [423, "Locked"], + [424, "Failed Dependency"], + [425, "Too Early"], + [426, "Upgrade Required"], + [428, "Precondition Required"], + [429, "Too Many Requests"], + [431, "Request Header Fields Too Large"], + [450, "Blocked by Windows Parental Controls"], + [451, "Unavailable For Legal Reasons"], + [500, "Internal Server Error"], + [501, "Not Implemented"], + [502, "Bad Gateway"], + [503, "Service Unavailable"], + [504, "Gateway Timeout"], + [505, "HTTP Version Not Supported"], + [506, "Variant Also Negotiates"], + [507, "Insufficient Storage"], + [508, "Loop Detected"], + [509, "Bandwidth Limit Exceeded"], + [510, "Not Extended"], + [511, "Network Authentication Required"], + [520, "Cloudflare Web Server Returned an Unknown Error"], + [521, "Cloudflare Web Server Is Down"], + [522, "Cloudflare Connection Timed Out"], + [523, "Cloudflare Origin Is Unreachable"], + [524, "Cloudflare Timeout Occurred"], + [525, "Cloudflare SSL Handshake Failed"], + [598, "Network Read Timeout Error"], + [599, "Network Connect Timeout Error"], +]); + +const REGEX_URL = new RegExp( + "^(https?:\\/\\/)?" + // protocol (optional) + "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name + "((\\d{1,3}\\.){3}\\d{1,3}))" + // or, IPv4 address + "(\\:\\d+)?" + // port (optional) + "(\\/[-a-z\\d%_.~+=]*)*" + // path string (at least one "/" will be in a valid resource) + "(\\?[^#]*)?" + // query string (optional) + "(\\#[-a-z\\d_]*)?$", // URL fragment (optional) + "i", +); + +export function validateUrl(input: string) { + const trimmedInput = input.trim(); + + if (!trimmedInput || !REGEX_URL.test(trimmedInput)) return null; + + try { + const url = new URL( + trimmedInput.startsWith("http") + ? trimmedInput + : `https://${trimmedInput}`, + ); + return url.href; + } catch { + return null; + } +} diff --git a/src/lib/file-utils.ts b/src/lib/file-utils.ts index bf244ef..f4ac218 100644 --- a/src/lib/file-utils.ts +++ b/src/lib/file-utils.ts @@ -1,5 +1,39 @@ import type { ChangeEvent } from "react"; +import type { FileUploaderResult } from "@/hooks/use-file-uploader"; +import type { FileFetcherResult } from "@/hooks/use-file-fetcher"; + +export type ImageMetadata = + | FileUploaderResult["imageMetadata"] + | FileFetcherResult["imageMetadata"]; + +export type FileType = "Image (any type)" | "JPG" | "PNG" | "WEBP" | "SVG"; +export type FileTypeString = + | "image/*" + | "image/jpeg" + | ".jpg" + | ".jpeg" + | "image/png" + | ".png" + | "image/webp" + | ".webp" + | "image/svg+xml" + | ".svg"; + +// Mapping of file extensions and MIME types to human-readable file types +export const allFileTypes: { type: FileTypeString; group: FileType }[] = [ + { type: "image/*", group: "Image (any type)" }, + { type: ".jpeg", group: "JPG" }, + { type: ".jpg", group: "JPG" }, + { type: "image/jpeg", group: "JPG" }, + { type: ".png", group: "PNG" }, + { type: "image/png", group: "PNG" }, + { type: ".webp", group: "WEBP" }, + { type: "image/webp", group: "WEBP" }, + { type: ".svg", group: "SVG" }, + { type: "image/svg+xml", group: "SVG" }, +] as const; + // Create a no-op function that satisfies the linter const noop = () => { /* intentionally empty */ @@ -35,3 +69,95 @@ export function createFileChangeEvent( persist: noop, } as ChangeEvent; } + +export function parseImageFile( + content: string, + fileName: string, +): Promise<{ + content: string; + metadata: { width: number; height: number; name: string }; +}> { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + resolve({ + content, + metadata: { + width: img.width, + height: img.height, + name: fileName, + }, + }); + }; + img.src = content; + }); +} + +export function parseSvgFile(content: string, fileName: string) { + const parser = new DOMParser(); + const svgDoc = parser.parseFromString(content, "image/svg+xml"); + const svgElement = svgDoc.documentElement; + const viewBox = svgElement.getAttribute("viewBox"); + let width = parseInt(svgElement.getAttribute("width") ?? ""); + let height = parseInt(svgElement.getAttribute("height") ?? ""); + + // If width and height are not expliclitly defined, try to extract them from the viewBox attribute + if ((!width || !height) && viewBox) { + const viewBoxValues = viewBox.split(" ").map(parseFloat); + if (viewBoxValues.length === 4) { + width = viewBoxValues[2]!; + height = viewBoxValues[3]!; + } + } + + if (isNaN(width) || isNaN(height)) + throw new TypeError("Unable to parse SVG. File may be corrupted."); + + // Convert SVG content to a data URL + const svgBlob = new Blob([content], { type: "image/svg+xml" }); + const svgUrl = URL.createObjectURL(svgBlob); + + return { + content: svgUrl, + metadata: { + width, + height, + name: fileName, + }, + }; +} + +export function readFileContent(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + if (!e.target?.result) { + reject(new Error("Unable to read file")); + } else { + resolve(e.target.result as string); + } + }; + + if (file.type === "image/svg+xml") { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + }); +} + +/** + * Given a list of accepted file extensions and/or MIME types, generates + * a human-readable string of accepted file types for display purposes. + */ +export function generateFileTypesString(accepted: FileTypeString[]) { + return [ + ...new Set( + accepted.map( + (acceptedType) => + allFileTypes.find(({ type }) => type === acceptedType)!.group, + ), + ), + ].join(", "); +} diff --git a/src/lib/math-utils.ts b/src/lib/math-utils.ts new file mode 100644 index 0000000..000764c --- /dev/null +++ b/src/lib/math-utils.ts @@ -0,0 +1,18 @@ +/** + * Rounding function for neatly displaying pixel values to a maximum of 2 decimal places. + * + * Examples: + * formatNumber(124) // 124 + * formatNumber(201.5) // 201.5 + * formatNumber(88.56) // 88.56 + * formatNumber(299.636) // 299.64 + * formatNumber(67.699) // 67.7 + * formatNumber(454.99999999999994) // 455 + * formatNumber(12.340) // 12.34 + * formatNumber(5.6000) // 5.6 + */ + +export function formatNumber(value: number): number { + const rounded = Math.round(value * 100) / 100; + return parseFloat(rounded.toString()); +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 021c393..0486bfe 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,5 +1,12 @@ import type { Config } from "tailwindcss"; +const widths = { + "2xs": "20rem", + xs: "29rem", + sm: "40rem", + container: "25rem", +} as const; + const config: Config = { content: [ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", @@ -12,8 +19,18 @@ const config: Config = { background: "var(--background)", foreground: "var(--foreground)", }, + width: { + ...widths, + }, + maxWidth: { + ...widths, + }, + screens: { + ...widths, + }, }, }, plugins: [], }; + export default config;