import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "./lib/cn"; import { useEditor } from "./hooks/useEditor"; import type { ExportResult } from "./types"; import { buildFilename, isImageFile, loadImage, triggerDownload, type LoadedImage } from "./lib/image"; import { exportImage } from "./lib/exportClient"; import { TopBar } from "./components/TopBar"; import { Dropzone } from "./components/Dropzone"; import { EditorCanvas } from "./components/EditorCanvas"; import { TemplatePicker } from "./components/TemplatePicker"; import { Footer } from "./components/footer"; import { ToastContainer, toast } from "./components/toast"; import { DownloadIcon, SlidersIcon } from "./components/icons"; import { AdjustPanel, CropPanel, EffectsPanel, ExportPanel, TransformPanel } from "./components/panels"; export default function App() { const [state, dispatch] = useEditor(); const [image, setImage] = useState(null); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [exporting, setExporting] = useState(false); const [result, setResult] = useState(null); const [resultUrl, setResultUrl] = useState(null); const [exportError, setExportError] = useState(null); // Which side drawer is open on small screens ('none' on desktop — trays are inline). const [tray, setTray] = useState<"none" | "tools" | "export">("none"); // Track the live object URL outside the render cycle so revocation is // StrictMode-safe (we only revoke the previous image when a new one loads). const urlRef = useRef(null); // The export-preview object URL is owned here so it can be revoked when the // result is replaced or cleared (object URLs are an external resource). const resultUrlRef = useRef(null); const clearPreview = useCallback(() => { if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); resultUrlRef.current = null; setResultUrl(null); }, []); useEffect( () => () => { if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); }, [], ); const handleFile = useCallback( async (file: File) => { setLoading(true); setLoadError(null); try { if (!isImageFile(file)) { throw new Error("That file doesn’t look like a supported image."); } const loaded = await loadImage(file); if (urlRef.current) URL.revokeObjectURL(urlRef.current); urlRef.current = loaded.url; setImage(loaded); setResult(null); clearPreview(); setExportError(null); dispatch({ type: "resetEdits" }); } catch (err) { setLoadError(err instanceof Error ? err.message : "Could not load that image."); } finally { setLoading(false); } }, [dispatch, clearPreview], ); const handleClose = useCallback(() => { if (urlRef.current) URL.revokeObjectURL(urlRef.current); urlRef.current = null; setImage(null); setResult(null); clearPreview(); setExportError(null); setLoadError(null); dispatch({ type: "resetEdits" }); }, [dispatch, clearPreview]); async function handleExport() { if (!image || !state.croppedAreaPixels) return; setExporting(true); setExportError(null); try { const bytes = await image.file.arrayBuffer(); const res = await exportImage({ bytes, mime: image.file.type, crop: state.croppedAreaPixels, rotation: state.rotation, flipH: state.flipH, flipV: state.flipV, adjustments: state.adjustments, output: state.output, maxEdge: state.maxEdge, format: state.format, quality: state.quality, }); setResult(res); if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current); const previewUrl = URL.createObjectURL(res.blob); resultUrlRef.current = previewUrl; setResultUrl(previewUrl); triggerDownload(res.blob, buildFilename(image.file.name, state.templateId, state.format)); toast.success("Image exported"); } catch (err) { setExportError(err instanceof Error ? err.message : "Export failed."); } finally { setExporting(false); } } // Keep a stable handle to the latest export fn for the keyboard shortcut, // updated in an effect so the keydown listener never needs to re-subscribe. const exportRef = useRef(handleExport); useEffect(() => { exportRef.current = handleExport; }); // Paste an image from the clipboard at any time. useEffect(() => { const onPaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file) { void handleFile(file); break; } } } }; window.addEventListener("paste", onPaste); return () => window.removeEventListener("paste", onPaste); }, [handleFile]); // While editing, dropping a file anywhere replaces the current image // (and we stop the browser from navigating away to the dropped file). useEffect(() => { if (!image) return; const onDragOver = (e: DragEvent) => e.preventDefault(); const onDrop = (e: DragEvent) => { e.preventDefault(); const file = e.dataTransfer?.files?.[0]; if (file) void handleFile(file); }; window.addEventListener("dragover", onDragOver); window.addEventListener("drop", onDrop); return () => { window.removeEventListener("dragover", onDragOver); window.removeEventListener("drop", onDrop); }; }, [image, handleFile]); // Keyboard shortcuts: [ ] rotate · h/v flip · 0 reset view · Cmd/Ctrl+S export. useEffect(() => { if (!image) return; const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement | null; if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; if (e.metaKey || e.ctrlKey) { if (e.key.toLowerCase() === "s") { e.preventDefault(); void exportRef.current(); } return; } switch (e.key) { case "[": dispatch({ type: "rotateBy", delta: -90 }); break; case "]": dispatch({ type: "rotateBy", delta: 90 }); break; case "h": case "H": dispatch({ type: "toggleFlipH" }); break; case "v": case "V": dispatch({ type: "toggleFlipV" }); break; case "0": dispatch({ type: "setZoom", zoom: 1 }); dispatch({ type: "setCrop", crop: { x: 0, y: 0 } }); break; default: return; } e.preventDefault(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [image, dispatch]); return (
Skip to content dispatch({ type: "resetEdits" })} onClose={handleClose} /> {image ? (
{/* Mobile drawer backdrop */} {tray !== "none" && (
setTray("none")} aria-hidden="true" /> )} {/* Mobile tray toggles (hidden while a drawer is open; backdrop closes it) */} {tray === "none" && (
)}
) : ( <>
); }