refactor: simplify export to PNG-only, improve controls UI

- Replace html2canvas with modern-screenshot for better performance
- Remove ExportModal and CSS export functionality
- Simplify export to single PNG download
- Improve controls layout with better spacing and text shadows
- Increase glass-panel opacity for better readability
- Remove unused ExportData interface and CSS generation utilities
This commit is contained in:
Ryan Walters
2025-11-06 23:40:43 -06:00
parent 6834aa308f
commit f5090b9c10
10 changed files with 110 additions and 327 deletions

View File

@@ -1,116 +0,0 @@
import { useState } from "preact/hooks";
import * as Dialog from "@radix-ui/react-dialog";
import { X, Download, Copy, Check } from "lucide-preact";
import { GlassButton } from "./GlassButton";
import { exportAsPNG, generateCSS, copyCSSToClipboard, downloadCSS, type ExportData } from "../utils/exportGradient";
interface ExportModalProps {
open: boolean;
onClose: () => void;
exportData: ExportData;
}
export function ExportModal({ open, onClose, exportData }: ExportModalProps) {
const [copied, setCopied] = useState(false);
const [exporting, setExporting] = useState(false);
const cssCode = generateCSS(exportData);
const handleCopyCSS = async () => {
try {
await copyCSSToClipboard(cssCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
const handleDownloadCSS = () => {
downloadCSS(cssCode);
};
const handleExportPNG = async () => {
setExporting(true);
try {
await exportAsPNG("gradient-container");
} catch (error) {
console.error("Failed to export PNG:", error);
} finally {
setExporting(false);
}
};
return (
<Dialog.Root open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50" />
<Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-lg">
<div className="glass-panel p-6 m-4 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Dialog.Title className="font-space-grotesk text-2xl font-bold text-zinc-900">
Export Gradient
</Dialog.Title>
<Dialog.Close asChild>
<button
className="glass-button rounded-lg p-2 text-zinc-900"
aria-label="Close"
>
<X size={20} />
</button>
</Dialog.Close>
</div>
{/* PNG Export Section */}
<div className="space-y-3">
<h3 className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
Export as Image
</h3>
<GlassButton
onClick={handleExportPNG}
disabled={exporting}
className="w-full flex items-center justify-center gap-2"
>
<Download size={18} />
<span>{exporting ? "Exporting..." : "Download PNG"}</span>
</GlassButton>
</div>
{/* CSS Export Section */}
<div className="space-y-3">
<h3 className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
Export as CSS
</h3>
{/* CSS Code Preview */}
<div className="glass-input rounded-lg p-4 overflow-x-auto max-h-48 overflow-y-auto">
<pre className="font-mono text-xs text-zinc-900 leading-relaxed">
{cssCode}
</pre>
</div>
{/* CSS Actions */}
<div className="flex gap-2">
<GlassButton
onClick={handleCopyCSS}
className="flex-1 flex items-center justify-center gap-2"
>
{copied ? <Check size={18} /> : <Copy size={18} />}
<span>{copied ? "Copied!" : "Copy CSS"}</span>
</GlassButton>
<GlassButton
onClick={handleDownloadCSS}
className="flex-1 flex items-center justify-center gap-2"
>
<Download size={18} />
<span>Download CSS</span>
</GlassButton>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -28,69 +28,70 @@ export function GradientControls({
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-6">
<h1 className="font-space-grotesk text-4xl font-bold tracking-tight text-zinc-900">
<div className="pl-4 pt-3 pb-2">
<h1 className="font-space-grotesk text-4xl font-bold tracking-tight text-zinc-50 text-shadow-md">
Grain
</h1>
</div>
<Separator.Root className="h-px bg-white/20 mx-6" />
<Separator.Root className="h-px bg-white/20" />
{/* Controls */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Palette Selector */}
<PaletteSelector
selectedPaletteId={paletteId}
onSelect={onPaletteChange}
/>
{/* Palette Selector */}
<PaletteSelector
className="px-6 py-4"
selectedPaletteId={paletteId}
onSelect={onPaletteChange}
/>
<Separator.Root className="h-px bg-white/10" />
<Separator.Root className="h-px bg-white/20" />
{/* Gradient Count Slider */}
<SliderControl
label="Gradients"
value={gradientCount}
onChange={onGradientCountChange}
min={2}
max={8}
step={1}
/>
{/* Gradient Count Slider */}
<SliderControl
label="Gradients"
value={gradientCount}
onChange={onGradientCountChange}
min={2}
max={8}
step={1}
className="px-6 py-4"
/>
{/* Noise Intensity Slider */}
<SliderControl
label="Noise"
value={noiseIntensity}
onChange={onNoiseIntensityChange}
min={0}
max={1.5}
step={0.1}
formatValue={(v) => `${Math.round(v * 100)}%`}
/>
{/* Noise Intensity Slider */}
<SliderControl
label="Noise"
value={noiseIntensity}
onChange={onNoiseIntensityChange}
min={0}
max={1.5}
step={0.1}
formatValue={(v) => `${Math.round(v * 100)}%`}
className="px-6 py-4"
/>
<Separator.Root className="h-px bg-white/10" />
<Separator.Root className="h-px bg-white/20" />
{/* Action Buttons */}
<div className="space-y-3">
<GlassButton
onClick={onRegenerate}
className="w-full flex items-center justify-center gap-2"
tooltip="Regenerate gradient (R)"
aria-label="Regenerate gradient"
>
<RefreshCw size={18} />
<span>Regenerate</span>
</GlassButton>
{/* Action Buttons */}
<div className="flex flex-col gap-2 mx-6 my-4">
<GlassButton
onClick={onRegenerate}
className="w-full flex items-center justify-center gap-2"
tooltip="Regenerate gradient (R)"
aria-label="Regenerate gradient"
>
<RefreshCw size={18} />
<span>Regenerate</span>
</GlassButton>
<GlassButton
onClick={onExport}
className="w-full flex items-center justify-center gap-2"
tooltip="Export gradient (E)"
aria-label="Export gradient"
>
<Download size={18} />
<span>Export</span>
</GlassButton>
</div>
<GlassButton
onClick={onExport}
className="w-full flex items-center justify-center gap-2"
tooltip="Export gradient (E)"
aria-label="Export gradient"
>
<Download size={18} />
<span>Export</span>
</GlassButton>
</div>
</div>
);

View File

@@ -4,12 +4,17 @@ import { motion } from "framer-motion";
interface PaletteSelectorProps {
selectedPaletteId: string;
onSelect: (paletteId: string) => void;
className?: string;
}
export function PaletteSelector({ selectedPaletteId, onSelect }: PaletteSelectorProps) {
export function PaletteSelector({
selectedPaletteId,
onSelect,
className,
}: PaletteSelectorProps) {
return (
<div className="space-y-2">
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
<div className={`space-y-2 ${className}`}>
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-50 text-shadow-md">
Color Palette
</label>
<div className="grid grid-cols-2 gap-2">

View File

@@ -8,6 +8,7 @@ interface SliderControlProps {
max: number;
step?: number;
formatValue?: (value: number) => string;
className?: string;
}
export function SliderControl({
@@ -17,15 +18,16 @@ export function SliderControl({
min,
max,
step = 1,
formatValue = (v) => v.toString()
formatValue = (v) => v.toString(),
className,
}: SliderControlProps) {
return (
<div className="space-y-2">
<div className={`space-y ${className}`}>
<div className="flex justify-between items-center">
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-900">
<label className="font-space-grotesk text-sm font-semibold uppercase tracking-wide text-zinc-50 text-shadow-md">
{label}
</label>
<span className="font-inter text-sm text-zinc-800">
<span className="font-inter text-sm text-zinc-100 text-shadow-md">
{formatValue(value)}
</span>
</div>
@@ -37,7 +39,7 @@ export function SliderControl({
max={max}
step={step}
>
<Slider.Track className="glass-input relative flex-grow rounded-full h-3">
<Slider.Track className="glass-input relative grow rounded-full h-3">
<Slider.Range className="absolute bg-white/40 rounded-full h-full" />
</Slider.Track>
<Slider.Thumb

View File

@@ -65,7 +65,7 @@ pre.inline {
/* Glassmorphism utilities */
.glass-panel {
background: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.35);
backdrop-filter: blur(50px) saturate(100%);
/* -webkit-backdrop-filter: blur(10px) saturate(80%); */
border: 1px solid rgba(255, 255, 255, 0.3);

View File

@@ -7,9 +7,8 @@ import { useViewportSize } from "./utils/useViewportSize";
import useBackground from "./utils/useBackground";
import { GlassPanel } from "./components/GlassPanel";
import { GradientControls } from "./components/GradientControls";
import { ExportModal } from "./components/ExportModal";
import { getPaletteById } from "./utils/palettes";
import type { ExportData } from "./utils/exportGradient";
import { exportGradientAsPNG } from "./utils/exportGradient";
export function App() {
const { width, height } = useViewportSize();
@@ -18,7 +17,6 @@ export function App() {
const [paletteId, setPaletteId] = useState("classic");
const [gradientCount, setGradientCount] = useState(5);
const [noiseIntensity, setNoiseIntensity] = useState(0.9);
const [exportModalOpen, setExportModalOpen] = useState(false);
const palette = getPaletteById(paletteId);
@@ -37,12 +35,6 @@ export function App() {
};
}, [svg, backgrounds]);
const exportData: ExportData = useMemo(() => ({
backgrounds,
svg,
noiseIntensity,
}), [backgrounds, svg, noiseIntensity]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
@@ -51,7 +43,7 @@ export function App() {
regenerate();
} else if (e.key === "e" || e.key === "E") {
e.preventDefault();
setExportModalOpen(true);
exportGradientAsPNG();
}
};
@@ -81,17 +73,10 @@ export function App() {
noiseIntensity={noiseIntensity}
onNoiseIntensityChange={setNoiseIntensity}
onRegenerate={regenerate}
onExport={() => setExportModalOpen(true)}
onExport={() => exportGradientAsPNG()}
/>
</GlassPanel>
</div>
{/* Export modal */}
<ExportModal
open={exportModalOpen}
onClose={() => setExportModalOpen(false)}
exportData={exportData}
/>
</>
);
}

View File

@@ -1,86 +1,20 @@
import html2canvas from "html2canvas";
export interface ExportData {
backgrounds: string[];
svg: string;
noiseIntensity: number;
}
import { domToPng } from "modern-screenshot";
/**
* Export the current gradient as a PNG image
* Export the gradient as a PNG screenshot
*/
export async function exportAsPNG(elementId: string = "gradient-container"): Promise<void> {
const element = document.getElementById(elementId);
export async function exportGradientAsPNG(
elementId: string = "gradient-container"
): Promise<void> {
const element = document.querySelector(`#${elementId}`);
if (!element) {
throw new Error("Gradient element not found");
}
try {
const canvas = await html2canvas(element, {
backgroundColor: null,
scale: 2, // Higher quality
logging: false,
});
const dataUrl = await domToPng(element as HTMLElement);
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `grain-gradient-${Date.now()}.png`;
link.click();
URL.revokeObjectURL(url);
});
} catch (error) {
console.error("Failed to export PNG:", error);
throw error;
}
}
/**
* Generate CSS code for the current gradient
*/
export function generateCSS(data: ExportData): string {
const { backgrounds, svg } = data;
const backgroundLayers = [`url("${svg}")`, ...backgrounds].join(",\n ");
return `/* Grain Gradient CSS */
.gradient-background {
background: ${backgroundLayers};
filter: contrast(150%) brightness(90%);
background-blend-mode: overlay;
}
/* Additional overlay (optional) */
.gradient-overlay {
background: rgba(40, 40, 40, 0.5);
background-blend-mode: overlay;
}`;
}
/**
* Copy CSS code to clipboard
*/
export async function copyCSSToClipboard(css: string): Promise<void> {
try {
await navigator.clipboard.writeText(css);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
throw error;
}
}
/**
* Download CSS code as a file
*/
export function downloadCSS(css: string): void {
const blob = new Blob([css], { type: "text/css" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `grain-gradient-${Date.now()}.css`;
link.download = `grain-gradient-${Date.now()}.png`;
link.href = dataUrl;
link.click();
URL.revokeObjectURL(url);
}