9f83962bf7
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Per the owner's idea: instead of moving the banner out of the per-screen header (which would change its position), remember the banner's "life stage" and resume it on the next screen. The engine already keeps the message + rotation timing; this adds the scroll offset: - bannerEngine tracks the in-flight scroll (target, duration, start). On attach, if a scroll is still running, it computes the current offset and calls the new host's resumeScroll(fromTx, toPx, remaining) — the view jumps to the carried offset and continues to the end over the remaining time, instead of restarting at the left. - A finished scroll is left at its end; the rotator's own loop then takes over. Verified: spot-checked in the browser (a long message at offset -785 resumes at -788 on the next screen, not 0) and unit-tested (attach mid-scroll calls resumeScroll with a partial offset and the remaining duration).
111 lines
4.4 KiB
TypeScript
111 lines
4.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 = '';
|
|
// The in-flight horizontal scroll of the current message, so a view mounted by navigation can
|
|
// resume it from the same offset instead of restarting at the left. Null when not scrolling.
|
|
let activeScroll: { toPx: number; dur: number; start: number } | null = null;
|
|
|
|
// proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view
|
|
// can resume them) and forwards every effect to the currently-attached DOM host, if any.
|
|
const proxy: BannerHost = {
|
|
show(md) {
|
|
current = md;
|
|
activeScroll = null;
|
|
mounted?.show(md);
|
|
},
|
|
resetScroll() {
|
|
activeScroll = null;
|
|
mounted?.resetScroll();
|
|
},
|
|
hide(durationMs) {
|
|
mounted?.hide(durationMs);
|
|
},
|
|
overflowPx() {
|
|
return mounted?.overflowPx() ?? 0;
|
|
},
|
|
scrollTo(toPx, durationMs) {
|
|
activeScroll = { toPx, dur: durationMs, start: Date.now() };
|
|
mounted?.scrollTo(toPx, durationMs);
|
|
},
|
|
resumeScroll(fromTx, toPx, durationMs) {
|
|
mounted?.resumeScroll(fromTx, 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;
|
|
// Resume the current message's scroll from where it had reached, so navigation does not restart
|
|
// a long message at the left. Only while a scroll is still in flight; a finished scroll is left
|
|
// at its end and the rotator's own loop takes over.
|
|
if (activeScroll) {
|
|
const elapsed = Date.now() - activeScroll.start;
|
|
if (elapsed < activeScroll.dur) {
|
|
const progress = elapsed / activeScroll.dur;
|
|
host.resumeScroll(-activeScroll.toPx * progress, activeScroll.toPx, activeScroll.dur - elapsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|