import { useMemo, useState } from 'react'; import { STICKERS, STICKER_CATEGORIES } from '../../constants/stickers'; import '../../styles/StickersTab.css'; /** * Stickers tab — image stickers from `public/stickers/`. * * Lists every PNG/WebP/JPG/SVG dropped into the public/stickers/ folder, * grouped by the category prefix in its filename. Tapping a sticker adds it * to the canvas as a 'sticker'-type element with the file's URL as the src. * * Image fetches are lazy: each thumbnail uses ``, so a * library of 200+ stickers doesn't fire 200 GETs on tab open. The browser * pulls them in as the user scrolls through the grid. * * Adding stickers is folder-driven (see public/stickers/README.md). There's * no constants file to edit and no manifest to regenerate — a small Vite * plugin reads the directory at build time and emits the file list. * * The Emoji tab (EmojiTab.jsx) used to live here too as a `variant="emoji"` * mode. It's a separate component now: image stickers and emoji glyphs have * essentially no shared rendering path beyond the grid CSS, which both reuse. */ export function StickersTab({ onAddSticker }) { const [activeCategory, setActiveCategory] = useState('all'); const [failedIds, setFailedIds] = useState(() => new Set()); // Categories pill bar. Prepend an "All" entry that the data layer // intentionally doesn't include — "All" is a UI concept (filter // disable), not a data category. const categoryPills = useMemo( () => [{ id: 'all', label: 'All' }, ...STICKER_CATEGORIES], [], ); const visibleStickers = useMemo(() => { if (activeCategory === 'all') return STICKERS; return STICKERS.filter((s) => s.categoryId === activeCategory); }, [activeCategory]); const handleAddSticker = async (sticker) => { // Read the source image's natural dimensions so the sticker lands on // the canvas with its true aspect ratio, not as a stretched square. // The thumbnail's in the panel may not have loaded yet (it's // lazy) and even if it has, the DOM element isn't a stable place to // pull `naturalWidth` from — we just construct a fresh Image. If the // browser already has the file in cache the onload fires immediately; // otherwise we wait for the network. Either way, the click feels // responsive because the bottleneck is decode latency, not network. // // Falls back to a square 80×80 if the load fails for any reason // (broken file, CORS oddity, blocked). That's strictly no worse than // the previous unconditional 80×80 placement. const fitInto = 80; // design-coord bounding box let width = fitInto; let height = fitInto; try { const dims = await new Promise((resolveDims, rejectDims) => { const img = new Image(); img.onload = () => resolveDims({ w: img.naturalWidth, h: img.naturalHeight }); img.onerror = () => rejectDims(new Error('Sticker image failed to load')); img.src = sticker.url; }); if (dims.w > 0 && dims.h > 0) { // Fit (dims.w × dims.h) into an 80×80 box, preserving aspect. // The longer side becomes 80; the shorter side scales // proportionally. Equal sides land on 80×80 (matches old // behavior for square sources). const scale = fitInto / Math.max(dims.w, dims.h); width = dims.w * scale; height = dims.h * scale; } } catch { // Keep the 80×80 fallback. handleImgError will fire from the // thumbnail render path separately if the file is truly missing. } // Center the (width, height) box on the print zone center. Design // coords are 0–300, so center is at 150. With width=height=80, this // produces (x: 110, y: 110), matching the old fixed placement — so // square stickers don't appear to move relative to the previous // behavior. Non-square stickers center properly instead of having // their top-left pinned (which would have skewed their visual // center off the print-zone center). const printCenter = 150; onAddSticker({ type: 'sticker', x: printCenter - width / 2, y: printCenter - height / 2, width, height, rotation: 0, src: sticker.url, }); }; const handleImgError = (id) => { // Track failed loads so we can hide broken thumbnails. We don't // remove the sticker entry — the file is presumably there (the // manifest came from a directory listing), so a 404 likely means a // dev-server hiccup that'll fix itself on reload. Hiding the broken // image tile is just polite. setFailedIds((prev) => { if (prev.has(id)) return prev; const next = new Set(prev); next.add(id); return next; }); }; // Empty-state: no stickers in the folder. Friendlier than rendering an // empty pill bar and a blank grid. The README at public/stickers/README.md // explains the filename convention; pointing the user there in the // message would be over-explaining for an end-user-facing surface — this // copy is intentionally generic. if (STICKERS.length === 0) { return (

Stickers Library

No stickers available yet. Stickers will appear here once they're added to the library.

); } return (

Stickers Library

{categoryPills.map((cat) => { const isActive = cat.id === activeCategory; return ( ); })}
{visibleStickers.map((sticker) => { if (failedIds.has(sticker.id)) return null; return ( ); })}
); }