Skip to content
File

Blob: src/App.tsx

typescript308 lines
1import { useCallback, useEffect, useRef, useState } from "react";
2import { cn } from "./lib/cn";
3import { useEditor } from "./hooks/useEditor";
4import type { ExportResult } from "./types";
5import { buildFilename, isImageFile, loadImage, triggerDownload, type LoadedImage } from "./lib/image";
6import { exportImage } from "./lib/exportClient";
7import { TopBar } from "./components/TopBar";
8import { Dropzone } from "./components/Dropzone";
9import { EditorCanvas } from "./components/EditorCanvas";
10import { TemplatePicker } from "./components/TemplatePicker";
11import { Footer } from "./components/footer";
12import { ToastContainer, toast } from "./components/toast";
13import { DownloadIcon, SlidersIcon } from "./components/icons";
14import { AdjustPanel, CropPanel, EffectsPanel, ExportPanel, TransformPanel } from "./components/panels";
15 
16export default function App() {
17 const [state, dispatch] = useEditor();
18 const [image, setImage] = useState<LoadedImage | null>(null);
19 const [loading, setLoading] = useState(false);
20 const [loadError, setLoadError] = useState<string | null>(null);
21 
22 const [exporting, setExporting] = useState(false);
23 const [result, setResult] = useState<ExportResult | null>(null);
24 const [resultUrl, setResultUrl] = useState<string | null>(null);
25 const [exportError, setExportError] = useState<string | null>(null);
26 
27 // Which side drawer is open on small screens ('none' on desktop — trays are inline).
28 const [tray, setTray] = useState<"none" | "tools" | "export">("none");
29 
30 // Track the live object URL outside the render cycle so revocation is
31 // StrictMode-safe (we only revoke the previous image when a new one loads).
32 const urlRef = useRef<string | null>(null);
33 
34 // The export-preview object URL is owned here so it can be revoked when the
35 // result is replaced or cleared (object URLs are an external resource).
36 const resultUrlRef = useRef<string | null>(null);
37 const clearPreview = useCallback(() => {
38 if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current);
39 resultUrlRef.current = null;
40 setResultUrl(null);
41 }, []);
42 useEffect(
43 () => () => {
44 if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current);
45 },
46 [],
47 );
48 
49 const handleFile = useCallback(
50 async (file: File) => {
51 setLoading(true);
52 setLoadError(null);
53 try {
54 if (!isImageFile(file)) {
55 throw new Error("That file doesn’t look like a supported image.");
56 }
57 const loaded = await loadImage(file);
58 if (urlRef.current) URL.revokeObjectURL(urlRef.current);
59 urlRef.current = loaded.url;
60 setImage(loaded);
61 setResult(null);
62 clearPreview();
63 setExportError(null);
64 dispatch({ type: "resetEdits" });
65 } catch (err) {
66 setLoadError(err instanceof Error ? err.message : "Could not load that image.");
67 } finally {
68 setLoading(false);
69 }
70 },
71 [dispatch, clearPreview],
72 );
73 
74 const handleClose = useCallback(() => {
75 if (urlRef.current) URL.revokeObjectURL(urlRef.current);
76 urlRef.current = null;
77 setImage(null);
78 setResult(null);
79 clearPreview();
80 setExportError(null);
81 setLoadError(null);
82 dispatch({ type: "resetEdits" });
83 }, [dispatch, clearPreview]);
84 
85 async function handleExport() {
86 if (!image || !state.croppedAreaPixels) return;
87 setExporting(true);
88 setExportError(null);
89 try {
90 const bytes = await image.file.arrayBuffer();
91 const res = await exportImage({
92 bytes,
93 mime: image.file.type,
94 crop: state.croppedAreaPixels,
95 rotation: state.rotation,
96 flipH: state.flipH,
97 flipV: state.flipV,
98 adjustments: state.adjustments,
99 output: state.output,
100 maxEdge: state.maxEdge,
101 format: state.format,
102 quality: state.quality,
103 });
104 setResult(res);
105 if (resultUrlRef.current) URL.revokeObjectURL(resultUrlRef.current);
106 const previewUrl = URL.createObjectURL(res.blob);
107 resultUrlRef.current = previewUrl;
108 setResultUrl(previewUrl);
109 triggerDownload(res.blob, buildFilename(image.file.name, state.templateId, state.format));
110 toast.success("Image exported");
111 } catch (err) {
112 setExportError(err instanceof Error ? err.message : "Export failed.");
113 } finally {
114 setExporting(false);
115 }
116 }
117 
118 // Keep a stable handle to the latest export fn for the keyboard shortcut,
119 // updated in an effect so the keydown listener never needs to re-subscribe.
120 const exportRef = useRef(handleExport);
121 useEffect(() => {
122 exportRef.current = handleExport;
123 });
124 
125 // Paste an image from the clipboard at any time.
126 useEffect(() => {
127 const onPaste = (e: ClipboardEvent) => {
128 const items = e.clipboardData?.items;
129 if (!items) return;
130 for (let i = 0; i < items.length; i++) {
131 const item = items[i];
132 if (item.type.startsWith("image/")) {
133 const file = item.getAsFile();
134 if (file) {
135 void handleFile(file);
136 break;
137 }
138 }
139 }
140 };
141 window.addEventListener("paste", onPaste);
142 return () => window.removeEventListener("paste", onPaste);
143 }, [handleFile]);
144 
145 // While editing, dropping a file anywhere replaces the current image
146 // (and we stop the browser from navigating away to the dropped file).
147 useEffect(() => {
148 if (!image) return;
149 const onDragOver = (e: DragEvent) => e.preventDefault();
150 const onDrop = (e: DragEvent) => {
151 e.preventDefault();
152 const file = e.dataTransfer?.files?.[0];
153 if (file) void handleFile(file);
154 };
155 window.addEventListener("dragover", onDragOver);
156 window.addEventListener("drop", onDrop);
157 return () => {
158 window.removeEventListener("dragover", onDragOver);
159 window.removeEventListener("drop", onDrop);
160 };
161 }, [image, handleFile]);
162 
163 // Keyboard shortcuts: [ ] rotate · h/v flip · 0 reset view · Cmd/Ctrl+S export.
164 useEffect(() => {
165 if (!image) return;
166 const onKey = (e: KeyboardEvent) => {
167 const t = e.target as HTMLElement | null;
168 if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
169 if (e.metaKey || e.ctrlKey) {
170 if (e.key.toLowerCase() === "s") {
171 e.preventDefault();
172 void exportRef.current();
173 }
174 return;
175 }
176 switch (e.key) {
177 case "[":
178 dispatch({ type: "rotateBy", delta: -90 });
179 break;
180 case "]":
181 dispatch({ type: "rotateBy", delta: 90 });
182 break;
183 case "h":
184 case "H":
185 dispatch({ type: "toggleFlipH" });
186 break;
187 case "v":
188 case "V":
189 dispatch({ type: "toggleFlipV" });
190 break;
191 case "0":
192 dispatch({ type: "setZoom", zoom: 1 });
193 dispatch({ type: "setCrop", crop: { x: 0, y: 0 } });
194 break;
195 default:
196 return;
197 }
198 e.preventDefault();
199 };
200 window.addEventListener("keydown", onKey);
201 return () => window.removeEventListener("keydown", onKey);
202 }, [image, dispatch]);
203 
204 return (
205 <div className="flex h-screen flex-col overflow-hidden bg-canvas text-zinc-100">
206 <a
207 href="#main-content"
208 className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[200] focus:rounded-lg focus:bg-zinc-800 focus:px-4 focus:py-2 focus:text-accent-400 focus:ring-2 focus:ring-accent-500/50"
209 >
210 Skip to content
211 </a>
212 <TopBar
213 hasImage={!!image}
214 onFile={handleFile}
215 onResetEdits={() => dispatch({ type: "resetEdits" })}
216 onClose={handleClose}
217 />
218
219 {image ? (
220 <main id="main-content" tabIndex={-1} className="relative flex min-h-0 flex-1 outline-none">
221 <aside
222 className={cn(
223 "flex w-72 shrink-0 flex-col overflow-y-auto border-r border-zinc-800/60 bg-zinc-900",
224 "transition-transform duration-200 lg:translate-x-0",
225 "max-lg:fixed max-lg:bottom-0 max-lg:left-0 max-lg:top-14 max-lg:z-30 max-lg:w-[min(20rem,85vw)] max-lg:shadow-2xl max-lg:shadow-black/50",
226 tray === "tools" ? "max-lg:translate-x-0" : "max-lg:-translate-x-full",
227 )}
228 >
229 <div className="border-b border-zinc-800/60 p-4">
230 <p className="mb-2 text-[0.7rem] font-semibold uppercase tracking-wider text-zinc-400">Template</p>
231 <TemplatePicker
232 currentId={state.templateId}
233 onSelect={(template) => dispatch({ type: "selectTemplate", template })}
234 />
235 </div>
236 <CropPanel state={state} dispatch={dispatch} />
237 <TransformPanel state={state} dispatch={dispatch} />
238 <EffectsPanel state={state} dispatch={dispatch} />
239 <AdjustPanel state={state} dispatch={dispatch} />
240 </aside>
241
242 <section className="relative min-w-0 flex-1">
243 <EditorCanvas image={image} state={state} dispatch={dispatch} />
244 </section>
245
246 <aside
247 className={cn(
248 "flex w-72 shrink-0 flex-col overflow-y-auto border-l border-zinc-800/60 bg-zinc-900",
249 "transition-transform duration-200 lg:translate-x-0",
250 "max-lg:fixed max-lg:bottom-0 max-lg:right-0 max-lg:top-14 max-lg:z-30 max-lg:w-[min(20rem,85vw)] max-lg:shadow-2xl max-lg:shadow-black/50",
251 tray === "export" ? "max-lg:translate-x-0" : "max-lg:translate-x-full",
252 )}
253 >
254 <ExportPanel
255 state={state}
256 dispatch={dispatch}
257 image={image}
258 onExport={handleExport}
259 exporting={exporting}
260 result={result}
261 resultUrl={resultUrl}
262 error={exportError}
263 />
264 </aside>
265
266 {/* Mobile drawer backdrop */}
267 {tray !== "none" && (
268 <div
269 className="fixed inset-x-0 bottom-0 top-14 z-20 bg-black/50 lg:hidden"
270 onClick={() => setTray("none")}
271 aria-hidden="true"
272 />
273 )}
274
275 {/* Mobile tray toggles (hidden while a drawer is open; backdrop closes it) */}
276 {tray === "none" && (
277 <div className="fixed bottom-4 left-1/2 z-40 flex -translate-x-1/2 gap-1 rounded-full border border-zinc-800/60 bg-zinc-900/95 p-1 shadow-2xl shadow-black/40 backdrop-blur-sm lg:hidden">
278 <button
279 onClick={() => setTray("tools")}
280 aria-label="Toggle editing tools"
281 className="flex items-center gap-1.5 rounded-full px-4 py-2 text-xs font-medium text-zinc-100"
282 >
283 <SlidersIcon className="size-4" /> Edit
284 </button>
285 <button
286 onClick={() => setTray("export")}
287 aria-label="Toggle export panel"
288 className="flex items-center gap-1.5 rounded-full bg-accent-600 px-4 py-2 text-xs font-medium text-white"
289 >
290 <DownloadIcon className="size-4" /> Export
291 </button>
292 </div>
293 )}
294 </main>
295 ) : (
296 <>
297 <main id="main-content" tabIndex={-1} className="min-h-0 flex-1 outline-none">
298 <Dropzone onFile={handleFile} loading={loading} error={loadError} />
299 </main>
300 <Footer />
301 </>
302 )}
303 
304 <ToastContainer />
305 </div>
306 );
307}