feat(studio): a sticker library of 148 hand-drawn illustrations, placed, recoloured and searched by people and the assistant

A new element kind, sticker, draws layered vector illustrations from a library in twelve themes, behind its own entry point @trek/shared/stickers. Studio gets a Stickers rail section (themes, localized search, recently used, click or drag to place) and an inspector for colour slots, mirroring, turning and opacity. The assistant gets list_book_assets, add and style of stickers in edit_book_page, stickers in describe_book_page and an overlap warning for a sticker over words. BOOK_SCHEMA_REV goes to 2.
This commit is contained in:
Maurice
2026-10-23 19:50:55 +02:00
parent 23c446b7e9
commit 8c6f5deab2
110 changed files with 13616 additions and 19 deletions
@@ -37,6 +37,7 @@ export const BOOK_LOOK = {
// The server's own read: it waits for the Studio to draw the page, and says it looks at it.
preview_book_page: { stepKey: 'assistant.step.lookingAtPage' },
list_book_layouts: { stepKey: 'assistant.step.bookLayouts' },
list_book_assets: { stepKey: 'assistant.step.bookStickers' },
suggest_book_photos: { stepKey: 'assistant.step.choosingPhotos' },
},
entities: {
@@ -11,6 +11,7 @@ import { PageNumbers } from './PageNumbers'
import { PaintedShapeView } from './paint/PaintedShape'
import { SpreadPaint } from './paint/paintRender'
import { usePaintId } from './paint/usePaintId'
import { StickerView } from './StickerView'
/**
* One spread, drawn.
@@ -405,6 +406,7 @@ export function ElementView({
// Above the catch-all below, which routes everything that is not text into
// the travel elements and draws null for anything they do not know.
if (el.kind === 'icon') return <IconView el={el} />
if (el.kind === 'sticker') return <StickerView el={el} frameStyle={frameStyle(el)} />
if (el.kind !== 'text') {
// map, stats, countries, badge — the elements drawn from the journey's own
@@ -0,0 +1,72 @@
import { bookStickerLayerPaint, type BookStickerElement } from '@trek/shared';
import { bookSticker, type BookStickerDef } from '@trek/shared/stickers';
import type { CSSProperties } from 'react';
/**
* A sticker, drawn: its layers as one inline SVG in the sticker's own box,
* scaled to the frame by the viewBox, so every layer stays a vector path in
* the print and in the page image the assistant reads.
*
* The drawing keeps its proportions inside whatever frame it is given (meet),
* the way an icon does: a lemon stretched to twice its width stops being a
* lemon. A flip mirrors the drawing inside the frame, so the frame, its
* handles and its rotation stay where they were.
*
* A sticker this build's library does not hold draws nothing. Only a newer
* book can carry one, and a book that is newer than this reader is opened
* read-only (book-schema-rev.ts), so nothing is lost by not drawing it.
*/
/** The drawing alone, for the page, the panel's tiles and the inspector. */
export function StickerArt({
def,
colors = [],
flipX = false,
}: {
def: BookStickerDef;
colors?: readonly string[];
flipX?: boolean;
}) {
const [w, h] = def.box;
return (
<svg
width="100%"
height="100%"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="xMidYMid meet"
aria-hidden
style={{ display: 'block', overflow: 'visible', transform: flipX ? 'scaleX(-1)' : undefined }}
>
{def.layers.map((layer, i) => {
const { color, opacity } = bookStickerLayerPaint(layer, def.slots, colors);
const alpha = opacity < 1 ? opacity : undefined;
return layer.w ? (
<path
// A sticker's layers never reorder: their place in the list is their identity.
key={i}
d={layer.d}
fill="none"
stroke={color}
strokeOpacity={alpha}
strokeWidth={layer.w}
strokeLinecap="round"
strokeLinejoin="round"
/>
) : (
<path key={i} d={layer.d} fill={color} fillOpacity={alpha} fillRule={layer.eo ? 'evenodd' : undefined} />
);
})}
</svg>
);
}
/** A sticker element on the page, in its frame. */
export function StickerView({ el, frameStyle }: { el: BookStickerElement; frameStyle: CSSProperties }) {
const def = bookSticker(el.stickerId);
if (!def) return null;
return (
<div style={frameStyle}>
<StickerArt def={def} colors={el.colors} flipX={el.flipX} />
</div>
);
}
@@ -9,6 +9,7 @@ import { fontStack, nearestWeight } from './bookFonts'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
import { useStudioStore } from '../../store/studioStore'
import { useTranslation } from '../../i18n'
import { STICKER_DRAG_TYPE, stickerDropped } from './stickerLibrary'
const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
@@ -196,6 +197,12 @@ export function StudioCanvas({
onPointerUp={finish}
onPointerCancel={finish}
onDragOver={e => {
// A sticker from its panel lands where it is let go; it fills no frame.
if (e.dataTransfer.types.includes(STICKER_DRAG_TYPE)) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
return
}
const fromPanel = e.dataTransfer.types.includes('application/x-trek-photo')
// A file drag is only claimed when somebody is there to take it; for a
// viewer the browser keeps its default and opens the picture itself.
@@ -209,6 +216,12 @@ export function StudioCanvas({
}}
onDragLeave={() => { setDropTarget(null); setFileOver(false) }}
onDrop={e => {
const sticker = stickerDropped(e.dataTransfer.getData(STICKER_DRAG_TYPE), page, single, pointInMm(e))
if (sticker) {
e.preventDefault()
addElement(spreadIndex, sticker)
return
}
const raw = e.dataTransfer.getData('application/x-trek-photo')
const files = Array.from(e.dataTransfer.files ?? [])
if (!raw && !(files.length && onDropFiles)) return
@@ -9,6 +9,7 @@ import { photoSrc } from './bookRender'
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
import { ShapeGlyph } from './StudioElementsPanel'
import { TravelInspector } from './StudioTravelInspector'
import { StudioStickerInspector } from './StudioStickerInspector'
import { Swatches } from './StudioSwatches'
import { StudioBackgroundSection } from './StudioBackgroundSection'
import { StudioShapePaint } from './StudioShapePaint'
@@ -615,6 +616,8 @@ export function StudioInspector({
</>
)}
{el.kind === 'sticker' && <StudioStickerInspector el={el} set={set} t={t} />}
<TravelInspector el={el} stats={stats} path={path} set={set} t={t} locale={locale} />
{/*
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Camera, Check, ChevronDown, ImagePlus, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate,
Plus, Search, Shapes, Trash2, Upload, X,
Plus, Search, Shapes, Sticker, Trash2, Upload, X,
} from 'lucide-react'
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
import { useElementSize } from '../../hooks/useElementSize'
@@ -21,6 +21,7 @@ import {
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { PanelHead as Head } from './StudioPanelHead'
import { StudioElementsPanel } from './StudioElementsPanel'
import { StudioStickersPanel } from './StudioStickersPanel'
import { StudioTravelPanel } from './StudioTravelPanel'
import { StudioSavedLayouts } from './StudioSavedLayouts'
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
@@ -39,7 +40,7 @@ import type { StudioUploader } from './studioUpload'
* costs 52px and gives the panel the room a photo browser actually needs.
*/
type Section = 'pages' | 'content' | 'elements' | 'travel' | 'templates'
type Section = 'pages' | 'content' | 'elements' | 'stickers' | 'travel' | 'templates'
export interface JourneySource {
entries: {
@@ -118,6 +119,7 @@ export function StudioSidebar({
{ id: 'pages', icon: Files, labelKey: 'journey.studio.pages' },
{ id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' },
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' },
{ id: 'stickers', icon: Sticker, labelKey: 'journey.studio.stickers' },
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
]
@@ -146,6 +148,7 @@ export function StudioSidebar({
providers={providers} onBrowseProvider={onBrowseProvider} />
)}
{section === 'elements' && <StudioElementsPanel page={page} t={t} />}
{section === 'stickers' && <StudioStickersPanel page={page} t={t} />}
{section === 'travel' && (
<StudioTravelPanel
page={page}
@@ -0,0 +1,103 @@
import { bookStickerSlotColor, type BookElement, type BookStickerElement } from '@trek/shared';
import { bookSticker } from '@trek/shared/stickers';
import { RotateCcw, RotateCw } from 'lucide-react';
import { Line, NumField, Section, Switch } from './StudioControls';
import { Swatches } from './StudioSwatches';
import { stickerLabel, turned, withSlotColor } from './stickerLibrary';
/**
* The inspector's part for a sticker: its colours slot by slot, and how it
* lies on the page (mirrored, turned, faded).
*
* Every slot is offered with the colour it shows now, the sticker's own until
* somebody picks another, and choosing one writes the colours of the slots
* before it as well, so the list the document keeps always reads in slot order.
* Turning is here as well as in the folded position numbers because a sticker
* is the one element people set at an angle as a matter of course.
*/
type Translate = (k: string, params?: Record<string, string | number>) => string;
/** A quarter of a right angle and a little: what a sticker is tipped by, a step at a time. */
const TURN_STEP = 15;
export function StudioStickerInspector({
el,
set,
t,
}: {
el: BookStickerElement;
set: (patch: Partial<BookElement>) => void;
t: Translate;
}) {
const def = bookSticker(el.stickerId);
return (
<>
<Section
label={t('journey.studio.stickerColours')}
hint={def ? stickerLabel(t, def.id) : t('journey.studio.stickerMissing')}
>
{def?.slots.map((slot, i) => (
<Line key={slot.name} label={t('journey.studio.stickerSlot', { index: i + 1 })} wide>
<Swatches
value={bookStickerSlotColor(def.slots, el.colors, i)}
onPick={(color) => set({ colors: withSlotColor(el, def.slots, i, color) })}
/>
</Line>
))}
{el.colors.length > 0 && (
<div className="st-row" style={{ marginTop: 8 }}>
<button type="button" className="st-chip" onClick={() => set({ colors: [] })}>
{t('journey.studio.stickerResetColours')}
</button>
</div>
)}
</Section>
<Section label={t('journey.studio.stickerPlacement')}>
<Switch label={t('journey.studio.stickerFlip')} on={el.flipX} onToggle={() => set({ flipX: !el.flipX })} />
<div className="st-row" style={{ marginTop: 10, alignItems: 'flex-end' }}>
<button
type="button"
className="st-chip is-icon"
onClick={() => set({ rotation: turned(el.rotation, -TURN_STEP) })}
title={t('journey.studio.stickerTurnLeft')}
aria-label={t('journey.studio.stickerTurnLeft')}
>
<RotateCcw size={14} />
</button>
<button
type="button"
className="st-chip is-icon"
onClick={() => set({ rotation: turned(el.rotation, TURN_STEP) })}
title={t('journey.studio.stickerTurnRight')}
aria-label={t('journey.studio.stickerTurnRight')}
>
<RotateCw size={14} />
</button>
</div>
<div className="st-grid2" style={{ marginTop: 10 }}>
<NumField
label={t('journey.studio.rotation')}
value={el.rotation}
min={-180}
max={180}
step={1}
unit="°"
onChange={(v) => set({ rotation: v })}
/>
<NumField
label={t('journey.studio.opacity')}
value={Math.round(el.opacity * 100)}
min={0}
max={100}
step={5}
unit="%"
onChange={(v) => set({ opacity: Math.min(1, Math.max(0, v / 100)) })}
/>
</div>
</Section>
</>
);
}
@@ -0,0 +1,160 @@
import { BOOK_STICKER_THEMES, type BookPageSetup, type BookStickerTheme } from '@trek/shared';
import { bookStickersOf, type BookStickerDef } from '@trek/shared/stickers';
import { Search, X } from 'lucide-react';
import { useMemo, useState, type DragEvent } from 'react';
import { useStudioStore } from '../../store/studioStore';
import { StickerArt } from './StickerView';
import { PanelHead } from './StudioPanelHead';
import {
STICKER_DRAG_TYPE,
matchStickers,
newStickerElement,
readRecentStickers,
rememberSticker,
stickerLabel,
stickerThemeLabel,
} from './stickerLibrary';
/**
* The sticker library, as a section of the rail: illustrations to put on a
* page, grouped by theme, searchable in the reader's language, with the ones
* this viewer placed last on a shelf of their own.
*
* A click places a sticker in the middle of the page, a drag places it where
* it is let go. Either way it arrives selected, so the inspector beside the
* page is where it is recoloured, flipped and turned.
*/
type Translate = (k: string, params?: Record<string, string | number>) => string;
function StickerTile({ def, t, onPick }: { def: BookStickerDef; t: Translate; onPick: (def: BookStickerDef) => void }) {
const label = stickerLabel(t, def.id);
const onDragStart = (e: DragEvent<HTMLButtonElement>) => {
e.dataTransfer.setData(STICKER_DRAG_TYPE, def.id);
e.dataTransfer.effectAllowed = 'copy';
};
return (
<button
type="button"
className="st-shape-btn is-glyph"
draggable
onDragStart={onDragStart}
onClick={() => onPick(def)}
title={label}
aria-label={label}
style={{ cursor: 'grab' }}
>
<StickerArt def={def} />
</button>
);
}
function StickerGrid({
list,
t,
onPick,
}: {
list: readonly BookStickerDef[];
t: Translate;
onPick: (def: BookStickerDef) => void;
}) {
return (
<div className="st-shape-grid">
{list.map((def) => (
<StickerTile key={def.id} def={def} t={t} onPick={onPick} />
))}
</div>
);
}
export function StudioStickersPanel({ page, t }: { page: BookPageSetup; t: Translate }) {
const [query, setQuery] = useState('');
const [theme, setTheme] = useState<BookStickerTheme | null>(null);
const [recent, setRecent] = useState<BookStickerDef[]>(readRecentStickers);
const addElement = useStudioStore((s) => s.addElement);
const active = useStudioStore((s) => s.activeSpread);
const role = useStudioStore((s) => s.doc?.spreads[s.activeSpread]?.role);
const single = role !== undefined && role !== 'inner';
const matches = useMemo(() => matchStickers(query, theme, t), [query, theme, t]);
const browsing = !query.trim() && theme === null;
const place = (def: BookStickerDef) => {
addElement(active, newStickerElement(def, page, single));
setRecent(rememberSticker(def.id));
};
return (
<>
<PanelHead label={t('journey.studio.stickers')} count={browsing ? undefined : matches.length} />
<div className="st-search">
<Search size={14} />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('journey.studio.searchStickers')}
aria-label={t('journey.studio.searchStickers')}
spellCheck={false}
/>
{query && (
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
<div className="st-panel-scroll">
<div className="st-section">
<div className="st-row" role="radiogroup" aria-label={t('journey.studio.stickerThemes')}>
<button
type="button"
role="radio"
aria-checked={theme === null}
className={`st-chip ${theme === null ? 'is-on' : ''}`}
onClick={() => setTheme(null)}
>
{t('journey.studio.stickersAll')}
</button>
{BOOK_STICKER_THEMES.map((id) => (
<button
type="button"
key={id}
role="radio"
aria-checked={theme === id}
className={`st-chip ${theme === id ? 'is-on' : ''}`}
onClick={() => setTheme(theme === id ? null : id)}
>
{stickerThemeLabel(t, id)}
</button>
))}
</div>
</div>
{browsing && recent.length > 0 && (
<div className="st-section">
<div className="st-section-label">{t('journey.studio.stickersRecent')}</div>
<StickerGrid list={recent} t={t} onPick={place} />
</div>
)}
{/* Browsing, the library by theme; searching or filtered, one grid of what matches. */}
{browsing ? (
BOOK_STICKER_THEMES.map((id) => (
<div className="st-section" key={id}>
<div className="st-section-label">{stickerThemeLabel(t, id)}</div>
<StickerGrid list={bookStickersOf(id)} t={t} onPick={place} />
</div>
))
) : (
<div className="st-section">
<StickerGrid list={matches} t={t} onPick={place} />
{!matches.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
</div>
)}
<p className="st-hint">{t('journey.studio.stickersHint')}</p>
</div>
</>
);
}
@@ -0,0 +1,111 @@
import { BOOK_STICKER_THEMES } from '@trek/shared';
import en from '@trek/shared/i18n/en';
import { BOOK_STICKERS, bookSticker } from '@trek/shared/stickers';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
MAX_RECENT_STICKERS,
matchStickers,
newStickerElement,
readRecentStickers,
rememberSticker,
stickerDropped,
stickerLabel,
stickerThemeLabel,
} from './stickerLibrary';
/**
* The Studio's side of the sticker library: every sticker and theme has a name
* in en (the key the i18n check lets through as a template, see
* client/scripts/i18n-keys.mjs), the search finds a sticker by its name in the
* reader's language and by the library's English tags, the recent shelf
* survives a reload and shrugs off storage it cannot use, and a new sticker
* lands where it was asked to.
*/
const page = { pageWidth: 210, pageHeight: 210 };
const echo = (k: string) => k;
const strings = en as Record<string, string>;
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
describe('names', () => {
it('names every sticker of the library and every theme in en', () => {
for (const s of BOOK_STICKERS) expect(strings[stickerLabel(echo, s.id)], s.id).toEqual(expect.any(String));
for (const theme of BOOK_STICKER_THEMES) {
expect(strings[stickerThemeLabel(echo, theme)], theme).toEqual(expect.any(String));
}
});
});
describe('matchStickers', () => {
/** The en names, but the lemon in German: the reader's word for one sticker. */
const german = (k: string) => (k === 'journey.studio.sticker.lemon' ? 'Zitrone' : (strings[k] ?? k));
it('lists a theme, or the whole library, when nothing is typed', () => {
expect(matchStickers('', null, echo)).toHaveLength(BOOK_STICKERS.length);
expect(matchStickers(' ', 'food', echo).every((s) => s.theme === 'food')).toBe(true);
});
it('finds a sticker by its name in the reader language first, then by the English tags', () => {
expect(matchStickers('zitrone', null, german)[0]?.id).toBe('lemon');
// The names that say lemon come first, then what the English tags find, the lemon itself among them.
const english = matchStickers('lemon', null, german).map((s) => s.id);
expect(english.slice(0, 2)).toEqual(['lemonBranch', 'lemonVase']);
expect(english).toContain('lemon');
expect(new Set(english).size).toBe(english.length);
expect(matchStickers('lemon', 'food', german)).toEqual([]);
});
});
describe('the recent shelf', () => {
it('keeps the stickers placed last, newest first, without repeats, up to its size', () => {
expect(readRecentStickers()).toEqual([]);
rememberSticker('lemon');
rememberSticker('sun');
expect(rememberSticker('lemon').map((s) => s.id)).toEqual(['lemon', 'sun']);
for (const s of BOOK_STICKERS.slice(0, 12)) rememberSticker(s.id);
expect(readRecentStickers()).toHaveLength(MAX_RECENT_STICKERS);
expect(readRecentStickers()[0]?.id).toBe(BOOK_STICKERS[11]?.id);
});
it('starts empty from storage it did not write or cannot read, and leaves out ids it does not know', () => {
window.localStorage.setItem('trek.studio.recentStickers', '{nope');
expect(readRecentStickers()).toEqual([]);
window.localStorage.setItem('trek.studio.recentStickers', '{"a":1}');
expect(readRecentStickers()).toEqual([]);
window.localStorage.setItem('trek.studio.recentStickers', '["sun","unicornCastle",3]');
expect(readRecentStickers().map((s) => s.id)).toEqual(['sun']);
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('denied');
});
expect(readRecentStickers()).toEqual([]);
expect(rememberSticker('sun').map((s) => s.id)).toEqual(['sun']);
});
});
describe('a new sticker', () => {
const lemon = bookSticker('lemon');
if (!lemon) throw new Error('the library holds a lemon');
it('arrives centred on the right-hand page of a spread, or on a single page, at the panel size', () => {
const onSpread = newStickerElement(lemon, page, false);
expect(onSpread).toMatchObject({ kind: 'sticker', stickerId: 'lemon', colors: [], flipX: false });
expect(onSpread.frame).toEqual({ x: 283.5, y: 79.8, w: 63, h: 50.4 });
expect(newStickerElement(lemon, page, true).frame.x).toBe(73.5);
expect(newStickerElement(lemon, page, false, { x: 50, y: 50 }).frame).toMatchObject({ x: 18.5, y: 24.8 });
});
it('comes from a drop that carries a sticker the library holds, and goes on the shelf', () => {
expect(stickerDropped('', page, false, { x: 0, y: 0 })).toBeNull();
expect(stickerDropped('unicornCastle', page, false, { x: 0, y: 0 })).toBeNull();
expect(stickerDropped('sun', page, true, { x: 100, y: 100 })?.frame).toMatchObject({ x: 68.5, y: 68.5 });
expect(readRecentStickers().map((s) => s.id)).toEqual(['sun']);
});
});
@@ -0,0 +1,145 @@
import {
bookElementId,
bookStickerElement,
bookStickerSize,
bookStickerSlotColor,
type BookPageSetup,
type BookStickerElement,
type BookStickerSlot,
type BookStickerTheme,
} from '@trek/shared';
import { BOOK_STICKERS, bookSticker, searchBookStickers, type BookStickerDef } from '@trek/shared/stickers';
/**
* The Studio's side of the sticker library: the words each sticker is shown
* and found by in the reader's language, the shelf of recently used ones, and
* a new sticker element where it was clicked or dropped.
*
* The drawings themselves come from `@trek/shared/stickers`, which only the
* Studio's chunk imports (this file, StickerView, the panel), so the library
* arrives with the editor and never with the app shell.
*/
/** Translates a key; the panel and the inspector pass the Studio's `t`. */
type Translate = (key: string) => string;
/** The drag the panel starts and the canvas takes: the sticker id as plain text under its own type. */
export const STICKER_DRAG_TYPE = 'application/x-trek-sticker';
/**
* A sticker's name in the reader's language (studioStickers.ts in every
* locale): every id of the library has one, which stickerLibrary.test.ts holds.
*/
export const stickerLabel = (t: Translate, id: string) => t(`journey.studio.sticker.${id}`);
/** A theme's name in the reader's language. */
export const stickerThemeLabel = (t: Translate, theme: BookStickerTheme) => t(`journey.studio.stickerTheme.${theme}`);
const fold = (s: string) => s.toLocaleLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
/**
* Stickers matching what was typed, in one theme or all: the reader's own word
* for a sticker first (its label), then the library's English search over ids,
* tags and descriptions, so "Zitrone" finds the lemon in German and "lemon"
* finds it everywhere.
*/
export function matchStickers(query: string, theme: BookStickerTheme | null, t: Translate): BookStickerDef[] {
const words = fold(query.trim());
const pool = theme ? BOOK_STICKERS.filter((s) => s.theme === theme) : BOOK_STICKERS;
if (!words) return [...pool];
const byLabel = pool.filter((s) => fold(stickerLabel(t, s.id)).includes(words));
const byTags = searchBookStickers({ query: words, theme: theme ?? undefined });
const seen = new Set(byLabel.map((s) => s.id));
return [...byLabel, ...byTags.filter((s) => !seen.has(s.id))];
}
const RECENT_STORAGE = 'trek.studio.recentStickers';
/** How many recently used stickers the shelf holds: one row of the panel's grid, three times over. */
export const MAX_RECENT_STICKERS = 9;
/** The stickers this viewer placed last, newest first; empty where storage is refused or holds nonsense. */
export function readRecentStickers(): BookStickerDef[] {
let ids: unknown;
try {
ids = JSON.parse(window.localStorage.getItem(RECENT_STORAGE) ?? '[]');
} catch {
// Storage refused (a private window) or a value it did not write: the shelf starts empty.
return [];
}
if (!Array.isArray(ids)) return [];
return ids.flatMap((id) => {
const def = typeof id === 'string' ? bookSticker(id) : undefined;
return def ? [def] : [];
});
}
/** Puts a sticker at the front of the recent shelf and answers the shelf as it is now. */
export function rememberSticker(id: string): BookStickerDef[] {
const next = [
id,
...readRecentStickers()
.map((s) => s.id)
.filter((other) => other !== id),
].slice(0, MAX_RECENT_STICKERS);
try {
window.localStorage.setItem(RECENT_STORAGE, JSON.stringify(next));
} catch {
// Storage refused: the shelf is this visit's only, which is all it promises.
}
return next.flatMap((x) => bookSticker(x) ?? []);
}
/**
* A new sticker element at the panel's size: centred on `at` (where it was
* dropped), else on the page, the right-hand one of a double spread so it
* never lands in the fold.
*/
export function newStickerElement(
def: BookStickerDef,
page: Pick<BookPageSetup, 'pageWidth' | 'pageHeight'>,
single: boolean,
at?: { x: number; y: number }
): BookStickerElement {
const { w, h } = bookStickerSize(def.box, page);
const cx = at?.x ?? (single ? page.pageWidth / 2 : page.pageWidth * 1.5);
const cy = at?.y ?? page.pageHeight / 2;
const round = (v: number) => Math.round(v * 100) / 100;
return bookStickerElement(def, { x: round(cx - w / 2), y: round(cy - h / 2), w, h }, bookElementId('k'));
}
/**
* The sticker a drop onto the page carries, centred where it was let go and
* put on the recent shelf; null for a drop that carries none (a photo, a file)
* or an id the library does not hold.
*/
export function stickerDropped(
id: string,
page: Pick<BookPageSetup, 'pageWidth' | 'pageHeight'>,
single: boolean,
at: { x: number; y: number }
): BookStickerElement | null {
const def = id ? bookSticker(id) : undefined;
if (!def) return null;
rememberSticker(def.id);
return newStickerElement(def, page, single, at);
}
/** Turned by `by` degrees, kept between -180 and 180 the way the canvas keeps it. */
export function turned(rotation: number, by: number): number {
let next = (rotation + by) % 360;
if (next > 180) next -= 360;
if (next < -180) next += 360;
return Math.round(next * 10) / 10;
}
/** The colours with slot `slot` set to `color`, the slots before it filled with what they show now. */
export function withSlotColor(
el: BookStickerElement,
slots: readonly BookStickerSlot[],
slot: number,
color: string
): string[] {
const named = slots.map((_, i) => bookStickerSlotColor(slots, el.colors, i));
named[slot] = color;
return named.slice(0, Math.max(el.colors.length, slot + 1));
}