Major update for v1 and tests

This commit is contained in:
khalid@traclabs.com
2026-05-23 03:28:58 -05:00
parent 628a6765f4
commit b1fb6fa3aa
1748 changed files with 27723 additions and 1854 deletions

View File

@@ -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 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: '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>
);