Files
apparel-designer/src/components/sidebar/StickersTab.jsx
2026-05-23 03:28:58 -05:00

181 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 0300, 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>
);
}