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,29 +1,32 @@
![Grain Project - Banner Image][grain-banner] [![Grain Project - Banner Image][grain-banner]][grain-website]
A small experiment on creating beautiful, dynamic backgrounds with [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
colorful gradients & film grain. Built in <b>React</b> & <b>Vite</b> with <b>SVGs</b> and layers of <b>Radial Gradients</b> [![Node Version](https://img.shields.io/badge/node-v22-brightgreen.svg)](https://nodejs.org/)
[![pnpm](https://img.shields.io/badge/maintained%20with-pnpm-cc00ff.svg)](https://pnpm.io/)
### Dependencies Used Create beautiful, dynamic backgrounds with colorful gradients and film grain. Built with modern web technologies for smooth performance and stunning visuals.
- Hero Icons - **Customizable gradients & noise** via SVG filters
- React - **Preset palettes** for quick styling
- Typescript - **PNG export** with a single click
- Vite
- Sass
### Installation Built with [Preact](https://preactjs.com/), [Vite](https://vite.dev/), [Tailwind CSS](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), and [Lucide Icons](https://lucide.dev/).
- Built on Node v16, packages managed with Yarn. ## Usage
```bash ```bash
npm install --global yarn # If you don't have yarn installed git clone https://github.com/Xevion/grain.git # Clone the repository
yarn # Run inside root directory to install all dependencies. cd grain # Navigate to the repository
npm install --global pnpm # Install pnpm if needed
pnpm install # Install dependencies
pnpm dev # Start development server
pnpm build # Build for production
pnpm preview # Preview the production build
``` ```
### Development ## License
```bash Licensed under the [GNU General Public License v3.0](LICENSE).
yarn dev # Starts a development server with Hot Module Replacement
```
[grain-banner]: ./.media/banner.jpeg [grain-banner]: ./.media/banner.jpeg
[grain-website]: https://grain.xevion.dev/

View File

@@ -23,8 +23,8 @@
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"cssnano": "^7.1.0", "cssnano": "^7.1.0",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"html2canvas": "^1.4.1",
"lucide-preact": "^0.468.0", "lucide-preact": "^0.468.0",
"modern-screenshot": "^4.6.6",
"preact": "^10.27.2", "preact": "^10.27.2",
"preact-iso": "^2.11.0", "preact-iso": "^2.11.0",
"preact-render-to-string": "^6.6.3", "preact-render-to-string": "^6.6.3",

47
pnpm-lock.yaml generated
View File

@@ -41,12 +41,12 @@ importers:
framer-motion: framer-motion:
specifier: ^12.23.24 specifier: ^12.23.24
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
html2canvas:
specifier: ^1.4.1
version: 1.4.1
lucide-preact: lucide-preact:
specifier: ^0.468.0 specifier: ^0.468.0
version: 0.468.0(preact@10.27.2) version: 0.468.0(preact@10.27.2)
modern-screenshot:
specifier: ^4.6.6
version: 4.6.6
preact: preact:
specifier: ^10.27.2 specifier: ^10.27.2
version: 10.27.2 version: 10.27.2
@@ -1297,10 +1297,6 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
boolbase@1.0.0: boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@@ -1384,9 +1380,6 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.0.9 postcss: ^8.0.9
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-select@5.2.2: css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
@@ -1831,10 +1824,6 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true hasBin: true
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -2159,6 +2148,9 @@ packages:
minimatch@3.1.2: minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
modern-screenshot@4.6.6:
resolution: {integrity: sha512-8tF0xEpe7yx37mK95UcIghSCWYeu628K2hLJl+ZNY2ANmRzYLlRLpquPHAQcL8keF6BoeEzTEw4GrgmUpGuZ8w==}
motion-dom@12.23.23: motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
@@ -2734,9 +2726,6 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} engines: {node: '>=6'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2829,9 +2818,6 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
vite-prerender-plugin@0.5.11: vite-prerender-plugin@0.5.11:
resolution: {integrity: sha512-xWOhb8Ef2zoJIiinYVunIf3omRfUbEXcPEvrkQcrDpJ2yjDokxhvQ26eSJbkthRhymntWx6816jpATrJphh+ug==} resolution: {integrity: sha512-xWOhb8Ef2zoJIiinYVunIf3omRfUbEXcPEvrkQcrDpJ2yjDokxhvQ26eSJbkthRhymntWx6816jpATrJphh+ug==}
peerDependencies: peerDependencies:
@@ -4004,8 +3990,6 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
boolbase@1.0.0: {} boolbase@1.0.0: {}
brace-expansion@1.1.12: brace-expansion@1.1.12:
@@ -4095,10 +4079,6 @@ snapshots:
dependencies: dependencies:
postcss: 8.5.6 postcss: 8.5.6
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-select@5.2.2: css-select@5.2.2:
dependencies: dependencies:
boolbase: 1.0.0 boolbase: 1.0.0
@@ -4711,11 +4691,6 @@ snapshots:
he@1.2.0: {} he@1.2.0: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
ignore@5.3.2: {} ignore@5.3.2: {}
immutable@5.1.4: immutable@5.1.4:
@@ -5007,6 +4982,8 @@ snapshots:
dependencies: dependencies:
brace-expansion: 1.1.12 brace-expansion: 1.1.12
modern-screenshot@4.6.6: {}
motion-dom@12.23.23: motion-dom@12.23.23:
dependencies: dependencies:
motion-utils: 12.23.6 motion-utils: 12.23.6
@@ -5626,10 +5603,6 @@ snapshots:
tapable@2.3.0: {} tapable@2.3.0: {}
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
@@ -5723,10 +5696,6 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
vite-prerender-plugin@0.5.11(vite@7.2.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(yaml@2.8.1)): vite-prerender-plugin@0.5.11(vite@7.2.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.90.0)(yaml@2.8.1)):
dependencies: dependencies:
kolorist: 1.8.0 kolorist: 1.8.0

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,23 +28,23 @@ export function GradientControls({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* Header */}
<div className="p-6"> <div className="pl-4 pt-3 pb-2">
<h1 className="font-space-grotesk text-4xl font-bold tracking-tight text-zinc-900"> <h1 className="font-space-grotesk text-4xl font-bold tracking-tight text-zinc-50 text-shadow-md">
Grain Grain
</h1> </h1>
</div> </div>
<Separator.Root className="h-px bg-white/20 mx-6" /> <Separator.Root className="h-px bg-white/20" />
{/* Controls */} {/* Controls */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Palette Selector */} {/* Palette Selector */}
<PaletteSelector <PaletteSelector
className="px-6 py-4"
selectedPaletteId={paletteId} selectedPaletteId={paletteId}
onSelect={onPaletteChange} onSelect={onPaletteChange}
/> />
<Separator.Root className="h-px bg-white/10" /> <Separator.Root className="h-px bg-white/20" />
{/* Gradient Count Slider */} {/* Gradient Count Slider */}
<SliderControl <SliderControl
@@ -54,6 +54,7 @@ export function GradientControls({
min={2} min={2}
max={8} max={8}
step={1} step={1}
className="px-6 py-4"
/> />
{/* Noise Intensity Slider */} {/* Noise Intensity Slider */}
@@ -65,12 +66,13 @@ export function GradientControls({
max={1.5} max={1.5}
step={0.1} step={0.1}
formatValue={(v) => `${Math.round(v * 100)}%`} 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 */} {/* Action Buttons */}
<div className="space-y-3"> <div className="flex flex-col gap-2 mx-6 my-4">
<GlassButton <GlassButton
onClick={onRegenerate} onClick={onRegenerate}
className="w-full flex items-center justify-center gap-2" className="w-full flex items-center justify-center gap-2"
@@ -92,6 +94,5 @@ export function GradientControls({
</GlassButton> </GlassButton>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -4,12 +4,17 @@ import { motion } from "framer-motion";
interface PaletteSelectorProps { interface PaletteSelectorProps {
selectedPaletteId: string; selectedPaletteId: string;
onSelect: (paletteId: string) => void; onSelect: (paletteId: string) => void;
className?: string;
} }
export function PaletteSelector({ selectedPaletteId, onSelect }: PaletteSelectorProps) { export function PaletteSelector({
selectedPaletteId,
onSelect,
className,
}: PaletteSelectorProps) {
return ( return (
<div className="space-y-2"> <div className={`space-y-2 ${className}`}>
<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">
Color Palette Color Palette
</label> </label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">

View File

@@ -8,6 +8,7 @@ interface SliderControlProps {
max: number; max: number;
step?: number; step?: number;
formatValue?: (value: number) => string; formatValue?: (value: number) => string;
className?: string;
} }
export function SliderControl({ export function SliderControl({
@@ -17,15 +18,16 @@ export function SliderControl({
min, min,
max, max,
step = 1, step = 1,
formatValue = (v) => v.toString() formatValue = (v) => v.toString(),
className,
}: SliderControlProps) { }: SliderControlProps) {
return ( return (
<div className="space-y-2"> <div className={`space-y ${className}`}>
<div className="flex justify-between items-center"> <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}
</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)} {formatValue(value)}
</span> </span>
</div> </div>
@@ -37,7 +39,7 @@ export function SliderControl({
max={max} max={max}
step={step} 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.Range className="absolute bg-white/40 rounded-full h-full" />
</Slider.Track> </Slider.Track>
<Slider.Thumb <Slider.Thumb

View File

@@ -65,7 +65,7 @@ pre.inline {
/* Glassmorphism utilities */ /* Glassmorphism utilities */
.glass-panel { .glass-panel {
background: rgba(255, 255, 255, 0.25); background: rgba(255, 255, 255, 0.35);
backdrop-filter: blur(50px) saturate(100%); backdrop-filter: blur(50px) saturate(100%);
/* -webkit-backdrop-filter: blur(10px) saturate(80%); */ /* -webkit-backdrop-filter: blur(10px) saturate(80%); */
border: 1px solid rgba(255, 255, 255, 0.3); 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 useBackground from "./utils/useBackground";
import { GlassPanel } from "./components/GlassPanel"; import { GlassPanel } from "./components/GlassPanel";
import { GradientControls } from "./components/GradientControls"; import { GradientControls } from "./components/GradientControls";
import { ExportModal } from "./components/ExportModal";
import { getPaletteById } from "./utils/palettes"; import { getPaletteById } from "./utils/palettes";
import type { ExportData } from "./utils/exportGradient"; import { exportGradientAsPNG } from "./utils/exportGradient";
export function App() { export function App() {
const { width, height } = useViewportSize(); const { width, height } = useViewportSize();
@@ -18,7 +17,6 @@ export function App() {
const [paletteId, setPaletteId] = useState("classic"); const [paletteId, setPaletteId] = useState("classic");
const [gradientCount, setGradientCount] = useState(5); const [gradientCount, setGradientCount] = useState(5);
const [noiseIntensity, setNoiseIntensity] = useState(0.9); const [noiseIntensity, setNoiseIntensity] = useState(0.9);
const [exportModalOpen, setExportModalOpen] = useState(false);
const palette = getPaletteById(paletteId); const palette = getPaletteById(paletteId);
@@ -37,12 +35,6 @@ export function App() {
}; };
}, [svg, backgrounds]); }, [svg, backgrounds]);
const exportData: ExportData = useMemo(() => ({
backgrounds,
svg,
noiseIntensity,
}), [backgrounds, svg, noiseIntensity]);
// Keyboard shortcuts // Keyboard shortcuts
useEffect(() => { useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => { const handleKeyPress = (e: KeyboardEvent) => {
@@ -51,7 +43,7 @@ export function App() {
regenerate(); regenerate();
} else if (e.key === "e" || e.key === "E") { } else if (e.key === "e" || e.key === "E") {
e.preventDefault(); e.preventDefault();
setExportModalOpen(true); exportGradientAsPNG();
} }
}; };
@@ -81,17 +73,10 @@ export function App() {
noiseIntensity={noiseIntensity} noiseIntensity={noiseIntensity}
onNoiseIntensityChange={setNoiseIntensity} onNoiseIntensityChange={setNoiseIntensity}
onRegenerate={regenerate} onRegenerate={regenerate}
onExport={() => setExportModalOpen(true)} onExport={() => exportGradientAsPNG()}
/> />
</GlassPanel> </GlassPanel>
</div> </div>
{/* Export modal */}
<ExportModal
open={exportModalOpen}
onClose={() => setExportModalOpen(false)}
exportData={exportData}
/>
</> </>
); );
} }

View File

@@ -1,86 +1,20 @@
import html2canvas from "html2canvas"; import { domToPng } from "modern-screenshot";
export interface ExportData {
backgrounds: string[];
svg: string;
noiseIntensity: number;
}
/** /**
* 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> { export async function exportGradientAsPNG(
const element = document.getElementById(elementId); elementId: string = "gradient-container"
): Promise<void> {
const element = document.querySelector(`#${elementId}`);
if (!element) { if (!element) {
throw new Error("Gradient element not found"); throw new Error("Gradient element not found");
} }
try { const dataUrl = await domToPng(element as HTMLElement);
const canvas = await html2canvas(element, {
backgroundColor: null,
scale: 2, // Higher quality
logging: false,
});
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url;
link.download = `grain-gradient-${Date.now()}.png`; link.download = `grain-gradient-${Date.now()}.png`;
link.href = dataUrl;
link.click(); 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.click();
URL.revokeObjectURL(url);
} }