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 (
No stickers available yet. Stickers will appear here once they're added to the library.