Files
scrabble-game/ui/src/lib/banner.ts
T
Ilia Denisov 38be7fea96 Stage 7 polish: app shell + nav + lobby + settings (Parts A/B/C)
- Screen.svelte shell: nav bar grows, ad+content+tabbar pinned bottom (mobile feel)
- AdBanner.svelte + banner.ts rotator (params, mock long/short, linkify); Header CSS chevron + grow; Menu (bigger CSS hamburger); TabBar + HoldConfirm shared components; user-select:none
- Lobby: hide-empty sections, tab order New/Tournaments/Stats, place-based result badges (result.ts)
- Settings: Board style > Labels (beginner/classic/none) + prefs plumbing (boardlabels.ts); i18n keys + ru mirror
2026-06-03 13:20:56 +02:00

158 lines
4.6 KiB
TypeScript

// Announcement / "ad" banner — a parameterised rotator plus a tiny markdown linkifier.
// The rotator is DOM-agnostic (the host measures overflow and applies the visual
// effects through callbacks), so its timing is unit-testable with fake timers. Today
// the content is a mock long↔short rotation; later it becomes a server-driven
// announcements channel (see ARCHITECTURE).
export interface BannerConfig {
/** How long one message is shown before advancing (short text), ms. */
holdMs: number;
/** Pause at each end before/after scrolling a long message, ms. */
edgePauseMs: number;
/** Scroll speed for a long (overflowing) message, px/sec. */
scrollPxPerSec: number;
/** Cross-fade duration between messages, ms. */
fadeMs: number;
}
export const defaultBannerConfig: BannerConfig = {
holdMs: 60_000,
edgePauseMs: 5_000,
scrollPxPerSec: 40,
fadeMs: 400,
};
export interface BannerItem {
/** Minimal markdown: plain text + `[label](url)` links. */
md: string;
}
/** The host the rotator drives; the Svelte component supplies the DOM measurements. */
export interface BannerHost {
/** Overflow width of item `index` in px (0 when it fits). */
overflowPx(index: number): number;
/** Render item `index` (the host fades it in and resets scroll to the start). */
show(index: number): void;
/** Animate the horizontal scroll to `toPx` over `durationMs`. */
scrollTo(toPx: number, durationMs: number): void;
}
export interface Rotator {
start(): void;
stop(): void;
}
/**
* createBannerRotator drives a list of messages: a fitting message holds `holdMs`
* then advances; an overflowing one pauses, scrolls to its right edge, pauses, then
* repeats while the elapsed cycle is under `holdMs`, else advances.
*/
export function createBannerRotator(
items: BannerItem[],
host: BannerHost,
config: BannerConfig = defaultBannerConfig,
): Rotator {
let index = 0;
let running = false;
let cycleStart = 0;
const timers: ReturnType<typeof setTimeout>[] = [];
const at = (ms: number, fn: () => void) => {
timers.push(setTimeout(fn, ms));
};
const clear = () => {
for (const t of timers) clearTimeout(t);
timers.length = 0;
};
function advance() {
if (!running) return;
index = (index + 1) % items.length;
present();
}
function present() {
if (!running) return;
clear();
host.show(index);
// Let the swapped-in message render before measuring its overflow.
at(config.fadeMs, () => {
const over = host.overflowPx(index);
if (over <= 0) {
at(config.holdMs, advance);
return;
}
cycleStart = Date.now();
scrollCycle(over);
});
}
function scrollCycle(over: number) {
const dur = (over / config.scrollPxPerSec) * 1000;
at(config.edgePauseMs, () => {
host.scrollTo(over, dur);
at(dur + config.edgePauseMs, () => {
if (Date.now() - cycleStart >= config.holdMs) {
advance();
} else {
host.show(index); // resets scroll to the start
scrollCycle(over);
}
});
});
}
return {
start() {
if (running || items.length === 0) return;
running = true;
index = 0;
present();
},
stop() {
running = false;
clear();
},
};
}
const URL_RE = /^(https?:\/\/|\/)/i;
function escapeHtml(s: string): string {
return s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]!);
}
/**
* linkify renders minimal markdown to a safe HTML string: everything is escaped, then
* `[label](url)` becomes a link (only http(s):// or root-relative URLs are allowed).
*/
export function linkify(md: string): string {
const parts: string[] = [];
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(md)) !== null) {
parts.push(escapeHtml(md.slice(last, m.index)));
const label = escapeHtml(m[1]);
const url = m[2].trim();
if (URL_RE.test(url)) {
parts.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`);
} else {
parts.push(label);
}
last = re.lastIndex;
}
parts.push(escapeHtml(md.slice(last)));
return parts.join('');
}
/** mockBanners is the placeholder rotation (long ↔ short) to demo the mechanics. */
export function mockBanners(): BannerItem[] {
return [
{ md: 'New season starts soon — [learn more](https://example.com/season).' },
{
md: 'Tip: a 7-tile play earns a +50 bonus. Try the daily tournament, climb the leaderboard, and challenge friends — more modes are coming, [stay tuned](https://example.com/news)!',
},
];
}