From fc28e23f6b54e4bf51de923bc37a712ae05db8b4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 10 Nov 2024 22:06:38 -0800 Subject: [PATCH 1/2] Drag And Drop, Options Selector, Paste From Clipboard, Redo Upload (#62) Co-authored-by: grim <75869731+vys69@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 2 +- .../(tools)/rounded-border/rounded-tool.tsx | 196 +++++++------- src/app/(tools)/square-image/square-tool.tsx | 251 ++++++++---------- src/app/(tools)/svg-to-png/svg-tool.tsx | 192 +++++++------- src/components/border-radius-selector.tsx | 112 ++++++++ src/components/shared/file-dropzone.tsx | 96 +++++++ src/components/shared/option-selector.tsx | 68 +++++ src/components/shared/upload-box.tsx | 58 ++++ src/components/svg-scale-selector.tsx | 104 ++++++++ src/hooks/use-clipboard-paste.ts | 50 ++++ src/hooks/use-file-uploader.ts | 144 ++++++++++ src/lib/file-utils.ts | 37 +++ 13 files changed, 955 insertions(+), 357 deletions(-) create mode 100644 src/components/border-radius-selector.tsx create mode 100644 src/components/shared/file-dropzone.tsx create mode 100644 src/components/shared/option-selector.tsx create mode 100644 src/components/shared/upload-box.tsx create mode 100644 src/components/svg-scale-selector.tsx create mode 100644 src/hooks/use-clipboard-paste.ts create mode 100644 src/hooks/use-file-uploader.ts create mode 100644 src/lib/file-utils.ts diff --git a/package.json b/package.json index 78bfe51..55fd279 100644 --- a/package.json +++ b/package.json @@ -43,4 +43,4 @@ "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1" } } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 710dabe..30439a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3923,4 +3923,4 @@ snapshots: dependencies: zod: 3.23.8 - zod@3.23.8: {} + zod@3.23.8: {} \ No newline at end of file diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index 8c85dc4..2dc4b80 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -1,11 +1,18 @@ "use client"; import { usePlausible } from "next-plausible"; import { useMemo, useState } from "react"; -import type { ChangeEvent } from "react"; import { useLocalStorage } from "@/hooks/use-local-storage"; import React from "react"; +import { UploadBox } from "@/components/shared/upload-box"; +import { OptionSelector } from "@/components/shared/option-selector"; +import { BorderRadiusSelector } from "@/components/border-radius-selector"; +import { + useFileUploader, + type FileUploaderResult, +} from "@/hooks/use-file-uploader"; +import { FileDropzone } from "@/components/shared/file-dropzone"; -type Radius = 2 | 4 | 8 | 16 | 32 | 64; +type Radius = number; type BackgroundOption = "white" | "black" | "transparent"; @@ -69,44 +76,6 @@ function useImageConverter(props: { }; } -export const useFileUploader = () => { - const [imageContent, setImageContent] = useState(""); - - const [imageMetadata, setImageMetadata] = useState<{ - width: number; - height: number; - name: string; - } | null>(null); - - const handleFileUpload = (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - const content = e.target?.result as string; - const img = new Image(); - img.onload = () => { - setImageMetadata({ - width: img.width, - height: img.height, - name: file.name, - }); - setImageContent(content); - }; - img.src = content; - }; - reader.readAsDataURL(file); - } - }; - - const cancel = () => { - setImageContent(""); - setImageMetadata(null); - }; - - return { imageContent, imageMetadata, handleFileUpload, cancel }; -}; - interface ImageRendererProps { imageContent: string; radius: Radius; @@ -130,7 +99,7 @@ const ImageRenderer: React.FC = ({ }, [imageContent, radius]); return ( -
+
= ({ src={imageContent} alt="Preview" className="relative rounded-lg" - style={{ width: "100%", height: "auto" }} + style={{ width: "100%", height: "auto", objectFit: "contain" }} />
); @@ -185,91 +154,104 @@ function SaveAsPngButton({ ); } -export function RoundedTool() { - const { imageContent, imageMetadata, handleFileUpload, cancel } = - useFileUploader(); - +function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { + const { imageContent, imageMetadata, handleFileUploadEvent, cancel } = + props.fileUploaderProps; const [radius, setRadius] = useLocalStorage("roundedTool_radius", 2); + const [isCustomRadius, setIsCustomRadius] = useState(false); const [background, setBackground] = useLocalStorage( "roundedTool_background", "transparent", ); - if (!imageMetadata) + const handleRadiusChange = (value: number | "custom") => { + if (value === "custom") { + setIsCustomRadius(true); + } else { + setRadius(value); + setIsCustomRadius(false); + } + }; + + if (!imageMetadata) { return ( -
-

Round the corners of any image

-
- -
-
+ ); + } return ( -
- -

{imageMetadata.name}

-

- Original size: {imageMetadata.width}px x {imageMetadata.height}px -

-
- {([2, 4, 8, 16, 32, 64] as Radius[]).map((value) => ( - - ))} -
-
- {(["white", "black", "transparent"] as BackgroundOption[]).map( - (option) => ( - - ), - )} -
-
- +
+ +

+ {imageMetadata.name} +

+
+ +
+ Original Size + + {imageMetadata.width} × {imageMetadata.height} + +
+ + + + + option.charAt(0).toUpperCase() + option.slice(1) + } + /> + +
+
); } + +export function RoundedTool() { + const fileUploaderProps = useFileUploader(); + + return ( + + + + ); +} diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index 07832e0..d5a43a3 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -1,43 +1,58 @@ "use client"; -import React, { useState, useEffect, type ChangeEvent } from "react"; 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 { FileDropzone } from "@/components/shared/file-dropzone"; +import { + type FileUploaderResult, + useFileUploader, +} from "@/hooks/use-file-uploader"; +import { useEffect, useState } from "react"; + +function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { + const { imageContent, imageMetadata, handleFileUploadEvent, cancel } = + props.fileUploaderProps; -export const SquareTool: React.FC = () => { - const [imageFile, setImageFile] = useState(null); const [backgroundColor, setBackgroundColor] = useLocalStorage< "black" | "white" >("squareTool_backgroundColor", "white"); - const [previewUrl, setPreviewUrl] = useState(null); - const [canvasDataUrl, setCanvasDataUrl] = useState(null); - const [imageMetadata, setImageMetadata] = useState<{ - width: number; - height: number; - name: string; - } | null>(null); - const plausible = usePlausible(); + const [squareImageContent, setSquareImageContent] = useState( + null, + ); - const handleImageUpload = (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - setImageFile(file); - setImageMetadata({ width: 0, height: 0, name: file.name }); - } - }; + useEffect(() => { + if (imageContent && imageMetadata) { + const canvas = document.createElement("canvas"); + const size = Math.max(imageMetadata.width, imageMetadata.height); + canvas.width = size; + canvas.height = size; - const handleBackgroundColorChange = ( - event: ChangeEvent, - ) => { - const color = event.target.value as "black" | "white"; - setBackgroundColor(color); - }; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Fill background + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, size, size); + + // 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); + setSquareImageContent(canvas.toDataURL("image/png")); + }; + img.src = imageContent; + } + }, [imageContent, imageMetadata, backgroundColor]); const handleSaveImage = () => { - if (canvasDataUrl && imageMetadata) { + if (squareImageContent && imageMetadata) { const link = document.createElement("a"); - link.href = canvasDataUrl; + link.href = squareImageContent; const originalFileName = imageMetadata.name; const fileNameWithoutExtension = originalFileName.substring(0, originalFileName.lastIndexOf(".")) || @@ -49,124 +64,65 @@ export const SquareTool: React.FC = () => { } }; - useEffect(() => { - if (imageFile) { - const reader = new FileReader(); - reader.onload = () => { - const img = new Image(); - img.onload = () => { - const maxDim = Math.max(img.width, img.height); - setImageMetadata((prevState) => ({ - ...prevState!, - width: img.width, - height: img.height, - })); - - const canvas = document.createElement("canvas"); - canvas.width = maxDim; - canvas.height = maxDim; - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, canvas.width, canvas.height); - const x = (maxDim - img.width) / 2; - const y = (maxDim - img.height) / 2; - ctx.drawImage(img, x, y); - const dataUrl = canvas.toDataURL("image/png"); - setCanvasDataUrl(dataUrl); - - // Create a smaller canvas for the preview - const previewCanvas = document.createElement("canvas"); - const previewSize = 200; // Set desired preview size - previewCanvas.width = previewSize; - previewCanvas.height = previewSize; - const previewCtx = previewCanvas.getContext("2d"); - if (previewCtx) { - previewCtx.drawImage( - canvas, - 0, - 0, - canvas.width, - canvas.height, - 0, - 0, - previewSize, - previewSize, - ); - const previewDataUrl = previewCanvas.toDataURL("image/png"); - setPreviewUrl(previewDataUrl); - } - } - }; - if (typeof reader.result === "string") { - img.src = reader.result; - } - }; - reader.readAsDataURL(imageFile); - } else { - setPreviewUrl(null); - setCanvasDataUrl(null); - setImageMetadata(null); - } - }, [imageFile, backgroundColor]); + const plausible = usePlausible(); if (!imageMetadata) { return ( -
-

- Create square images with custom backgrounds. Fast and free. -

-
- -
-
+ ); } return ( -
- {previewUrl && Preview} -

{imageMetadata.name}

-

- Original size: {imageMetadata.width}px x {imageMetadata.height}px -

-

- Square size: {Math.max(imageMetadata.width, imageMetadata.height)}px x{" "} - {Math.max(imageMetadata.width, imageMetadata.height)}px -

- -
- - +
+
+ {squareImageContent && ( + Preview + )} +

+ {imageMetadata.name} +

-
+
+
+ Original + + {imageMetadata.width} × {imageMetadata.height} + +
+ +
+ Square Size + + {Math.max(imageMetadata.width, imageMetadata.height)} ×{" "} + {Math.max(imageMetadata.width, imageMetadata.height)} + +
+
+ + + option.charAt(0).toUpperCase() + option.slice(1) + } + /> + +
+ -
); +} + +export const SquareTool: React.FC = () => { + const fileUploaderProps = useFileUploader(); + + return ( + + + + ); }; diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index eb9232c..a60d659 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -1,13 +1,14 @@ "use client"; import { usePlausible } from "next-plausible"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useLocalStorage } from "@/hooks/use-local-storage"; -import { type ChangeEvent } from "react"; +import { UploadBox } from "@/components/shared/upload-box"; +import { SVGScaleSelector } from "@/components/svg-scale-selector"; -type Scale = 1 | 2 | 4 | 8 | 16 | 32 | 64; +export type Scale = "custom" | number; -function scaleSvg(svgContent: string, scale: Scale) { +function scaleSvg(svgContent: string, scale: number) { const parser = new DOMParser(); const svgDoc = parser.parseFromString(svgContent, "image/svg+xml"); const svgElement = svgDoc.documentElement; @@ -26,7 +27,7 @@ function scaleSvg(svgContent: string, scale: Scale) { function useSvgConverter(props: { canvas: HTMLCanvasElement | null; svgContent: string; - scale: Scale; + scale: number; fileName?: string; imageMetadata: { width: number; height: number; name: string }; }) { @@ -74,60 +75,20 @@ function useSvgConverter(props: { }; } -export const useFileUploader = () => { - const [svgContent, setSvgContent] = useState(""); - - const [imageMetadata, setImageMetadata] = useState<{ - width: number; - height: number; - name: string; - } | null>(null); - - const handleFileUpload = (event: ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - const content = e.target?.result as string; - - // Extract width and height from SVG content - 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"); - - setSvgContent(content); - setImageMetadata({ width, height, name: file.name }); - }; - reader.readAsText(file); - } - }; - - const cancel = () => { - setSvgContent(""); - setImageMetadata(null); - }; - - return { svgContent, imageMetadata, handleFileUpload, cancel }; -}; - -import React from "react"; - interface SVGRendererProps { svgContent: string; } const SVGRenderer: React.FC = ({ svgContent }) => { - const containerRef = React.useRef(null); + const containerRef = useRef(null); - React.useEffect(() => { + useEffect(() => { if (containerRef.current) { containerRef.current.innerHTML = svgContent; const svgElement = containerRef.current.querySelector("svg"); if (svgElement) { svgElement.setAttribute("width", "100%"); - svgElement.setAttribute("height", "auto"); + svgElement.setAttribute("height", "100%"); } } }, [svgContent]); @@ -141,12 +102,10 @@ function SaveAsPngButton({ imageMetadata, }: { svgContent: string; - scale: Scale; + scale: number; imageMetadata: { width: number; height: number; name: string }; }) { - const [canvasRef, setCanvasRef] = React.useState( - null, - ); + const [canvasRef, setCanvasRef] = useState(null); const { convertToPng, canvasProps } = useSvgConverter({ canvas: canvasRef, svgContent, @@ -172,71 +131,100 @@ function SaveAsPngButton({ ); } -export function SVGTool() { - const { svgContent, imageMetadata, handleFileUpload, cancel } = - useFileUploader(); +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; const [scale, setScale] = useLocalStorage("svgTool_scale", 1); + const [customScale, setCustomScale] = useLocalStorage( + "svgTool_customScale", + 1, + ); + + // Get the actual numeric scale value + const effectiveScale = scale === "custom" ? customScale : scale; if (!imageMetadata) return ( -
-

- Make SVGs into PNGs. Also makes them bigger. (100% free btw.) -

-
- -
-
+ ); return ( -
- -

{imageMetadata.name}

-

- Original size: {imageMetadata.width}px x {imageMetadata.height}px -

-

- Scaled size: {imageMetadata.width * scale}px x{" "} - {imageMetadata.height * scale}px -

-
- {([1, 2, 4, 8, 16, 32, 64] as Scale[]).map((value) => ( - - ))} +
+ {/* Preview Section */} +
+ +

+ {imageMetadata.name} +

-
- + + {/* Size Information */} +
+
+ Original + + {imageMetadata.width} × {imageMetadata.height} + +
+ +
+ Scaled + + {imageMetadata.width * effectiveScale} ×{" "} + {imageMetadata.height * effectiveScale} + +
+
+ + {/* Scale Controls */} + + + {/* Action Buttons */} +
+
); } + +export function SVGTool() { + const fileUploaderProps = useFileUploader(); + return ( + + + + ); +} diff --git a/src/components/border-radius-selector.tsx b/src/components/border-radius-selector.tsx new file mode 100644 index 0000000..8ff2ed8 --- /dev/null +++ b/src/components/border-radius-selector.tsx @@ -0,0 +1,112 @@ +import React, { useRef, useEffect } from "react"; + +interface BorderRadiusSelectorProps { + title: string; + options: number[]; + selected: number | "custom"; + onChange: (value: number | "custom") => void; + customValue?: number; + onCustomValueChange?: (value: number) => void; +} + +export function BorderRadiusSelector({ + title, + options, + selected, + onChange, + customValue, + onCustomValueChange, +}: BorderRadiusSelectorProps) { + 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; + + const containerRect = container.getBoundingClientRect(); + const selectedRect = selected.getBoundingClientRect(); + + highlight.style.left = `${selectedRect.left - containerRect.left}px`; + highlight.style.width = `${selectedRect.width}px`; + } + }, [selected]); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = Math.min(999, Math.max(0, parseInt(e.target.value) || 0)); + onCustomValueChange?.(value); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + 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( + 999, + Math.max(0, Number(newValue.toFixed(1))), + ); + onCustomValueChange?.(clampedValue); + }; + + return ( +
+ {title} +
+
+
+ {[...options, "custom" as const].map((option) => ( + + ))} +
+ {selected === "custom" && ( +
+
+ + px +
+
+ )} +
+
+ ); +} diff --git a/src/components/shared/file-dropzone.tsx b/src/components/shared/file-dropzone.tsx new file mode 100644 index 0000000..021c17f --- /dev/null +++ b/src/components/shared/file-dropzone.tsx @@ -0,0 +1,96 @@ +import React, { useCallback, useState, useRef } from "react"; + +interface FileDropzoneProps { + children: React.ReactNode; + acceptedFileTypes: string[]; + dropText: string; + setCurrentFile: (file: File) => void; +} + +export const FileDropzone: React.FC = ({ + children, + acceptedFileTypes, + dropText, + setCurrentFile, +}) => { + const [isDragging, setIsDragging] = useState(false); + const dragCounter = useRef(0); + + const handleDrag = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDragIn = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current++; + + if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) { + setIsDragging(true); + } + }, []); + + const handleDragOut = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter.current--; + + if (dragCounter.current === 0) { + setIsDragging(false); + } + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + dragCounter.current = 0; + + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const droppedFile = files[0]; + + if (!droppedFile) { + alert("How did you do a drop with no files???"); + throw new Error("No files dropped"); + } + + if ( + !acceptedFileTypes.includes(droppedFile.type) && + !acceptedFileTypes.some((type) => + droppedFile.name.toLowerCase().endsWith(type.replace("*", "")), + ) + ) { + alert("Invalid file type. Please upload a supported file type."); + throw new Error("Invalid file"); + } + + // Happy path + setCurrentFile(droppedFile); + } + }, + [acceptedFileTypes, setCurrentFile], + ); + + return ( +
+ {isDragging && ( +
+
+
+

{dropText}

+
+
+ )} + {children} +
+ ); +}; diff --git a/src/components/shared/option-selector.tsx b/src/components/shared/option-selector.tsx new file mode 100644 index 0000000..b77ce5e --- /dev/null +++ b/src/components/shared/option-selector.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +interface OptionSelectorProps { + title: string; + options: T[]; + selected: T; + onChange: (value: T) => void; + formatOption?: (option: T) => string; +} + +export function OptionSelector({ + title, + options, + selected, + onChange, + formatOption = (option) => `${option}`, +}: OptionSelectorProps) { + 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; + + const containerRect = container.getBoundingClientRect(); + const selectedRect = selected.getBoundingClientRect(); + + highlight.style.left = `${selectedRect.left - containerRect.left}px`; + highlight.style.width = `${selectedRect.width}px`; + } + }, [selected]); + + return ( +
+ {title} +
+
+
+ {options.map((option) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/shared/upload-box.tsx b/src/components/shared/upload-box.tsx new file mode 100644 index 0000000..32f7c90 --- /dev/null +++ b/src/components/shared/upload-box.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React from "react"; + +interface UploadBoxProps { + title: string; + subtitle?: string; + description: string; + accept: string; + onChange: (event: React.ChangeEvent) => void; +} + +export const UploadBox: React.FC = ({ + title, + subtitle, + description, + accept, + onChange, +}) => { + return ( +
+
+

{title}

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ + + +

Drag and Drop

+

or

+ +
+
+ ); +}; diff --git a/src/components/svg-scale-selector.tsx b/src/components/svg-scale-selector.tsx new file mode 100644 index 0000000..f2eb628 --- /dev/null +++ b/src/components/svg-scale-selector.tsx @@ -0,0 +1,104 @@ +import React, { useRef, useEffect } from "react"; + +interface SVGScaleSelectorProps { + title: string; + options: number[]; + selected: number | "custom"; + onChange: (value: number | "custom") => void; + customValue?: number; + onCustomValueChange?: (value: number) => void; +} + +export function SVGScaleSelector({ + title, + options, + selected, + onChange, + customValue, + onCustomValueChange, +}: SVGScaleSelectorProps) { + 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; + + const containerRect = container.getBoundingClientRect(); + const selectedRect = selected.getBoundingClientRect(); + + highlight.style.left = `${selectedRect.left - containerRect.left}px`; + highlight.style.width = `${selectedRect.width}px`; + } + }, [selected]); + + return ( +
+ {title} +
+
+
+ {[...options, "custom" as const].map((option) => ( + + ))} +
+ {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-clipboard-paste.ts b/src/hooks/use-clipboard-paste.ts new file mode 100644 index 0000000..f95aa69 --- /dev/null +++ b/src/hooks/use-clipboard-paste.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useCallback } from "react"; + +interface UseClipboardPasteProps { + onPaste: (file: File) => void; + acceptedFileTypes: string[]; +} + +export function useClipboardPaste({ + onPaste, + acceptedFileTypes, +}: UseClipboardPasteProps) { + const handlePaste = useCallback( + async (event: ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) return; + + for (const item of Array.from(items)) { + if (item.type.startsWith("image/")) { + const file = item.getAsFile(); + if (!file) continue; + + const isAcceptedType = acceptedFileTypes.some( + (type) => + type === "image/*" || + type === item.type || + file.name.toLowerCase().endsWith(type.replace("*", "")), + ); + + if (isAcceptedType) { + event.preventDefault(); + onPaste(file); + break; + } + } + } + }, + [onPaste, acceptedFileTypes], + ); + + useEffect(() => { + const handler = (event: ClipboardEvent) => { + void handlePaste(event); + }; + + document.addEventListener("paste", handler); + return () => document.removeEventListener("paste", handler); + }, [handlePaste]); +} diff --git a/src/hooks/use-file-uploader.ts b/src/hooks/use-file-uploader.ts new file mode 100644 index 0000000..74e2372 --- /dev/null +++ b/src/hooks/use-file-uploader.ts @@ -0,0 +1,144 @@ +import { useCallback } from "react"; +import { type ChangeEvent, useState } from "react"; +import { useClipboardPaste } from "./use-clipboard-paste"; + +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; + }); +}; + +export type FileUploaderResult = { + /** 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 file input change events */ + handleFileUpload: (file: File) => void; + handleFileUploadEvent: (event: ChangeEvent) => void; + /** Resets the upload state */ + cancel: () => void; +}; + +/** + * A hook for handling file uploads, particularly images and SVGs + * @returns {FileUploaderResult} An object containing: + * - 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 + * - 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); + + 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); + } + }; + + if (file.type === "image/svg+xml") { + reader.readAsText(file); + } else { + reader.readAsDataURL(file); + } + }; + + const handleFileUploadEvent = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + processFile(file); + } + }; + + const handleFilePaste = useCallback((file: File) => { + processFile(file); + }, []); + + useClipboardPaste({ + onPaste: handleFilePaste, + acceptedFileTypes: ["image/*", ".jpg", ".jpeg", ".png", ".webp", ".svg"], + }); + + const cancel = () => { + setImageContent(""); + setImageMetadata(null); + }; + + return { + imageContent, + rawContent, + imageMetadata, + handleFileUpload: processFile, + handleFileUploadEvent, + cancel, + }; +}; diff --git a/src/lib/file-utils.ts b/src/lib/file-utils.ts new file mode 100644 index 0000000..bf244ef --- /dev/null +++ b/src/lib/file-utils.ts @@ -0,0 +1,37 @@ +import type { ChangeEvent } from "react"; + +// Create a no-op function that satisfies the linter +const noop = () => { + /* intentionally empty */ +}; + +export function createFileChangeEvent( + file: File, +): ChangeEvent { + const target: Partial = { + files: [file] as unknown as FileList, + value: "", + name: "", + type: "file", + accept: ".svg", + multiple: false, + webkitdirectory: false, + className: "hidden", + }; + + return { + target: target as HTMLInputElement, + currentTarget: target as HTMLInputElement, + preventDefault: noop, + stopPropagation: noop, + bubbles: true, + cancelable: true, + timeStamp: Date.now(), + type: "change", + nativeEvent: new Event("change"), + isDefaultPrevented: () => false, + isPropagationStopped: () => false, + isTrusted: true, + persist: noop, + } as ChangeEvent; +} From abba7df270741ee208d08f947c08201e3536241e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 10 Nov 2024 22:10:51 -0800 Subject: [PATCH 2/2] more cleanup and refactoring --- src/app/(tools)/rounded-border/rounded-tool.tsx | 15 ++++++--------- src/app/(tools)/square-image/square-tool.tsx | 4 ++-- src/app/(tools)/svg-to-png/svg-tool.tsx | 4 ++-- src/components/shared/file-dropzone.tsx | 6 +++--- src/components/shared/upload-box.tsx | 8 +++----- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index 2dc4b80..a5dbf34 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -1,8 +1,7 @@ "use client"; import { usePlausible } from "next-plausible"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useLocalStorage } from "@/hooks/use-local-storage"; -import React from "react"; import { UploadBox } from "@/components/shared/upload-box"; import { OptionSelector } from "@/components/shared/option-selector"; import { BorderRadiusSelector } from "@/components/border-radius-selector"; @@ -82,14 +81,14 @@ interface ImageRendererProps { background: BackgroundOption; } -const ImageRenderer: React.FC = ({ +const ImageRenderer = ({ imageContent, radius, background, -}) => { - const containerRef = React.useRef(null); +}: ImageRendererProps) => { + const containerRef = useRef(null); - React.useEffect(() => { + useEffect(() => { if (containerRef.current) { const imgElement = containerRef.current.querySelector("img"); if (imgElement) { @@ -125,9 +124,7 @@ function SaveAsPngButton({ background: BackgroundOption; imageMetadata: { width: number; height: number; name: string }; }) { - const [canvasRef, setCanvasRef] = React.useState( - null, - ); + const [canvasRef, setCanvasRef] = useState(null); const { convertToPng, canvasProps } = useImageConverter({ canvas: canvasRef, imageContent, diff --git a/src/app/(tools)/square-image/square-tool.tsx b/src/app/(tools)/square-image/square-tool.tsx index d5a43a3..8264309 100644 --- a/src/app/(tools)/square-image/square-tool.tsx +++ b/src/app/(tools)/square-image/square-tool.tsx @@ -137,7 +137,7 @@ function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { ); } -export const SquareTool: React.FC = () => { +export function SquareTool() { const fileUploaderProps = useFileUploader(); return ( @@ -149,4 +149,4 @@ export const SquareTool: React.FC = () => { ); -}; +} diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index a60d659..137d778 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -79,7 +79,7 @@ interface SVGRendererProps { svgContent: string; } -const SVGRenderer: React.FC = ({ svgContent }) => { +function SVGRenderer({ svgContent }: SVGRendererProps) { const containerRef = useRef(null); useEffect(() => { @@ -94,7 +94,7 @@ const SVGRenderer: React.FC = ({ svgContent }) => { }, [svgContent]); return
; -}; +} function SaveAsPngButton({ svgContent, diff --git a/src/components/shared/file-dropzone.tsx b/src/components/shared/file-dropzone.tsx index 021c17f..fc0ad70 100644 --- a/src/components/shared/file-dropzone.tsx +++ b/src/components/shared/file-dropzone.tsx @@ -7,12 +7,12 @@ interface FileDropzoneProps { setCurrentFile: (file: File) => void; } -export const FileDropzone: React.FC = ({ +export function FileDropzone({ children, acceptedFileTypes, dropText, setCurrentFile, -}) => { +}: FileDropzoneProps) { const [isDragging, setIsDragging] = useState(false); const dragCounter = useRef(0); @@ -93,4 +93,4 @@ export const FileDropzone: React.FC = ({ {children}
); -}; +} diff --git a/src/components/shared/upload-box.tsx b/src/components/shared/upload-box.tsx index 32f7c90..4972a4a 100644 --- a/src/components/shared/upload-box.tsx +++ b/src/components/shared/upload-box.tsx @@ -1,5 +1,3 @@ -"use client"; - import React from "react"; interface UploadBoxProps { @@ -10,13 +8,13 @@ interface UploadBoxProps { onChange: (event: React.ChangeEvent) => void; } -export const UploadBox: React.FC = ({ +export function UploadBox({ title, subtitle, description, accept, onChange, -}) => { +}: UploadBoxProps) { return (
@@ -55,4 +53,4 @@ export const UploadBox: React.FC = ({
); -}; +}