feat: add analytics share preview

Add preview sizing, copy-to-clipboard, and improved PNG rendering for analytics share cards. Refresh the exported share card visuals and keep chart bars constrained for compact layouts.
This commit is contained in:
Matthew Meszaros
2026-06-04 18:57:08 +02:00
parent f72952803e
commit cdb2eaa229
4 changed files with 499 additions and 73 deletions
@@ -1,11 +1,30 @@
import { useRef } from "react";
import { DownloadIcon, Loader2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { CheckIcon, CopyIcon, DownloadIcon, ImageIcon, Loader2Icon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import useExportCard from "@/hooks/useExportCard";
import StatsShareCard, { type ShareCardData } from "./StatsShareCard";
import StatsShareCard, { type ShareAspect, type ShareCardData } from "./StatsShareCard";
// Drops a "Share image" button next to any analytics surface. It keeps a
// branded <StatsShareCard> mounted off-viewport (real layout, so the capture
// isn't blank) and rasterizes it to a downloadable PNG on click.
// isn't blank) and, on click, opens a preview modal that rasterizes the card to
// a PNG. The user picks an aspect preset (1:1 / 3:2 / 16:9) and previews it
// before choosing to download or copy.
const ASPECTS: { value: ShareAspect; label: string; ratio: string; suffix: string }[] = [
{ value: "1:1", label: "1:1", ratio: "1 / 1", suffix: "1x1" },
{ value: "3:2", label: "3:2", ratio: "3 / 2", suffix: "3x2" },
{ value: "16:9", label: "16:9", ratio: "16 / 9", suffix: "16x9" },
];
// Apply a preset suffix before the ".png" extension (e.g. "warmbly-x.png" ->
// "warmbly-x-16x9.png"). Falls back to appending if there's no .png.
function withPresetSuffix(filename: string, suffix: string): string {
const i = filename.toLowerCase().lastIndexOf(".png");
if (i === -1) return `${filename}-${suffix}.png`;
return `${filename.slice(0, i)}-${suffix}${filename.slice(i)}`;
}
export default function AnalyticsShareButton({
data,
filename,
@@ -16,27 +35,196 @@ export default function AnalyticsShareButton({
label?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const { exporting, exportPng } = useExportCard();
const { renderPng, downloadPng } = useExportCard();
const [open, setOpen] = useState(false);
const [aspect, setAspect] = useState<ShareAspect>("1:1");
const [url, setUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Render the PNG when the preview opens AND whenever the aspect changes;
// reset when it closes. The capture is heavy, so defer a frame to let the
// off-viewport card relayout for the new aspect first. Square stays crisp at
// pixelRatio 3; wider presets drop to 2 so the (much larger) capture is
// snappy.
useEffect(() => {
if (!open) {
setUrl(null);
setCopied(false);
return;
}
let alive = true;
setUrl(null); // show the spinner while re-capturing for the new aspect
// Double rAF: the off-viewport card re-lays-out for the new aspect on
// the next frame; capturing in a second frame guarantees we rasterize
// the committed-and-painted new size, not the previous one.
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(async () => {
const out = await renderPng(ref.current, { pixelRatio: aspect === "1:1" ? 3 : 2 });
if (alive) setUrl(out);
});
});
return () => {
alive = false;
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, [open, aspect, renderPng]);
// Esc closes the preview.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
async function copy() {
if (!url) return;
try {
// Pass a Promise<Blob> to ClipboardItem rather than awaiting the blob
// first: browsers (especially Safari) require clipboard.write() to run
// within the click's user-gesture, and an `await fetch(...)` beforehand
// forfeits it → NotAllowedError. The Promise form keeps the gesture.
const item = new ClipboardItem({ "image/png": fetch(url).then((r) => r.blob()) });
await navigator.clipboard.write([item]);
setCopied(true);
toast.success("Image copied to clipboard");
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("Couldn't copy — download instead");
}
}
const current = ASPECTS.find((a) => a.value === aspect) ?? ASPECTS[0];
return (
<>
<button
type="button"
onClick={() => exportPng(ref.current, filename)}
disabled={exporting}
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 bg-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-60"
onClick={() => setOpen(true)}
className="h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-slate-700 hover:text-slate-900 bg-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
{exporting ? (
<Loader2Icon className="w-3.5 h-3.5 animate-spin" />
) : (
<DownloadIcon className="w-3.5 h-3.5" />
)}
{exporting ? "Rendering…" : label}
<ImageIcon className="w-3.5 h-3.5" />
{label}
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-900/30 backdrop-blur-[2px] px-4"
onMouseDown={() => setOpen(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.18, ease: "easeOut" }}
onMouseDown={(e) => e.stopPropagation()}
className="w-full max-w-[min(94vw,860px)] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden"
>
<div className="h-12 px-4 border-b border-slate-200 flex items-center gap-2 shrink-0">
<span className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
Share image
</span>
<div className="h-4 w-px bg-slate-200" />
<span className="text-[12.5px] text-slate-600 truncate">
Preview before download
</span>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close"
className="ml-auto size-7 rounded-md text-slate-400 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
>
<XIcon className="w-4 h-4" />
</button>
</div>
{/* aspect preset selector (house theme segmented control) */}
<div className="px-4 pt-3 flex items-center gap-2">
<span className="text-[11px] text-slate-500 font-medium">Aspect</span>
<div className="inline-flex items-center gap-1 rounded-md bg-slate-100 p-0.5">
{ASPECTS.map((a) => {
const active = a.value === aspect;
return (
<button
key={a.value}
type="button"
onClick={() => setAspect(a.value)}
aria-pressed={active}
className={`h-7 px-3 rounded text-[12px] font-medium transition-colors ${
active
? "bg-sky-600 text-white shadow-sm"
: "text-slate-600 hover:text-slate-900"
}`}
>
{a.label}
</button>
);
})}
</div>
</div>
<div className="px-4 py-4 bg-slate-50/40 flex justify-center">
<div
className="relative max-w-full rounded-md border border-slate-200 bg-white overflow-hidden"
style={{ aspectRatio: current.ratio, height: "min(460px, 58vh)" }}
>
{url ? (
<img
src={url}
alt="Analytics share preview"
className="w-full h-full object-contain"
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-slate-400">
<Loader2Icon className="w-5 h-5 animate-spin" />
<span className="text-[11.5px]">Rendering</span>
</div>
)}
</div>
</div>
<div className="px-4 py-3 border-t border-slate-200 flex items-center justify-end gap-2">
<button
type="button"
onClick={copy}
disabled={!url}
className="h-8 px-3 rounded-md border border-slate-200 hover:border-slate-300 bg-white text-slate-700 hover:text-slate-900 text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
>
{copied ? (
<CheckIcon className="w-3.5 h-3.5 text-emerald-600" />
) : (
<CopyIcon className="w-3.5 h-3.5" />
)}
{copied ? "Copied" : "Copy"}
</button>
<button
type="button"
onClick={() =>
url && downloadPng(url, withPresetSuffix(filename, current.suffix))
}
disabled={!url}
className="h-8 px-3 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[12px] font-medium inline-flex items-center gap-1.5 transition-colors disabled:opacity-50"
>
<DownloadIcon className="w-3.5 h-3.5" />
Download PNG
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Off-viewport but laid out, so html-to-image captures real pixels. */}
<div aria-hidden className="fixed pointer-events-none -z-10" style={{ left: -99999, top: 0 }}>
<StatsShareCard ref={ref} data={data} />
<StatsShareCard ref={ref} data={data} aspect={aspect} />
</div>
</>
);
@@ -1,11 +1,20 @@
import React from "react";
import { Logo } from "@/components/svg";
import { DailyBars, type ChartPoint } from "@/components/ui/charts";
import { type ChartPoint } from "@/components/ui/charts";
// A fixed-size, branded analytics card meant to be rasterized to a shareable
// PNG (see useExportCard). It mirrors the live dashboard's slate/sky language
// so the export looks like Warmbly, not a generic screenshot. Rendered
// off-viewport by AnalyticsShareButton.
// A branded analytics card rasterized to a shareable PNG (see useExportCard).
// Design: the marketing-site hero sky (deep-blue .sky-base + the real cloud
// WebP images), the Warmbly logo white on the sky, and ONE clean white panel
// floating on it holding the title, a divided metric row, and a long
// Instantly-style area chart. One soft shadow — no scattered heavy-shadow
// boxes.
//
// Capture-safe for html-to-image (SVG foreignObject):
// - sky / glow / haze are pure CSS gradients set inline.
// - clouds are SAME-ORIGIN WebP images (web/public/backdrops, from the site)
// so html-to-image inlines them without tainting the canvas.
// - NO CSS filter: blur() (unreliable in capture) and NO mix-blend-mode.
// - the chart is an inline SVG with an internal <linearGradient>.
export interface ShareMetric {
label: string;
@@ -20,63 +29,260 @@ export interface ShareCardData {
daily: ChartPoint[]; // primary "sent" series
}
const StatsShareCard = React.forwardRef<HTMLDivElement, { data: ShareCardData }>(
function StatsShareCard({ data }, ref) {
export type ShareAspect = "1:1" | "3:2" | "16:9";
const DIMENSIONS: Record<ShareAspect, { width: number; height: number }> = {
"1:1": { width: 1080, height: 1080 },
"3:2": { width: 1620, height: 1080 },
"16:9": { width: 1920, height: 1080 },
};
// Marketing-site hero sky palette, but SYMMETRIC for a standalone card: a soft
// top-center radial in the bright sky-300 → sky-400 band (never the hero's
// sky-800 navy). Centered at 50%, so neither side is darker than the other —
// no "dark on the right". Bright at top, gently deeper toward the bottom.
const SKY_BASE =
"radial-gradient(ellipse 125% 105% at 50% -12%," +
" #9fe0fb 0%, #74cef7 30%, #4ec1f6 58%, #2fb6f2 82%, #18abed 100%)";
// Additive light wash (the .sky-breathe layer, baked static) — centered to match.
const SKY_BREATHE =
"radial-gradient(ellipse 125% 105% at 50% -12%," +
" rgba(224,242,254,0.85) 0%, rgba(125,211,252,0.38) 24%, rgba(56,189,248,0.14) 46%, transparent 64%)";
// Lifts the exposed bottom band toward light sky so it stays airy.
const HAZE_BOTTOM =
"linear-gradient(to top, rgba(186,230,253,0.40) 0%, rgba(125,211,252,0.16) 42%, transparent 100%)";
// NO dark shadow — any dark-toned shadow on the blue sky pools into a visible
// "shadow band" along the card edge. Instead the panel lifts with a faint WHITE
// halo (lighter than the sky), so it separates without darkening anything.
const PANEL: React.CSSProperties = {
background: "#ffffff",
borderRadius: 26,
border: "1px solid rgba(255,255,255,0.85)",
boxShadow: "0 16px 50px -18px rgba(255,255,255,0.55), 0 2px 12px -6px rgba(255,255,255,0.45)",
};
const LOGO_LIFT: React.CSSProperties = {
filter: "drop-shadow(0 3px 10px rgba(8,47,73,0.40)) drop-shadow(0 1px 2px rgba(8,47,73,0.30))",
};
const onSky: React.CSSProperties = { textShadow: "0 1px 4px rgba(8,47,73,0.45)" };
// Real WebP clouds in the hero's arrangement (big top-left + top-right framing
// the logo, softer puffs drifting through the lower sky band). Same-origin so
// html-to-image inlines them without tainting the canvas.
const CLOUDS: { v: number; style: React.CSSProperties }[] = [
{ v: 5, style: { top: "-5%", left: "-5%", width: "44%", opacity: 0.95 } },
{ v: 2, style: { top: "2%", right: "-4%", width: "40%", opacity: 0.9 } },
{ v: 3, style: { bottom: "-3%", left: "12%", width: "28%", opacity: 0.62 } },
{ v: 4, style: { bottom: "3%", right: "8%", width: "26%", opacity: 0.58 } },
];
function SkyBackdrop() {
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{/* additive light wash */}
<div className="absolute inset-0" style={{ background: SKY_BREATHE, opacity: 0.6 }} />
{/* lift the bottom band */}
<div className="absolute inset-x-0 bottom-0" style={{ height: "55%", background: HAZE_BOTTOM }} />
{CLOUDS.map((c, i) => (
<img
key={i}
src={`/backdrops/cloud-${c.v}.webp`}
alt=""
aria-hidden="true"
style={{ position: "absolute", height: "auto", pointerEvents: "none", userSelect: "none", ...c.style }}
/>
))}
</div>
);
}
function Stat({ metric, valueSize }: { metric: ShareMetric; valueSize: number }) {
return (
<div className="px-7 first:pl-0 last:pr-0">
<div className="text-[14px] font-medium uppercase tracking-[0.14em] text-slate-400">{metric.label}</div>
<div className="mt-2.5 font-mono leading-none tabular-nums text-slate-900" style={{ fontSize: valueSize }}>
{metric.value}
</div>
{metric.sub && <div className="mt-2 text-[14px] text-slate-400">{metric.sub}</div>}
</div>
);
}
// Long area chart — smooth sky line + gradient fill, ALWAYS with a bottom
// baseline (so it reads as a chart even with no data). Fills its flex parent.
function ShareAreaChart({ points }: { points: ChartPoint[] }) {
const vals = points.map((p) => p.value || 0);
const hasData = points.length > 0 && vals.reduce((a, b) => a + b, 0) > 0;
const W = 1000;
const H = 320;
const padX = 4;
const padTop = 14;
const baseY = H - 6;
const max = Math.max(1, ...vals);
const w = W - padX * 2;
const h = baseY - padTop;
const step = vals.length > 1 ? w / (vals.length - 1) : 0;
const pts = vals.map((v, i) => [padX + i * step, baseY - (v / max) * h] as const);
const line = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`).join(" ");
const lastX = padX + Math.max(0, vals.length - 1) * step;
const area = `${line} L ${lastX.toFixed(1)} ${baseY} L ${padX} ${baseY} Z`;
return (
<div className="flex-1 min-h-0 flex flex-col">
<div className="relative flex-1 min-h-0">
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
width="100%"
height="100%"
style={{ display: "block" }}
>
<defs>
<linearGradient id="shareArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0ea5e9" stopOpacity="0.28" />
<stop offset="100%" stopColor="#0ea5e9" stopOpacity="0" />
</linearGradient>
</defs>
{hasData && <path d={area} fill="url(#shareArea)" />}
{hasData && (
<path
d={line}
fill="none"
stroke="#0284c7"
strokeWidth={3}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
)}
{/* baseline — always present */}
<line
x1={padX}
y1={baseY}
x2={W - padX}
y2={baseY}
stroke="#cbd5e1"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
/>
</svg>
{!hasData && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[16px] text-slate-300">No sends in this window yet</span>
</div>
)}
</div>
<div className="mt-3 flex justify-between font-mono text-[12px] tabular-nums text-slate-400">
<span>{hasData ? shortDate(points[0].label) : ""}</span>
<span>{hasData ? shortDate(points[points.length - 1].label) : ""}</span>
</div>
</div>
);
}
const StatsShareCard = React.forwardRef<HTMLDivElement, { data: ShareCardData; aspect?: ShareAspect }>(
function StatsShareCard({ data, aspect = "1:1" }, ref) {
const { width, height } = DIMENSIONS[aspect];
const metrics = data.metrics.slice(0, 4);
const landscape = aspect !== "1:1";
const pad = aspect === "16:9" ? 64 : aspect === "3:2" ? 56 : 48;
const logoClass = landscape ? "w-[60px] h-[60px]" : "w-[54px] h-[54px]";
const wordmarkSize = landscape ? 40 : 36;
const titleSize = aspect === "16:9" ? 50 : aspect === "3:2" ? 46 : 40;
const valueSize = landscape ? 50 : 46;
return (
<div
ref={ref}
style={{ width: 1080, height: 1080 }}
className="flex flex-col bg-white text-slate-900 p-[72px]"
style={{ width, height, background: SKY_BASE, position: "relative", overflow: "hidden", borderRadius: 24 }}
>
{/* header */}
<div className="flex items-center">
<Logo className="w-12 h-12 text-sky-600" />
<span className="ml-4 text-[34px] font-semibold tracking-tight">Warmbly</span>
{data.subtitle && (
<span className="ml-auto text-[20px] font-medium uppercase tracking-[0.18em] text-slate-400">
{data.subtitle}
</span>
)}
</div>
<SkyBackdrop />
<h1 className="mt-14 text-[52px] font-semibold leading-tight tracking-tight">
{data.title}
</h1>
{/* metric grid */}
<div className="mt-12 grid grid-cols-2 gap-x-12 gap-y-10">
{metrics.map((m) => (
<div key={m.label} className="border-l-2 border-sky-500 pl-6">
<div className="text-[18px] font-medium uppercase tracking-[0.14em] text-slate-400">
{m.label}
</div>
<div className="mt-3 font-mono text-[64px] leading-none tabular-nums text-slate-900">
{m.value}
</div>
{m.sub && <div className="mt-3 text-[18px] text-slate-400">{m.sub}</div>}
<div className="relative z-10 flex flex-col h-full" style={{ padding: pad }}>
{/* logo on the sky */}
<div className="flex items-center justify-between">
<div className="inline-flex items-center gap-3.5" style={LOGO_LIFT}>
<Logo className={`${logoClass} text-white`} />
<span
className="text-white font-extrabold tracking-tight"
style={{ fontFamily: "var(--font-display)", fontSize: wordmarkSize }}
>
Warmbly
</span>
</div>
))}
</div>
{/* chart snapshot */}
<div className="mt-auto">
<div className="text-[18px] font-medium uppercase tracking-[0.14em] text-slate-400 mb-4">
Sends over time
<span className="font-mono text-[15px] tabular-nums text-white/85" style={onSky}>
{todayLabel()}
</span>
</div>
<DailyBars points={data.daily} height={220} emptyLabel="No sends in this window" />
</div>
{/* footer */}
<div className="mt-12 flex items-center justify-between text-[18px] text-slate-400">
<span className="font-medium text-slate-500">warmbly.com</span>
<span className="font-mono tabular-nums">{todayLabel()}</span>
{/* single white panel */}
<div style={PANEL} className="mt-6 flex-1 flex flex-col min-h-0" >
<div className="flex flex-col h-full" style={{ padding: landscape ? 44 : 40 }}>
{/* title */}
{data.subtitle && (
<div className="text-[14px] font-semibold uppercase tracking-[0.16em] text-sky-600">
{data.subtitle}
</div>
)}
<h1
className="mt-2 font-semibold leading-tight tracking-tight text-slate-900 line-clamp-2 break-words"
style={{ fontSize: titleSize }}
>
{data.title}
</h1>
{/* divided metric row */}
<div
className="mt-8 grid divide-x divide-slate-200"
style={{ gridTemplateColumns: `repeat(${metrics.length || 1}, minmax(0, 1fr))` }}
>
{metrics.map((m) => (
<Stat key={m.label} metric={m} valueSize={valueSize} />
))}
</div>
<div className="mt-8 h-px bg-slate-200/70" />
{/* long area chart */}
<div className="mt-6 flex items-center justify-between">
<span className="text-[14px] font-medium uppercase tracking-[0.14em] text-slate-400">
Sends over time
</span>
</div>
<div className="mt-4 flex-1 min-h-0 flex flex-col">
<ShareAreaChart points={data.daily} />
</div>
</div>
</div>
{/* footer on the sky */}
<div className="mt-5 flex items-center justify-between text-[16px]">
<span className="text-white font-semibold" style={onSky}>
warmbly.com
</span>
<span className="text-white/80" style={onSky}>
Cold email, warmed up.
</span>
</div>
</div>
</div>
);
},
);
function shortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" });
} catch {
return iso;
}
}
function todayLabel(): string {
return new Date().toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
+5 -2
View File
@@ -93,7 +93,7 @@ export function DailyBars({
return (
<div
key={i}
className="relative flex-1 flex items-end h-full"
className="relative flex-1 flex items-end justify-center h-full"
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
>
@@ -103,7 +103,10 @@ export function DailyBars({
barClass,
hover === null || hover === i ? "opacity-100" : "opacity-50",
)}
style={{ height: h }}
// Cap + center each bar so a sparse series (12 points) renders
// as a normal bar, not a full-width block. Dense series have
// cells narrower than the cap, so this is a no-op for them.
style={{ height: h, maxWidth: 64 }}
/>
{hover === i && (
<div className="absolute -top-7 left-1/2 -translate-x-1/2 z-10 whitespace-nowrap rounded bg-slate-900 px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm">
+38 -9
View File
@@ -15,9 +15,19 @@ import { downloadBlob } from "@/lib/api/client/app/contacts/exportContacts";
export default function useExportCard() {
const [exporting, setExporting] = useState(false);
const exportPng = useCallback(
async (node: HTMLElement | null, filename: string) => {
if (!node || exporting) return;
// Rasterize an offscreen node to a PNG *data URL* (two-pass: prime the
// font/embed cache, then capture). Returns null on failure. Does NOT
// download — callers can preview the result first, then downloadPng it.
//
// pixelRatio defaults to 3 (crisp for the square card). Wide presets (16:9
// at 1920px) get huge at 3x, so callers can pass a lower ratio (e.g. 2) to
// keep the capture snappy.
const renderPng = useCallback(
async (
node: HTMLElement | null,
options?: { pixelRatio?: number },
): Promise<string | null> => {
if (!node) return null;
setExporting(true);
try {
try {
@@ -25,17 +35,36 @@ export default function useExportCard() {
} catch {
/* fonts.ready is best-effort */
}
const opts = { pixelRatio: 3, backgroundColor: "#ffffff", cacheBust: true };
const opts = {
pixelRatio: options?.pixelRatio ?? 3,
backgroundColor: "#ffffff",
cacheBust: true,
};
await toPng(node, opts); // prime font/embed cache
const dataUrl = await toPng(node, opts); // real capture
const blob = await (await fetch(dataUrl)).blob();
downloadBlob(blob, filename);
return await toPng(node, opts); // real capture
} catch {
return null;
} finally {
setExporting(false);
}
},
[exporting],
[],
);
return { exporting, exportPng };
// Trigger a browser download of a PNG data URL (produced by renderPng).
const downloadPng = useCallback(async (dataUrl: string, filename: string) => {
const blob = await (await fetch(dataUrl)).blob();
downloadBlob(blob, filename);
}, []);
// Convenience: render + download in one shot (no preview step).
const exportPng = useCallback(
async (node: HTMLElement | null, filename: string, options?: { pixelRatio?: number }) => {
const url = await renderPng(node, options);
if (url) await downloadPng(url, filename);
},
[renderPng, downloadPng],
);
return { exporting, renderPng, downloadPng, exportPng };
}