181 lines
6.9 KiB
JavaScript
181 lines
6.9 KiB
JavaScript
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 `<img loading="lazy">`, 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 <img> 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 (
|
||
<div className="st st--full">
|
||
<div className="st__header">
|
||
<h3 className="st__title">Stickers Library</h3>
|
||
</div>
|
||
<p className="st__empty">
|
||
No stickers available yet. Stickers will appear here once they're
|
||
added to the library.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="st st--full">
|
||
<div className="st__header">
|
||
<h3 className="st__title">Stickers Library</h3>
|
||
</div>
|
||
|
||
<div className="st__categories" role="tablist" aria-label="Sticker categories">
|
||
{categoryPills.map((cat) => {
|
||
const isActive = cat.id === activeCategory;
|
||
return (
|
||
<button
|
||
key={cat.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={isActive}
|
||
className={`st__category${isActive ? ' is-active' : ''}`}
|
||
onClick={() => setActiveCategory(cat.id)}
|
||
>
|
||
{cat.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="st__grid">
|
||
{visibleStickers.map((sticker) => {
|
||
if (failedIds.has(sticker.id)) return null;
|
||
return (
|
||
<button
|
||
key={sticker.id}
|
||
type="button"
|
||
onClick={() => handleAddSticker(sticker)}
|
||
className="st__sticker st__sticker--image"
|
||
aria-label={`Add sticker ${sticker.name}`}
|
||
title={sticker.name}
|
||
>
|
||
<img
|
||
src={sticker.url}
|
||
alt=""
|
||
loading="lazy"
|
||
decoding="async"
|
||
className="st__sticker-img"
|
||
onError={() => handleImgError(sticker.id)}
|
||
/>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|