diff --git a/src/App.css b/src/App.css index f9aa274..a50a46f 100644 --- a/src/App.css +++ b/src/App.css @@ -1060,7 +1060,7 @@ /* Preview bar (original / output toggle) */ .preview__bar { display: grid; - grid-template-columns: 1fr 1fr 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid var(--line); } @@ -1182,46 +1182,68 @@ font-size: 0.78rem; } -/* ─── Export dialog ──────────────────────────────────────────────────── */ -.export-backdrop { +/* ─── Export mini bar ───────────────────────────────────────────────── */ +.export-mini-bar { position: fixed; - inset: 0; - z-index: 200; - display: flex; + left: 50%; + bottom: 24px; + z-index: 220; + width: min(1120px, calc(100vw - 32px)); + display: grid; + grid-template-columns: minmax(180px, 1fr) auto auto; align-items: center; - justify-content: center; - background: rgba(18, 38, 31, 0.3); - backdrop-filter: blur(4px); -} - -.export-dialog { - width: min(480px, calc(100vw - 40px)); - max-height: calc(100vh - 60px); - overflow-y: auto; - padding: 24px; + gap: 14px; + padding: 12px 14px; border: 1px solid var(--line); - border-radius: var(--radius-lg); - background: var(--panel); + border-radius: 999px; + background: rgba(250, 254, 252, 0.96); box-shadow: var(--shadow-lg); - display: flex; - flex-direction: column; - gap: 18px; - animation: slideUp 250ms ease both; + backdrop-filter: blur(14px); + transform: translateX(-50%); +} + +.export-mini-bar.slide-up { + animation: exportBarIn 250ms ease both; +} + +@keyframes exportBarIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(16px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } } -.export-dialog__header { +.export-mini-bar__summary { display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + min-width: 0; + gap: 2px; + padding-left: 6px; } -.export-dialog__header h2 { - margin: 0; +.export-mini-bar__summary strong { font-family: var(--font-display); - font-size: 1.2rem; - font-weight: 700; + font-size: 0.88rem; +} + +.export-mini-bar__summary span { + overflow: hidden; + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.export-mini-bar__summary .export-mini-bar__note { + color: var(--accent); } + .close-btn { display: flex; align-items: center; @@ -1246,22 +1268,24 @@ /* Format picker */ .format-picker { display: flex; - gap: 8px; + gap: 6px; flex-wrap: wrap; + justify-content: center; } .format-chip { display: flex; flex-direction: column; align-items: center; - gap: 2px; - padding: 10px 16px; + gap: 1px; + min-width: 58px; + padding: 7px 10px; border: 1.5px solid var(--line); - border-radius: var(--radius-md); + border-radius: 999px; background: white; cursor: pointer; font-family: var(--font-mono); - font-size: 0.88rem; + font-size: 0.76rem; font-weight: 600; color: var(--text); transition: @@ -1287,48 +1311,37 @@ .format-chip__label { font-family: var(--font-sans); - font-size: 0.66rem; - font-weight: 500; + font-size: 0.56rem; + font-weight: 600; color: var(--muted); text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: 0.05em; } .format-chip--active .format-chip__label { color: var(--accent); } -.export-dialog__size { - font-family: var(--font-mono); - font-size: 0.76rem; - color: var(--muted); -} - -.export-dialog__note { - font-size: 0.78rem; - color: var(--muted); - padding: 8px 12px; - background: rgba(23, 128, 95, 0.06); - border-radius: var(--radius-sm); -} - .export-actions { display: flex; flex-wrap: wrap; + justify-content: flex-end; + align-items: center; gap: 8px; } -.export-base64 { - width: 100%; - min-height: 80px; - padding: 10px 12px; - border: 1px solid var(--line); - border-radius: var(--radius-md); - background: rgba(245, 250, 248, 0.8); - font-family: var(--font-mono); - font-size: 0.72rem; - color: var(--text); - resize: vertical; +@media (max-width: 900px) { + .export-mini-bar { + grid-template-columns: 1fr; + border-radius: var(--radius-lg); + } + + .export-mini-bar__summary, + .export-actions { + align-items: center; + justify-content: center; + text-align: center; + } } /* ─── Toast ──────────────────────────────────────────────────────────── */ diff --git a/src/App.tsx b/src/App.tsx index f7545f0..fae8ecc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type DragEvent } from "react"; +import { useCallback, useEffect, useRef, useState, type DragEvent } from "react"; import "./App.css"; import { ColorPanel } from "./components/ColorPanel"; import { DropScreen } from "./components/DropScreen"; @@ -72,10 +72,15 @@ function EditorApp() { const [recents, setRecents] = useState([]); const [presets, setPresets] = useState([]); const [history, setHistory] = useState({ past: [], future: [] }); + const imageRef = useRef(null); const canUndo = history.past.length > 0; const canRedo = history.future.length > 0; + useEffect(() => { + imageRef.current = image; + }, [image]); + const commitOptions = useCallback( ( nextOrUpdater: EditorOptions | ((current: EditorOptions) => EditorOptions), @@ -166,12 +171,17 @@ function EditorApp() { setIsImporting(true); try { const next = await loadImageFromFile(file); + const hadImage = Boolean(imageRef.current); setImage((prev) => { if (prev) revokeImageUrl(prev.url); return next; }); setOptions((prev) => updateOptionsForImage(next, prev)); setHistory({ past: [], future: [] }); + setShowExport(false); + if (!hadImage) { + window.history.pushState({ imgxView: "editor" }, "", "#editor"); + } void saveToRecents(next); } catch (err) { setToast( @@ -187,12 +197,17 @@ function EditorApp() { setIsImporting(true); try { const next = await loadImageFromBase64(raw); + const hadImage = Boolean(imageRef.current); setImage((prev) => { if (prev) revokeImageUrl(prev.url); return next; }); setOptions((prev) => updateOptionsForImage(next, prev)); setHistory({ past: [], future: [] }); + setShowExport(false); + if (!hadImage) { + window.history.pushState({ imgxView: "editor" }, "", "#editor"); + } void saveToRecents(next); } catch (err) { setToast(err instanceof Error ? err.message : "Could not decode base64."); @@ -347,7 +362,7 @@ function EditorApp() { setPresets((current) => current.filter((preset) => preset.id !== id)); } - function clearImage() { + const clearImage = useCallback((syncUrl = true) => { setImage((c) => { if (c) revokeImageUrl(c.url); return null; @@ -362,7 +377,26 @@ function EditorApp() { setActivePanel(null); setColorPanelOpen(false); setPresetPanelOpen(false); - } + + if (syncUrl && window.location.hash === "#editor") { + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); + } + }, []); + + useEffect(() => { + function handlePopState() { + if (imageRef.current) { + clearImage(false); + } + } + + window.addEventListener("popstate", handlePopState); + return () => window.removeEventListener("popstate", handlePopState); + }, [clearImage]); // Derive base name for export const baseName = image?.file.name.replace(/\.[^.]+$/, "") ?? "image"; @@ -394,7 +428,7 @@ function EditorApp() { onResizeField={setResizeField} onUndo={undoOptions} onRedo={redoOptions} - onExport={() => setShowExport(true)} + onExport={() => setShowExport((open) => !open)} onNewImage={clearImage} isProcessing={isProcessing} activePanel={activePanel} diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 634aadb..dca0340 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import type { ExportFormat, ProcessedImage } from "../types/image"; import { exportFormats } from "../types/image"; import { @@ -26,26 +26,12 @@ export function ExportDialog({ onClose, onToast, }: ExportDialogProps) { - const dialogRef = useRef(null); const [selectedFormat, setSelectedFormat] = useState( result.type as ExportFormat, ); const [isEncoding, setIsEncoding] = useState(false); - const [base64Output, setBase64Output] = useState(""); - - // Close on escape - useEffect(() => { - function handleKey(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); - } - window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); - }, [onClose]); - - // Close on backdrop click - function handleBackdropClick(e: React.MouseEvent) { - if (e.target === e.currentTarget) onClose(); - } + const [previewSize, setPreviewSize] = useState(formatBytes(result.size)); + const [isCalculatingSize, setIsCalculatingSize] = useState(false); async function getBlob(): Promise { if (selectedFormat === result.type) { @@ -96,147 +82,112 @@ export function ExportDialog({ } } - // Compute preview size - const isNativeFormat = selectedFormat === result.type; - const previewSize = isNativeFormat ? formatBytes(result.size) : null; + // Keep the displayed output size in sync with the selected export format. + useEffect(() => { + let cancelled = false; + + async function calculatePreviewSize() { + if (selectedFormat === result.type) { + setPreviewSize(formatBytes(result.size)); + setIsCalculatingSize(false); + return; + } + + setIsCalculatingSize(true); + try { + const blob = await reEncodeAs(result, selectedFormat, quality); + if (!cancelled) setPreviewSize(formatBytes(blob.size)); + } catch { + if (!cancelled) setPreviewSize("Size unavailable"); + } finally { + if (!cancelled) setIsCalculatingSize(false); + } + } - return ( -
-
-
-

Export

- -
- - {/* Format picker */} -
- {exportFormats.map((fmt) => ( - - ))} -
- - {previewSize && ( -

- {result.width}x{result.height} · {previewSize} -

- )} + void calculatePreviewSize(); + return () => { + cancelled = true; + }; + }, [quality, result, selectedFormat]); + + return ( +