Skip to content
File

Blob: src/components/panels.tsx

typescript418 lines
1import type { Dispatch } from "react";
2import { cn } from "../lib/cn";
3import type { EditorAction, EditorState } from "../hooks/useEditor";
4import type { Adjustments, ExportFormat, ExportResult } from "../types";
5import { NEUTRAL_ADJUSTMENTS } from "../types";
6import type { LoadedImage } from "../lib/image";
7import { buildFilename, formatBytes, megapixels, triggerDownload } from "../lib/image";
8import { Button, Section, Segmented } from "./ui";
9import { Slider } from "./Slider";
10import { toast } from "./toast";
11import {
12 ContrastIcon,
13 CopyIcon,
14 CropIcon,
15 DownloadIcon,
16 DropletIcon,
17 FlipHorizontalIcon,
18 FlipVerticalIcon,
19 ResizeIcon,
20 RotateLeftIcon,
21 RotateRightIcon,
22 SlidersIcon,
23 SpinnerIcon,
24 SunIcon,
25 WandIcon,
26} from "./icons";
27 
28const EFFECTS: { label: string; value: Adjustments }[] = [
29 { label: "None", value: NEUTRAL_ADJUSTMENTS },
30 { label: "Vivid", value: { ...NEUTRAL_ADJUSTMENTS, saturate: 1.4, contrast: 1.1 } },
31 { label: "Fade", value: { ...NEUTRAL_ADJUSTMENTS, contrast: 0.85, brightness: 1.08, saturate: 0.9 } },
32 { label: "B&W", value: { ...NEUTRAL_ADJUSTMENTS, grayscale: 1, contrast: 1.05 } },
33 { label: "Noir", value: { ...NEUTRAL_ADJUSTMENTS, grayscale: 1, contrast: 1.3, brightness: 0.95 } },
34 { label: "Sepia", value: { ...NEUTRAL_ADJUSTMENTS, sepia: 0.75, contrast: 1.05, brightness: 1.02 } },
35];
36 
37function sameAdjustments(a: Adjustments, b: Adjustments): boolean {
38 return (
39 a.brightness === b.brightness &&
40 a.contrast === b.contrast &&
41 a.saturate === b.saturate &&
42 a.grayscale === b.grayscale &&
43 a.sepia === b.sepia &&
44 a.hueRotate === b.hueRotate
45 );
46}
47 
48interface PanelProps {
49 state: EditorState;
50 dispatch: Dispatch<EditorAction>;
51}
52 
53const ASPECTS: { label: string; value: number | null }[] = [
54 { label: "Original", value: null },
55 { label: "1:1", value: 1 },
56 { label: "4:3", value: 4 / 3 },
57 { label: "3:2", value: 3 / 2 },
58 { label: "16:9", value: 16 / 9 },
59 { label: "9:16", value: 9 / 16 },
60];
61 
62function aspectActive(current: number | null, value: number | null): boolean {
63 if (value === null) return current === null;
64 if (current === null) return false;
65 return Math.abs(current - value) < 1e-3;
66}
67 
68export function CropPanel({ state, dispatch }: PanelProps) {
69 return (
70 <Section title="Crop & frame" icon={<CropIcon className="size-3.5" />}>
71 <div className="grid grid-cols-3 gap-1.5">
72 {ASPECTS.map((a) => (
73 <Button
74 key={a.label}
75 size="sm"
76 active={aspectActive(state.aspect, a.value)}
77 onClick={() => dispatch({ type: "setAspect", aspect: a.value })}
78 >
79 {a.label}
80 </Button>
81 ))}
82 </div>
83 <div className="mt-4">
84 <Slider
85 label="Zoom"
86 value={state.zoom}
87 min={1}
88 max={8}
89 step={0.01}
90 resetTo={1}
91 display={(v) => `${v.toFixed(2)}×`}
92 onChange={(v) => dispatch({ type: "setZoom", zoom: v })}
93 />
94 </div>
95 </Section>
96 );
97}
98 
99export function TransformPanel({ state, dispatch }: PanelProps) {
100 return (
101 <Section title="Transform" icon={<RotateRightIcon className="size-3.5" />}>
102 <div className="mb-4 grid grid-cols-2 gap-1.5">
103 <Button size="sm" onClick={() => dispatch({ type: "rotateBy", delta: -90 })}>
104 <RotateLeftIcon className="size-4" /> -90°
105 </Button>
106 <Button size="sm" onClick={() => dispatch({ type: "rotateBy", delta: 90 })}>
107 <RotateRightIcon className="size-4" /> +90°
108 </Button>
109 <Button size="sm" active={state.flipH} onClick={() => dispatch({ type: "toggleFlipH" })}>
110 <FlipHorizontalIcon className="size-4" /> Flip H
111 </Button>
112 <Button size="sm" active={state.flipV} onClick={() => dispatch({ type: "toggleFlipV" })}>
113 <FlipVerticalIcon className="size-4" /> Flip V
114 </Button>
115 </div>
116 <Slider
117 label="Rotation"
118 value={state.rotation}
119 min={0}
120 max={360}
121 step={1}
122 resetTo={0}
123 display={(v) => `${Math.round(v)}°`}
124 onChange={(v) => dispatch({ type: "setRotation", rotation: v })}
125 />
126 </Section>
127 );
128}
129 
130export function EffectsPanel({ state, dispatch }: PanelProps) {
131 const a = state.adjustments;
132 return (
133 <Section title="Effects" icon={<WandIcon className="size-3.5" />}>
134 <div className="grid grid-cols-3 gap-1.5">
135 {EFFECTS.map((e) => (
136 <Button
137 key={e.label}
138 size="sm"
139 active={sameAdjustments(a, e.value)}
140 onClick={() => dispatch({ type: "setAdjustments", adjustments: e.value })}
141 >
142 {e.label}
143 </Button>
144 ))}
145 </div>
146 </Section>
147 );
148}
149 
150export function AdjustPanel({ state, dispatch }: PanelProps) {
151 const a = state.adjustments;
152 const dirty = !sameAdjustments(a, NEUTRAL_ADJUSTMENTS);
153 const pct = (v: number) => `${Math.round(v * 100)}%`;
154 
155 return (
156 <Section
157 title="Adjust"
158 icon={<SlidersIcon className="size-3.5" />}
159 action={
160 dirty ? (
161 <button
162 onClick={() => dispatch({ type: "resetAdjustments" })}
163 className="text-xs text-accent-400 hover:underline"
164 >
165 Reset
166 </button>
167 ) : null
168 }
169 >
170 <Slider
171 label={
172 <span className="flex items-center gap-1.5">
173 <SunIcon className="size-3.5 text-zinc-500" /> Brightness
174 </span>
175 }
176 value={a.brightness}
177 min={0}
178 max={2}
179 step={0.01}
180 resetTo={1}
181 display={pct}
182 onChange={(v) => dispatch({ type: "setAdjustment", key: "brightness", value: v })}
183 />
184 <Slider
185 label={
186 <span className="flex items-center gap-1.5">
187 <ContrastIcon className="size-3.5 text-zinc-500" /> Contrast
188 </span>
189 }
190 value={a.contrast}
191 min={0}
192 max={2}
193 step={0.01}
194 resetTo={1}
195 display={pct}
196 onChange={(v) => dispatch({ type: "setAdjustment", key: "contrast", value: v })}
197 />
198 <Slider
199 label={
200 <span className="flex items-center gap-1.5">
201 <DropletIcon className="size-3.5 text-zinc-500" /> Saturation
202 </span>
203 }
204 value={a.saturate}
205 min={0}
206 max={2}
207 step={0.01}
208 resetTo={1}
209 display={pct}
210 onChange={(v) => dispatch({ type: "setAdjustment", key: "saturate", value: v })}
211 />
212 </Section>
213 );
214}
215 
216const FORMAT_OPTIONS: { value: ExportFormat; label: string }[] = [
217 { value: "jpeg", label: "JPEG" },
218 { value: "webp", label: "WebP" },
219 { value: "png", label: "PNG" },
220 { value: "avif", label: "AVIF" },
221];
222 
223interface ExportPanelProps extends PanelProps {
224 image: LoadedImage;
225 onExport: () => void;
226 exporting: boolean;
227 result: ExportResult | null;
228 resultUrl: string | null;
229 error: string | null;
230}
231 
232function InfoRow({ label, value }: { label: string; value: string }) {
233 return (
234 <div className="flex items-center justify-between text-xs">
235 <dt className="text-zinc-400">{label}</dt>
236 <dd className="font-mono tabular-nums text-zinc-100">{value}</dd>
237 </div>
238 );
239}
240 
241function applyMaxEdge(
242 dims: { width: number; height: number } | null,
243 maxEdge: number | null,
244): { width: number; height: number } | null {
245 if (!dims || !maxEdge) return dims;
246 const long = Math.max(dims.width, dims.height);
247 if (long <= maxEdge) return dims;
248 const scale = maxEdge / long;
249 return { width: Math.round(dims.width * scale), height: Math.round(dims.height * scale) };
250}
251 
252export function ExportPanel({
253 state,
254 dispatch,
255 image,
256 onExport,
257 exporting,
258 result,
259 resultUrl,
260 error,
261}: ExportPanelProps) {
262 const lossy = state.format !== "png";
263 const limitOn = state.maxEdge !== null;
264 
265 const baseDims =
266 state.output ??
267 (state.croppedAreaPixels
268 ? {
269 width: Math.round(state.croppedAreaPixels.width),
270 height: Math.round(state.croppedAreaPixels.height),
271 }
272 : null);
273 const outDims = applyMaxEdge(baseDims, state.maxEdge);
274 
275 function downloadAgain() {
276 if (!result) return;
277 triggerDownload(result.blob, buildFilename(image.file.name, state.templateId, state.format));
278 toast.success("Saved to downloads");
279 }
280 
281 async function copyToClipboard() {
282 if (!result) return;
283 try {
284 await navigator.clipboard.write([new ClipboardItem({ [result.blob.type]: result.blob })]);
285 toast.success("Copied to clipboard");
286 } catch {
287 toast.error("Clipboard unavailable");
288 }
289 }
290 
291 return (
292 <div className="flex h-full flex-col">
293 <Section title="Export" icon={<DownloadIcon className="size-3.5" />}>
294 <label className="mb-1.5 block text-xs text-zinc-400">Format</label>
295 <Segmented
296 ariaLabel="Export format"
297 value={state.format}
298 options={FORMAT_OPTIONS}
299 onChange={(f) => dispatch({ type: "setFormat", format: f })}
300 />
301 <div className="mt-4">
302 <Slider
303 label="Quality"
304 value={state.quality}
305 min={1}
306 max={100}
307 step={1}
308 disabled={!lossy}
309 resetTo={90}
310 display={(v) => (lossy ? `${v}` : "lossless")}
311 onChange={(v) => dispatch({ type: "setQuality", quality: v })}
312 />
313 </div>
314 </Section>
315
316 <Section title="Output">
317 <dl className="space-y-2">
318 <InfoRow label="Dimensions" value={outDims ? `${outDims.width} × ${outDims.height}` : "—"} />
319 <InfoRow label="Megapixels" value={outDims ? megapixels(outDims.width, outDims.height) : "—"} />
320 <InfoRow label="Source" value={`${image.width} × ${image.height}`} />
321 </dl>
322
323 <div className="mt-3 flex items-center justify-between">
324 <span className="flex items-center gap-1.5 text-xs text-zinc-400">
325 <ResizeIcon className="size-3.5" /> Limit long edge
326 </span>
327 <div className="flex items-center gap-2">
328 <input
329 type="number"
330 min={16}
331 disabled={!limitOn}
332 value={limitOn ? state.maxEdge! : ""}
333 placeholder="—"
334 onChange={(e) => {
335 const n = Math.round(Number(e.target.value));
336 dispatch({ type: "setMaxEdge", maxEdge: Number.isFinite(n) && n > 0 ? Math.max(16, n) : 16 });
337 }}
338 className="w-16 rounded-md border border-zinc-700/60 bg-zinc-800 px-2 py-1 text-right text-xs tabular-nums text-zinc-100 outline-none focus:border-accent-500/50 disabled:opacity-40"
339 />
340 <span className="text-xs text-zinc-500">px</span>
341 <button
342 role="switch"
343 aria-checked={limitOn}
344 aria-label="Limit long edge"
345 onClick={() => dispatch({ type: "setMaxEdge", maxEdge: limitOn ? null : 1600 })}
346 className={cn(
347 "flex h-5 w-9 items-center rounded-full px-0.5 transition-colors",
348 limitOn ? "bg-accent-600" : "bg-zinc-700",
349 )}
350 >
351 <span
352 className={cn(
353 "size-4 rounded-full bg-white transition-transform",
354 limitOn ? "translate-x-4" : "translate-x-0",
355 )}
356 />
357 </button>
358 </div>
359 </div>
360 </Section>
361
362 <div className="mt-auto space-y-3 p-4">
363 {error && (
364 <p className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-400">{error}</p>
365 )}
366
367 {result && !error && (
368 <div className="space-y-2 rounded-lg border border-zinc-700/60 bg-zinc-800/60 p-2.5">
369 <div className="flex items-center gap-3">
370 {resultUrl && (
371 <img
372 src={resultUrl}
373 alt="Export preview"
374 className="size-12 shrink-0 rounded-md border border-zinc-700/60 bg-checker object-cover"
375 />
376 )}
377 <div className="min-w-0 flex-1">
378 <p className="truncate text-xs font-medium text-zinc-100">
379 {formatBytes(result.bytes)} · {result.width} × {result.height}
380 </p>
381 <p className="mt-0.5 text-[0.65rem] text-zinc-400">
382 {result.format.toUpperCase()} · {result.encoder} encoder · {result.engine} engine
383 </p>
384 </div>
385 </div>
386 <div className="flex gap-1.5">
387 <Button size="sm" variant="subtle" className="flex-1" onClick={downloadAgain}>
388 <DownloadIcon className="size-3.5" /> Save
389 </Button>
390 <Button size="sm" variant="subtle" className="flex-1" onClick={copyToClipboard}>
391 <CopyIcon className="size-3.5" /> Copy
392 </Button>
393 </div>
394 </div>
395 )}
396
397 <Button
398 variant="primary"
399 size="md"
400 className="w-full"
401 disabled={exporting || !state.croppedAreaPixels}
402 onClick={onExport}
403 >
404 {exporting ? (
405 <>
406 <SpinnerIcon className="size-4 animate-spin" /> Exporting…
407 </>
408 ) : (
409 <>
410 <DownloadIcon className="size-4" /> Export &amp; download
411 </>
412 )}
413 </Button>
414 </div>
415 </div>
416 );
417}