diff --git a/src/app/(tools)/rounded-border/rounded-tool.tsx b/src/app/(tools)/rounded-border/rounded-tool.tsx index a5dbf34..f15a2d1 100644 --- a/src/app/(tools)/rounded-border/rounded-tool.tsx +++ b/src/app/(tools)/rounded-border/rounded-tool.tsx @@ -1,6 +1,6 @@ "use client"; import { usePlausible } from "next-plausible"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, 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"; @@ -17,36 +17,51 @@ type BackgroundOption = "white" | "black" | "transparent"; function useImageConverter(props: { canvas: HTMLCanvasElement | null; - imageContent: string; + imageBlobUrl: string; radius: Radius; background: BackgroundOption; fileName?: string; imageMetadata: { width: number; height: number; name: string }; }) { - const { width, height } = useMemo(() => { - return { - width: props.imageMetadata.width, - height: props.imageMetadata.height, - }; - }, [props.imageMetadata]); + const { width, height } = props.imageMetadata; + + // Store blob URL to clean it up later + const [usedCanvasBlobUrl, setUsedCanvasBlobUrl] = useState( + null, + ); + useEffect( + () => () => { + if (usedCanvasBlobUrl) URL.revokeObjectURL(usedCanvasBlobUrl); + }, + [usedCanvasBlobUrl], + ); const convertToPng = async () => { const ctx = props.canvas?.getContext("2d"); if (!ctx) throw new Error("Failed to get canvas context"); - const saveImage = () => { - if (props.canvas) { - const dataURL = props.canvas.toDataURL("image/png"); - const link = document.createElement("a"); - link.href = dataURL; - const imageFileName = props.imageMetadata.name ?? "image_converted"; - link.download = `${imageFileName.replace(/\..+$/, "")}.png`; - link.click(); - } + const saveImage = async () => { + const canvasBlob = await new Promise((resolve, reject) => { + if (props.canvas) { + props.canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else reject(new Error("Canvas blob could not be created")); + }); + } else reject(new Error("Canvas not present")); + }); + const canvasBlobUrl = URL.createObjectURL(canvasBlob); + setUsedCanvasBlobUrl(canvasBlobUrl); + const link = document.createElement("a"); + link.href = canvasBlobUrl; + const imageFileName = props.imageMetadata.name ?? "image_converted"; + link.download = `${imageFileName.replace(/\..+$/, "")}.png`; + link.click(); }; const img = new Image(); img.onload = () => { + ctx.save(); ctx.clearRect(0, 0, width, height); ctx.fillStyle = props.background; ctx.fillRect(0, 0, width, height); @@ -63,10 +78,11 @@ function useImageConverter(props: { ctx.closePath(); ctx.clip(); ctx.drawImage(img, 0, 0, width, height); - saveImage(); + ctx.restore(); + void saveImage(); }; - img.src = props.imageContent; + img.src = props.imageBlobUrl; }; return { @@ -76,27 +92,18 @@ function useImageConverter(props: { } interface ImageRendererProps { - imageContent: string; + imageBlobUrl: string; radius: Radius; background: BackgroundOption; } const ImageRenderer = ({ - imageContent, + imageBlobUrl, radius, background, }: ImageRendererProps) => { const containerRef = useRef(null); - useEffect(() => { - if (containerRef.current) { - const imgElement = containerRef.current.querySelector("img"); - if (imgElement) { - imgElement.style.borderRadius = `${radius}px`; - } - } - }, [imageContent, radius]); - return (
Preview
); }; function SaveAsPngButton({ - imageContent, + imageBlobUrl, radius, background, imageMetadata, }: { - imageContent: string; + imageBlobUrl: string; radius: Radius; background: BackgroundOption; imageMetadata: { width: number; height: number; name: string }; @@ -127,7 +139,7 @@ function SaveAsPngButton({ const [canvasRef, setCanvasRef] = useState(null); const { convertToPng, canvasProps } = useImageConverter({ canvas: canvasRef, - imageContent, + imageBlobUrl, radius, background, imageMetadata, @@ -152,7 +164,7 @@ function SaveAsPngButton({ } function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { - const { imageContent, imageMetadata, handleFileUploadEvent, cancel } = + const { imageBlobUrl, imageMetadata, handleFileUploadEvent, cancel } = props.fileUploaderProps; const [radius, setRadius] = useLocalStorage("roundedTool_radius", 2); const [isCustomRadius, setIsCustomRadius] = useState(false); @@ -170,7 +182,7 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { } }; - if (!imageMetadata) { + if (!imageMetadata || !imageBlobUrl) { return (
@@ -229,7 +241,7 @@ function RoundedToolCore(props: { fileUploaderProps: FileUploaderResult }) { Cancel ("squareTool_backgroundColor", "white"); - const [squareImageContent, setSquareImageContent] = useState( + // Store blob URL to clean it up later + const [usedCanvasBlobUrl, setUsedCanvasBlobUrl] = useState( null, ); + useEffect( + () => () => { + if (usedCanvasBlobUrl) URL.revokeObjectURL(usedCanvasBlobUrl); + }, + [usedCanvasBlobUrl], + ); useEffect(() => { - if (imageContent && imageMetadata) { - const canvas = document.createElement("canvas"); + if (imageBlobUrl && imageMetadata) { const size = Math.max(imageMetadata.width, imageMetadata.height); - canvas.width = size; - canvas.height = size; + const canvas = new OffscreenCanvas(size, size); const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -39,20 +44,22 @@ function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { // Load and center the image const img = new Image(); - img.onload = () => { + img.onload = async () => { const x = (size - imageMetadata.width) / 2; const y = (size - imageMetadata.height) / 2; ctx.drawImage(img, x, y); - setSquareImageContent(canvas.toDataURL("image/png")); + const canvasBlob = await canvas.convertToBlob({ type: "image/png" }); + const canvasBlobUrl = URL.createObjectURL(canvasBlob); + setUsedCanvasBlobUrl(canvasBlobUrl); }; - img.src = imageContent; + img.src = imageBlobUrl; } - }, [imageContent, imageMetadata, backgroundColor]); + }, [imageBlobUrl, imageMetadata, backgroundColor]); const handleSaveImage = () => { - if (squareImageContent && imageMetadata) { + if (usedCanvasBlobUrl && imageMetadata) { const link = document.createElement("a"); - link.href = squareImageContent; + link.href = usedCanvasBlobUrl; const originalFileName = imageMetadata.name; const fileNameWithoutExtension = originalFileName.substring(0, originalFileName.lastIndexOf(".")) || @@ -81,8 +88,8 @@ function SquareToolCore(props: { fileUploaderProps: FileUploaderResult }) { return (
- {squareImageContent && ( - Preview + {usedCanvasBlobUrl && ( + Preview )}

{imageMetadata.name} diff --git a/src/app/(tools)/svg-to-png/svg-tool.tsx b/src/app/(tools)/svg-to-png/svg-tool.tsx index 137d778..29bf65a 100644 --- a/src/app/(tools)/svg-to-png/svg-tool.tsx +++ b/src/app/(tools)/svg-to-png/svg-tool.tsx @@ -1,6 +1,6 @@ "use client"; import { usePlausible } from "next-plausible"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useLocalStorage } from "@/hooks/use-local-storage"; import { UploadBox } from "@/components/shared/upload-box"; @@ -8,65 +8,67 @@ import { SVGScaleSelector } from "@/components/svg-scale-selector"; 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 scaledWidth = width * scale; - const scaledHeight = height * scale; - - svgElement.setAttribute("width", scaledWidth.toString()); - svgElement.setAttribute("height", scaledHeight.toString()); - - return new XMLSerializer().serializeToString(svgDoc); -} - function useSvgConverter(props: { canvas: HTMLCanvasElement | null; - svgContent: string; + imageBlobUrl: string; scale: number; - fileName?: string; imageMetadata: { width: number; height: number; name: string }; }) { - const { width, height, scaledSvg } = useMemo(() => { - const scaledSvg = scaleSvg(props.svgContent, props.scale); + const width = props.imageMetadata.width * props.scale, + height = props.imageMetadata.height * props.scale; - return { - width: props.imageMetadata.width * props.scale, - height: props.imageMetadata.height * props.scale, - scaledSvg, - }; - }, [props.svgContent, props.scale, props.imageMetadata]); + // Store blob URL to clean it up later + const [usedCanvasBlobUrl, setUsedCanvasBlobUrl] = useState( + null, + ); + useEffect( + () => () => { + if (usedCanvasBlobUrl) URL.revokeObjectURL(usedCanvasBlobUrl); + }, + [usedCanvasBlobUrl], + ); const convertToPng = async () => { const ctx = props.canvas?.getContext("2d"); if (!ctx) throw new Error("Failed to get canvas context"); // Trigger a "save image" of the resulting canvas content - const saveImage = () => { - if (props.canvas) { - const dataURL = props.canvas.toDataURL("image/png"); - const link = document.createElement("a"); - link.href = dataURL; - const svgFileName = props.imageMetadata.name ?? "svg_converted"; - - // Remove the .svg extension - link.download = `${svgFileName.replace(".svg", "")}-${props.scale}x.png`; - link.click(); - } + const saveImage = async () => { + const canvasBlob = await new Promise((resolve, reject) => { + if (props.canvas) { + props.canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else reject(new Error("Canvas blob could not be created")); + }); + } else reject(new Error("Canvas not present")); + }); + const canvasBlobUrl = URL.createObjectURL(canvasBlob); + setUsedCanvasBlobUrl(canvasBlobUrl); + + const link = document.createElement("a"); + link.href = canvasBlobUrl; + const svgFileName = props.imageMetadata.name; + + // Remove the .svg extension + link.download = `${svgFileName.replace(".svg", "")}-${props.scale}x.png`; + link.click(); }; const img = new Image(); // Call saveImage after the image has been drawn img.onload = () => { - ctx.drawImage(img, 0, 0); - saveImage(); + ctx.clearRect( + 0, + 0, + props.canvas?.width ?? width, + props.canvas?.height ?? height, + ); + ctx.drawImage(img, 0, 0, width, height); + void saveImage(); }; - img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(scaledSvg)}`; + img.src = props.imageBlobUrl; }; return { @@ -75,40 +77,19 @@ function useSvgConverter(props: { }; } -interface SVGRendererProps { - svgContent: string; -} - -function SVGRenderer({ svgContent }: SVGRendererProps) { - const containerRef = useRef(null); - - 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]); - - return

; -} - function SaveAsPngButton({ - svgContent, + imageBlobUrl, scale, imageMetadata, }: { - svgContent: string; + imageBlobUrl: string; scale: number; imageMetadata: { width: number; height: number; name: string }; }) { const [canvasRef, setCanvasRef] = useState(null); const { convertToPng, canvasProps } = useSvgConverter({ canvas: canvasRef, - svgContent, + imageBlobUrl, scale, imageMetadata, }); @@ -138,7 +119,7 @@ import { import { FileDropzone } from "@/components/shared/file-dropzone"; function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) { - const { rawContent, imageMetadata, handleFileUploadEvent, cancel } = + const { imageMetadata, imageBlobUrl, handleFileUploadEvent, cancel } = props.fileUploaderProps; const [scale, setScale] = useLocalStorage("svgTool_scale", 1); @@ -164,7 +145,13 @@ function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) {
{/* Preview Section */}
- +
+ +

{imageMetadata.name}

@@ -207,7 +194,7 @@ function SVGToolCore(props: { fileUploaderProps: FileUploaderResult }) { Cancel diff --git a/src/hooks/use-async-memo.ts b/src/hooks/use-async-memo.ts new file mode 100644 index 0000000..3f8623c --- /dev/null +++ b/src/hooks/use-async-memo.ts @@ -0,0 +1,27 @@ +import { type DependencyList, useCallback, useEffect, useState } from "react"; + +export const useAsyncMemo = ( + factory: () => Promise, + deps: DependencyList, + loadingState: L, + errorState?: (error: unknown) => L, +): T | L => { + const [result, setResult] = useState(loadingState); + const defaultErrorState = useCallback(() => loadingState, [loadingState]); + if (!errorState) errorState = defaultErrorState; + + useEffect(() => { + setResult(loadingState); + + factory().then( + (value) => { + setResult(value); + }, + (error) => { + setResult(errorState(error)); + }, + ); + }, [factory, loadingState, errorState, deps]); + + return result; +}; diff --git a/src/hooks/use-file-uploader.ts b/src/hooks/use-file-uploader.ts index 74e2372..b712821 100644 --- a/src/hooks/use-file-uploader.ts +++ b/src/hooks/use-file-uploader.ts @@ -1,62 +1,36 @@ -import { useCallback } from "react"; -import { type ChangeEvent, useState } from "react"; +import { useCallback, type ChangeEvent, useState, useEffect } from "react"; import { useClipboardPaste } from "./use-clipboard-paste"; +import { useAsyncMemo } from "./use-async-memo"; -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, +const getImageDimensions = ( + blobUrl: string, ): Promise<{ - content: string; - metadata: { width: number; height: number; name: string }; + width: number; + height: number; }> => { return new Promise((resolve) => { const img = new Image(); img.onload = () => { resolve({ - content, - metadata: { - width: img.width, - height: img.height, - name: fileName, - }, + width: img.naturalWidth, + height: img.naturalHeight, }); }; - img.src = content; + img.src = blobUrl; }); }; 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; + /** The image content as a Blob object */ + imageBlob: Blob | null; + /** That Blob's object URL (not the actual content, just a reference) */ + imageBlobUrl: string | null; /** Handler for file input change events */ handleFileUpload: (file: File) => void; handleFileUploadEvent: (event: ChangeEvent) => void; @@ -67,60 +41,46 @@ export type FileUploaderResult = { /** * 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) + * - imageBlobUrl: Use this as the src for an img tag + * - imageBlob: The raw file content as a Blob * - 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 [imageBlob, setImageBlob] = useState(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); - } - }; + const { imageBlobUrl, imageMetadata } = useAsyncMemo( + async () => { + if (!imageBlob) return { imageBlobUrl: null, imageMetadata: null }; + const imageBlobUrl = URL.createObjectURL(imageBlob); + const dimensions = await getImageDimensions(imageBlobUrl); + return { + imageBlobUrl, + imageMetadata: { ...dimensions, name: imageBlob.name }, + }; + }, + [imageBlob], + { imageBlobUrl: null, imageMetadata: null }, + ); - if (file.type === "image/svg+xml") { - reader.readAsText(file); - } else { - reader.readAsDataURL(file); - } - }; + // Clean up blob URLs when switching blobs + useEffect( + () => () => { + if (imageBlobUrl) URL.revokeObjectURL(imageBlobUrl); + }, + [imageBlobUrl], + ); const handleFileUploadEvent = (event: ChangeEvent) => { const file = event.target.files?.[0]; if (file) { - processFile(file); + setImageBlob(file); } }; const handleFilePaste = useCallback((file: File) => { - processFile(file); + setImageBlob(file); }, []); useClipboardPaste({ @@ -129,15 +89,14 @@ export const useFileUploader = (): FileUploaderResult => { }); const cancel = () => { - setImageContent(""); - setImageMetadata(null); + setImageBlob(null); }; return { - imageContent, - rawContent, imageMetadata, - handleFileUpload: processFile, + imageBlob, + imageBlobUrl, + handleFileUpload: setImageBlob, handleFileUploadEvent, cancel, };