mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
8c6f5deab2
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.
146 lines
5.5 KiB
TypeScript
146 lines
5.5 KiB
TypeScript
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));
|
|
}
|