+
+
+ 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 (
+
+
+ {error}
+
+ );
+}
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}
+
)}
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 ? (
+
- {options.map((option) => (
-
- ))}
-
+ ) : (
+
+
+ {options.map((option) => (
+
+ ))}
+
+ )}
);
diff --git a/src/components/shared/page-title.tsx b/src/components/shared/page-title.tsx
new file mode 100644
index 0000000..8b60082
--- /dev/null
+++ b/src/components/shared/page-title.tsx
@@ -0,0 +1,8 @@
+export function PageTitle({ title }: { title: string }) {
+ const text = title.split(" - ")[0];
+ return (
+
+ {text}
+
+ );
+}
diff --git a/src/components/shared/preview-scale.tsx b/src/components/shared/preview-scale.tsx
new file mode 100644
index 0000000..f0391a4
--- /dev/null
+++ b/src/components/shared/preview-scale.tsx
@@ -0,0 +1,20 @@
+import { EyeIcon } from "@/components/shared/icons";
+
+import { formatNumber } from "@/lib/math-utils";
+
+type PreviewScaleProps = {
+ previewScale: number | null;
+};
+
+export function PreviewScale({ previewScale }: PreviewScaleProps) {
+ // If the image preview is scaled down to fit, display the scaled percentage
+ return (
+ previewScale &&
+ previewScale < 1 && (
+
+
+ {`Viewing at ${formatNumber(previewScale * 100)}%`}
+
+ )
+ );
+}
diff --git a/src/components/shared/select.tsx b/src/components/shared/select.tsx
new file mode 100644
index 0000000..ed3b513
--- /dev/null
+++ b/src/components/shared/select.tsx
@@ -0,0 +1,209 @@
+import React, { useEffect, useRef, useState } from "react";
+
+import { useKeyDown } from "@/hooks/use-keydown";
+
+type SelectProps
= {
+ placeholder?: string;
+ options: T[];
+ selected: T;
+ onChange: (value: T) => void;
+ formatOption?: (option: T) => string;
+ className?: string;
+};
+
+export function Select({
+ placeholder,
+ options,
+ selected,
+ onChange,
+ formatOption = (option) => `${option}`,
+ className,
+}: SelectProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [highlightedIndex, setHighlightedIndex] = useState(null);
+ const containerRef = useRef(null);
+ const selectRef = useRef(null);
+
+ const selectedOption = options.find((option) => option === selected);
+ const selectedLabel = selectedOption
+ ? formatOption(selectedOption)
+ : (placeholder ?? "Select…");
+ const selectedIndex = options.findIndex((option) => option === selected);
+
+ const toggleOptions = () => {
+ setIsOpen((prev) => {
+ const newState = !prev;
+ if (newState)
+ setHighlightedIndex(selectedIndex !== -1 ? selectedIndex : 0);
+ return newState;
+ });
+ };
+
+ const selectOption = (index: number) => {
+ const option = options[index];
+
+ if (option) {
+ onChange(option);
+ setIsOpen(false);
+ }
+ };
+
+ useKeyDown(
+ ["ArrowDown", "ArrowUp"],
+ () => {
+ setIsOpen(true);
+ setHighlightedIndex(selectedIndex !== -1 ? selectedIndex : 0);
+ },
+ {
+ condition: !isOpen,
+ preventDefault: true,
+ ...(containerRef.current && { target: containerRef.current }),
+ },
+ );
+
+ useKeyDown(
+ "ArrowDown",
+ () => {
+ setIsOpen(true);
+
+ setHighlightedIndex((prevIndex) => {
+ if (prevIndex === null) return 0;
+ return (++prevIndex + options.length) % options.length;
+ });
+ },
+ {
+ condition: isOpen,
+ preventDefault: true,
+ ...(containerRef.current && { target: containerRef.current }),
+ },
+ );
+
+ useKeyDown(
+ "ArrowUp",
+ () => {
+ setIsOpen(true);
+
+ setHighlightedIndex((prevIndex) => {
+ if (prevIndex === null) return options.length - 1;
+ return (--prevIndex + options.length) % options.length;
+ });
+ },
+ {
+ condition: isOpen,
+ preventDefault: true,
+ ...(containerRef.current && { target: containerRef.current }),
+ },
+ );
+
+ useKeyDown(
+ ["Enter", "Space"],
+ () => {
+ selectOption(highlightedIndex!);
+ },
+ {
+ condition: isOpen && highlightedIndex !== null,
+ preventDefault: true,
+ ...(containerRef.current && { target: containerRef.current }),
+ },
+ );
+
+ useKeyDown(
+ ["Enter", "Space"],
+ () => {
+ setIsOpen(true);
+ setHighlightedIndex(selectedIndex !== -1 ? selectedIndex : 0);
+ },
+ {
+ condition: !isOpen || highlightedIndex === null,
+ preventDefault: true,
+ ...(containerRef.current && { target: containerRef.current }),
+ },
+ );
+
+ useKeyDown(
+ "Escape",
+ () => {
+ setIsOpen(false);
+ },
+ {
+ ...(containerRef.current && { target: containerRef.current }),
+ },
+ );
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ containerRef.current &&
+ !containerRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ return (
+
+
+
+ {selectedLabel}
+
+
+
+
+
+
+
+ {isOpen && (
+
+ {options.map((option, index) => (
+ - selectOption(index)}
+ onMouseEnter={() => setHighlightedIndex(index)}
+ >
+ {formatOption(option)}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/shared/upload-box.tsx b/src/components/shared/upload-box.tsx
index 4972a4a..fcc0d4a 100644
--- a/src/components/shared/upload-box.tsx
+++ b/src/components/shared/upload-box.tsx
@@ -1,8 +1,13 @@
-import React from "react";
+import React, { useRef } from "react";
+
+import { UploadIcon } from "@/components/shared/icons";
+
+import { useKeyDown } from "@/hooks/use-keydown";
interface UploadBoxProps {
title: string;
subtitle?: string;
+ subtitleIcon?: React.ReactElement;
description: string;
accept: string;
onChange: (event: React.ChangeEvent) => void;
@@ -11,43 +16,64 @@ interface UploadBoxProps {
export function UploadBox({
title,
subtitle,
+ subtitleIcon,
description,
accept,
onChange,
}: UploadBoxProps) {
+ const fileInputRef = useRef(null);
+ const fileInputLabelRef = useRef(null);
+
+ useKeyDown(["Enter", "Space"], () => {
+ if (
+ fileInputRef.current &&
+ document.activeElement === fileInputLabelRef.current
+ ) {
+ fileInputRef.current.click();
+ }
+ });
+
return (
-
+
-
{title}
+
+ {title}
+
{subtitle && (
-
+
+ {subtitleIcon}
{subtitle}
)}
-
-
-
Drag and Drop
-
or
-
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 ? (
+
)}
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;