mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user