mirror of
https://github.com/Xevion/grain.git
synced 2025-12-15 20:05:02 -06:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user