fix(ads): banner truly continuous across navigation + re-measure on resize
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
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
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.
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import { attachBannerHost, configureBanner, detachBannerHost } from '../lib/bannerEngine';
|
||||
import {
|
||||
attachBannerHost,
|
||||
bannerCurrent,
|
||||
configureBanner,
|
||||
detachBannerHost,
|
||||
remeasureBanner,
|
||||
} from '../lib/bannerEngine';
|
||||
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
|
||||
import type { BannerCampaign, BannerTimings } from '../lib/model';
|
||||
import { onExternalLinkClick } from '../lib/telegram';
|
||||
@@ -11,36 +16,42 @@
|
||||
reduceMotion = false,
|
||||
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
|
||||
|
||||
let visible = $state(false);
|
||||
let current = $state('');
|
||||
// Initialise from the persistent engine's live message: a view mounted by navigation shows the
|
||||
// current message immediately (opacity 1, no transition), so a screen change does not replay the
|
||||
// fade — only a real message advance fades. Empty on the very first mount (engine not started).
|
||||
let current = $state(bannerCurrent());
|
||||
let opacity = $state(bannerCurrent() ? 1 : 0);
|
||||
let opDur = $state(0);
|
||||
let tx = $state(0);
|
||||
let txDur = $state(0);
|
||||
let track = $state<HTMLElement>();
|
||||
let viewport = $state<HTMLElement>();
|
||||
|
||||
// Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
|
||||
// scroll. The rotation engine is configured with the same effective timings, so the fade
|
||||
// transition durations here stay in step with the rotator's hold/transition scheduling.
|
||||
// scroll. The engine is configured with the same effective timings, so the fade durations here
|
||||
// stay in step with the rotator's hold/transition scheduling.
|
||||
const eff = $derived<BannerTimings>(
|
||||
reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
|
||||
);
|
||||
|
||||
// The DOM host the rotation engine drives. The fade lives on the {#if visible} layer (Svelte's
|
||||
// transition:fade — reliable and independent of the scroll), the scroll on the inner track's
|
||||
// transform, so a long message's scroll never blocks its fade in/out.
|
||||
// The DOM host the engine drives. Opacity (fade) lives on the .fadewrap layer and the transform
|
||||
// (scroll) on the inner track, so a long message's scroll never blocks its fade in/out. show()
|
||||
// fades in from the (already painted) faded-out state left by the preceding hide().
|
||||
const host: BannerHost = {
|
||||
show(md) {
|
||||
current = md;
|
||||
tx = 0;
|
||||
txDur = 0;
|
||||
visible = true;
|
||||
opDur = eff.fadeInMs;
|
||||
opacity = 1;
|
||||
},
|
||||
resetScroll() {
|
||||
tx = 0;
|
||||
txDur = 0;
|
||||
},
|
||||
hide() {
|
||||
visible = false;
|
||||
opDur = eff.fadeOutMs;
|
||||
opacity = 0;
|
||||
},
|
||||
overflowPx() {
|
||||
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
|
||||
@@ -51,18 +62,33 @@
|
||||
},
|
||||
};
|
||||
|
||||
// Attach this view to the persistent engine on mount (and resync to the live message); detach on
|
||||
// unmount without stopping the engine, so navigation continues the cycle. Declared before the
|
||||
// configure effect so the host is attached when the rotator first measures overflow.
|
||||
// Attach to the persistent engine on mount, detach on unmount (without stopping it). Declared
|
||||
// before configure so the host is attached when the rotator first measures overflow.
|
||||
$effect(() => {
|
||||
attachBannerHost(host);
|
||||
return () => detachBannerHost(host);
|
||||
});
|
||||
// (Re)configure the engine when the campaigns or effective timings change; a no-op when
|
||||
// unchanged, so a remount does not restart the rotation.
|
||||
// unchanged, so a remount continues the running cycle rather than restarting it.
|
||||
$effect(() => {
|
||||
configureBanner(campaigns, eff);
|
||||
});
|
||||
// Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
|
||||
// may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
|
||||
$effect(() => {
|
||||
let t: ReturnType<typeof setTimeout>;
|
||||
const onResize = () => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(remeasureBanner, 250);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('orientationchange', onResize);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('orientationchange', onResize);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
|
||||
@@ -70,17 +96,15 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
||||
{#if visible}
|
||||
<div class="fadewrap" in:fade={{ duration: eff.fadeInMs }} out:fade={{ duration: eff.fadeOutMs }}>
|
||||
<div
|
||||
class="track"
|
||||
bind:this={track}
|
||||
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
|
||||
>
|
||||
{@html linkify(current)}
|
||||
</div>
|
||||
<div class="fadewrap" style="opacity:{opacity}; transition:opacity {opDur}ms ease">
|
||||
<div
|
||||
class="track"
|
||||
bind:this={track}
|
||||
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
|
||||
>
|
||||
{@html linkify(current)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -96,9 +120,6 @@
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
user-select: none;
|
||||
/* A stable height so the strip does not collapse during the fade gap (the message layer is
|
||||
removed between fade-out and fade-in). */
|
||||
min-height: calc(0.85rem * 1.2 + 12px);
|
||||
}
|
||||
.track {
|
||||
display: inline-block;
|
||||
|
||||
Reference in New Issue
Block a user