Major update for v1 and tests
This commit is contained in:
@@ -1,53 +1,179 @@
|
||||
import { useState } from 'react';
|
||||
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());
|
||||
|
||||
const filteredStickers = activeCategory === 'all'
|
||||
? STICKERS
|
||||
: STICKERS.filter(s => s.category === activeCategory);
|
||||
// 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 handleAddSticker = (emoji) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const size = 100;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.font = `${size * 0.8}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(emoji, size / 2, size / 2);
|
||||
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: 'image',
|
||||
x: 125, y: 125, width: 80, height: 80, rotation: 0,
|
||||
src: canvas.toDataURL('image/png'),
|
||||
emoji,
|
||||
type: 'sticker',
|
||||
x: printCenter - width / 2,
|
||||
y: printCenter - height / 2,
|
||||
width,
|
||||
height,
|
||||
rotation: 0,
|
||||
src: sticker.url,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="stickers-title">Stickers</h3>
|
||||
<div className="stickers-categories">
|
||||
{STICKER_CATEGORIES.map((cat) => (
|
||||
<button key={cat} onClick={() => setActiveCategory(cat)}
|
||||
className={`stickers-category-btn${activeCategory === cat ? ' active' : ''}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
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>
|
||||
<div className="stickers-grid">
|
||||
{filteredStickers.map((sticker, index) => (
|
||||
<button key={index} onClick={() => handleAddSticker(sticker.emoji)}
|
||||
className="sticker-btn"
|
||||
>
|
||||
{sticker.emoji}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user