Files
scrabble-game/ui/src/lib/bannerEngine.ts
T
Ilia Denisov 5fb0daa746
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
fix(ads): banner truly continuous across navigation + re-measure on resize
The previous engine kept the scheduler running but the view re-`show()`-ed the
current message on every (re)mount, replaying the fade on each navigation — which
looked like the cycle restarting (especially for a single message). Now:

- A mounted AdBanner reads the engine's live message (bannerCurrent) and renders
  it immediately, with no fade; attach no longer re-shows. Only a real advance
  fades. Verified: opacity stays 1.0 across a navigation, message preserved.
- The fade is manual opacity on a .fadewrap layer (not transition:fade), kept
  independent of the scroll (inner track transform), so a long message still
  fades at both ends and a {#key} remount cannot force an intro fade.
- A viewport size change (portrait↔landscape) re-measures the current message
  (remeasureBanner on resize/orientationchange, debounced) so the scroll
  re-evaluates for the new width — the owner accepts the restart on resize.
  Rotator gains restart(); engine gains bannerCurrent()/remeasureBanner().

Engine continuity + remeasure unit-tested.
2026-06-16 05:45:38 +02:00

92 lines
3.4 KiB
TypeScript

// A single, persistent banner rotation engine. The rotator and scheduler live here at module
// scope, so the rotation keeps running across screen navigations: each screen mounts its own
// AdBanner view, which only attaches as the DOM host. This makes the banner continuous and
// independent of route transitions (the cycle is not restarted on navigation).
import { createBannerRotator, type BannerHost, type Rotator } from './banner';
import type { BannerCampaign, BannerTimings } from './model';
let rotator: Rotator | null = null;
let mounted: BannerHost | null = null;
let current = '';
let key = '';
// proxy is the rotator's host: it records the current message (so a freshly-mounted view can
// resync to it) and forwards every effect to the currently-attached DOM host, if any.
const proxy: BannerHost = {
show(md) {
current = md;
mounted?.show(md);
},
resetScroll() {
mounted?.resetScroll();
},
hide(durationMs) {
mounted?.hide(durationMs);
},
overflowPx() {
return mounted?.overflowPx() ?? 0;
},
scrollTo(toPx, durationMs) {
mounted?.scrollTo(toPx, durationMs);
},
};
// bannerKey identifies a campaigns+timings set so configureBanner restarts the cycle only on a
// real change, not on every (re)mount.
function bannerKey(campaigns: BannerCampaign[], timings: BannerTimings): string {
return JSON.stringify({ campaigns, timings });
}
/**
* configureBanner (re)starts the rotation for the given campaigns and timings, or leaves the
* running rotation untouched when they are unchanged — so navigating between screens (which
* remounts the view with the same data) continues the cycle rather than restarting it.
*/
export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimings): void {
const k = bannerKey(campaigns, timings);
if (k === key && rotator) return;
key = k;
rotator?.stop();
current = '';
rotator = createBannerRotator(campaigns, proxy, timings);
rotator.start();
}
/**
* bannerCurrent returns the message the engine is currently displaying (empty before the first
* one). A freshly-mounted view reads it to render the live message immediately, without a fade —
* so navigating between screens does not visibly restart the cycle.
*/
export function bannerCurrent(): string {
return current;
}
/**
* attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the
* current message (the view renders it itself from bannerCurrent on mount): re-showing here would
* replay the fade-in on every navigation, which looks like the cycle restarting. The engine's own
* timer keeps driving show/hide for real message changes through this host.
*/
export function attachBannerHost(host: BannerHost): void {
mounted = host;
}
/**
* remeasureBanner re-presents the current message (re-measuring overflow and restarting its
* scroll), for when the viewport size changed (e.g. a portrait↔landscape rotation) and a message
* that fit may now overflow, or vice versa.
*/
export function remeasureBanner(): void {
rotator?.restart();
}
/**
* detachBannerHost disconnects a view on unmount without stopping the engine. It clears the host
* only when it is still the current one, so an outgoing view leaving after the incoming view has
* already attached does not detach the new host (the transition briefly double-mounts).
*/
export function detachBannerHost(host: BannerHost): void {
if (mounted === host) mounted = null;
}