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:
+14
-10
@@ -209,16 +209,20 @@ the profile to show or hide it in place.
|
|||||||
|
|
||||||
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
|
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
|
||||||
live outside the components, so navigating between screens (which remounts the view) **continues the
|
live outside the components, so navigating between screens (which remounts the view) **continues the
|
||||||
cycle** rather than restarting it — each mounted `AdBanner` only attaches as the DOM host and resyncs
|
cycle** rather than restarting it. A mounted `AdBanner` reads the engine's live message at init and
|
||||||
to the live message. A **smooth weighted round-robin** (`createScheduler`) picks the next message:
|
renders it **immediately, without a fade** (`bannerCurrent`), and attaches only as the DOM host — so
|
||||||
campaigns compete by their weight (each appears its weight share per cycle, evenly interleaved, not
|
a screen change does not replay the fade; only a real message advance fades. A **smooth weighted
|
||||||
at random), and a campaign's own messages advance round-robin. One message fades in (`fadeInMs`),
|
round-robin** (`createScheduler`) picks the next message: campaigns compete by their weight (each
|
||||||
holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its right edge at
|
appears its weight share per cycle, evenly interleaved, not at random), and a campaign's own messages
|
||||||
`scrollPxPerSec`, pauses, and repeats while under `holdMs`), then — with more than one message —
|
advance round-robin. One message fades in (`fadeInMs`), holds `holdMs` (a message wider than the
|
||||||
fades out (`fadeOutMs`), waits `gapMs`, and the next fades in. The fade (a `{#if}` `transition:fade`
|
strip pauses `edgePauseMs`, scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats while
|
||||||
layer) is independent of the scroll (the inner track's transform), so a long, scrolling message
|
under `holdMs`), then — with more than one message — fades out (`fadeOutMs`), waits `gapMs`, and the
|
||||||
still fades at both ends. A lone message stays put (a long one keeps scrolling). All timings are
|
next fades in. The fade (opacity on a `.fadewrap` layer) is independent of the scroll (the inner
|
||||||
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
|
track's transform), so a long, scrolling message still fades at both ends. A lone message stays put
|
||||||
|
(a long one keeps scrolling). A **viewport size change** (e.g. a portrait↔landscape rotation)
|
||||||
|
re-measures the current message (`remeasureBanner`, debounced), so its scroll re-evaluates for the
|
||||||
|
new width. All timings are operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades
|
||||||
|
collapse to an instant swap
|
||||||
and a long message does not scroll.
|
and a long message does not scroll.
|
||||||
|
|
||||||
## Result / status iconography (`lib/result.ts`)
|
## Result / status iconography (`lib/result.ts`)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import {
|
||||||
import { attachBannerHost, configureBanner, detachBannerHost } from '../lib/bannerEngine';
|
attachBannerHost,
|
||||||
|
bannerCurrent,
|
||||||
|
configureBanner,
|
||||||
|
detachBannerHost,
|
||||||
|
remeasureBanner,
|
||||||
|
} from '../lib/bannerEngine';
|
||||||
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
|
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
|
||||||
import type { BannerCampaign, BannerTimings } from '../lib/model';
|
import type { BannerCampaign, BannerTimings } from '../lib/model';
|
||||||
import { onExternalLinkClick } from '../lib/telegram';
|
import { onExternalLinkClick } from '../lib/telegram';
|
||||||
@@ -11,36 +16,42 @@
|
|||||||
reduceMotion = false,
|
reduceMotion = false,
|
||||||
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
|
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
|
||||||
|
|
||||||
let visible = $state(false);
|
// Initialise from the persistent engine's live message: a view mounted by navigation shows the
|
||||||
let current = $state('');
|
// 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 tx = $state(0);
|
||||||
let txDur = $state(0);
|
let txDur = $state(0);
|
||||||
let track = $state<HTMLElement>();
|
let track = $state<HTMLElement>();
|
||||||
let viewport = $state<HTMLElement>();
|
let viewport = $state<HTMLElement>();
|
||||||
|
|
||||||
// Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
|
// 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
|
// scroll. The engine is configured with the same effective timings, so the fade durations here
|
||||||
// transition durations here stay in step with the rotator's hold/transition scheduling.
|
// stay in step with the rotator's hold/transition scheduling.
|
||||||
const eff = $derived<BannerTimings>(
|
const eff = $derived<BannerTimings>(
|
||||||
reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
|
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
|
// The DOM host the engine drives. Opacity (fade) lives on the .fadewrap layer and the transform
|
||||||
// transition:fade — reliable and independent of the scroll), the scroll on the inner track's
|
// (scroll) on the inner track, so a long message's scroll never blocks its fade in/out. show()
|
||||||
// transform, so a long message's scroll never blocks its fade in/out.
|
// fades in from the (already painted) faded-out state left by the preceding hide().
|
||||||
const host: BannerHost = {
|
const host: BannerHost = {
|
||||||
show(md) {
|
show(md) {
|
||||||
current = md;
|
current = md;
|
||||||
tx = 0;
|
tx = 0;
|
||||||
txDur = 0;
|
txDur = 0;
|
||||||
visible = true;
|
opDur = eff.fadeInMs;
|
||||||
|
opacity = 1;
|
||||||
},
|
},
|
||||||
resetScroll() {
|
resetScroll() {
|
||||||
tx = 0;
|
tx = 0;
|
||||||
txDur = 0;
|
txDur = 0;
|
||||||
},
|
},
|
||||||
hide() {
|
hide() {
|
||||||
visible = false;
|
opDur = eff.fadeOutMs;
|
||||||
|
opacity = 0;
|
||||||
},
|
},
|
||||||
overflowPx() {
|
overflowPx() {
|
||||||
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
|
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
|
// Attach to the persistent engine on mount, detach on unmount (without stopping it). Declared
|
||||||
// unmount without stopping the engine, so navigation continues the cycle. Declared before the
|
// before configure so the host is attached when the rotator first measures overflow.
|
||||||
// configure effect so the host is attached when the rotator first measures overflow.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
attachBannerHost(host);
|
attachBannerHost(host);
|
||||||
return () => detachBannerHost(host);
|
return () => detachBannerHost(host);
|
||||||
});
|
});
|
||||||
// (Re)configure the engine when the campaigns or effective timings change; a no-op when
|
// (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(() => {
|
$effect(() => {
|
||||||
configureBanner(campaigns, eff);
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
|
<!-- 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_no_static_element_interactions -->
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
|
||||||
{#if visible}
|
<div class="fadewrap" style="opacity:{opacity}; transition:opacity {opDur}ms ease">
|
||||||
<div class="fadewrap" in:fade={{ duration: eff.fadeInMs }} out:fade={{ duration: eff.fadeOutMs }}>
|
<div
|
||||||
<div
|
class="track"
|
||||||
class="track"
|
bind:this={track}
|
||||||
bind:this={track}
|
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
|
||||||
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
|
>
|
||||||
>
|
{@html linkify(current)}
|
||||||
{@html linkify(current)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -96,9 +120,6 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
user-select: none;
|
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 {
|
.track {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
+51
-19
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
|
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
|
||||||
import { attachBannerHost, configureBanner, detachBannerHost } from './bannerEngine';
|
import { attachBannerHost, bannerCurrent, configureBanner, detachBannerHost, remeasureBanner } from './bannerEngine';
|
||||||
import type { BannerTimings } from './model';
|
import type { BannerTimings } from './model';
|
||||||
|
|
||||||
describe('linkify', () => {
|
describe('linkify', () => {
|
||||||
@@ -181,42 +181,74 @@ describe('bannerEngine', () => {
|
|||||||
|
|
||||||
const period = (c: BannerTimings) => c.fadeInMs + c.holdMs + c.fadeOutMs + c.gapMs + 5;
|
const period = (c: BannerTimings) => c.fadeInMs + c.holdMs + c.fadeOutMs + c.gapMs + 5;
|
||||||
|
|
||||||
it('continues the cycle across a host detach/attach (does not restart)', () => {
|
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
||||||
vi.useFakeTimers();
|
|
||||||
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
|
||||||
const camps = [{ weight: 1, messages: ['x0'] }, { weight: 1, messages: ['x1'] }];
|
|
||||||
const a = engineHost();
|
|
||||||
configureBanner(camps, cfg);
|
|
||||||
attachBannerHost(a.host);
|
|
||||||
expect(a.shown).toEqual(['x0']); // resynced to the live message
|
|
||||||
|
|
||||||
// Swap the host mid-cycle (as navigation remounts the view): the new host resyncs to the
|
it('keeps the live message across a host detach/attach, and keeps advancing (continuity)', () => {
|
||||||
// same live message rather than restarting at the first.
|
vi.useFakeTimers();
|
||||||
|
const camps = [{ weight: 1, messages: ['x0'] }, { weight: 1, messages: ['x1'] }];
|
||||||
|
configureBanner(camps, cfg);
|
||||||
|
const a = engineHost();
|
||||||
|
attachBannerHost(a.host);
|
||||||
|
// Advance mid-sequence so the live message is no longer the first.
|
||||||
|
vi.advanceTimersByTime(period(cfg));
|
||||||
|
expect(bannerCurrent()).toBe('x1');
|
||||||
|
|
||||||
|
// Remount the view (as navigation does): the live message is preserved (not reset to 'x0'),
|
||||||
|
// and a fresh view receives no replayed show() on attach (it reads bannerCurrent() itself).
|
||||||
detachBannerHost(a.host);
|
detachBannerHost(a.host);
|
||||||
const b = engineHost();
|
const b = engineHost();
|
||||||
attachBannerHost(b.host);
|
attachBannerHost(b.host);
|
||||||
expect(b.shown).toEqual(['x0']);
|
expect(bannerCurrent()).toBe('x1');
|
||||||
|
expect(b.shown).toEqual([]);
|
||||||
|
|
||||||
// The sequence advances to the next message on the new host (it continued, not restarted).
|
// The engine keeps advancing and drives the new host — the cycle continued, not restarted.
|
||||||
vi.advanceTimersByTime(period(cfg));
|
vi.advanceTimersByTime(period(cfg));
|
||||||
expect(b.shown).toContain('x1');
|
expect(b.shown).toContain('x0');
|
||||||
|
expect(bannerCurrent()).toBe('x0');
|
||||||
detachBannerHost(b.host);
|
detachBannerHost(b.host);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not restart on a no-op reconfigure with unchanged data', () => {
|
it('does not restart on a no-op reconfigure with unchanged data', () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
|
||||||
const camps = [{ weight: 1, messages: ['y0'] }, { weight: 1, messages: ['y1'] }];
|
const camps = [{ weight: 1, messages: ['y0'] }, { weight: 1, messages: ['y1'] }];
|
||||||
const a = engineHost();
|
|
||||||
configureBanner(camps, cfg);
|
configureBanner(camps, cfg);
|
||||||
|
const a = engineHost();
|
||||||
attachBannerHost(a.host);
|
attachBannerHost(a.host);
|
||||||
vi.advanceTimersByTime(period(cfg));
|
vi.advanceTimersByTime(period(cfg));
|
||||||
expect(a.shown).toEqual(['y0', 'y1']);
|
expect(bannerCurrent()).toBe('y1');
|
||||||
|
|
||||||
// Re-configuring with identical data keeps the running cycle (no fresh 'y0' burst).
|
// Re-configuring with identical data keeps the running cycle (the live message is unchanged).
|
||||||
configureBanner(camps, cfg);
|
configureBanner(camps, cfg);
|
||||||
vi.advanceTimersByTime(5);
|
vi.advanceTimersByTime(5);
|
||||||
expect(a.shown).toEqual(['y0', 'y1']);
|
expect(bannerCurrent()).toBe('y1');
|
||||||
detachBannerHost(a.host);
|
detachBannerHost(a.host);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('re-presents the current message on remeasure, engaging the scroll on a size change', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const big: BannerTimings = { ...cfg, holdMs: 5000 };
|
||||||
|
const shown: string[] = [];
|
||||||
|
let scrolled = 0;
|
||||||
|
let overflow = 0;
|
||||||
|
const host: BannerHost = {
|
||||||
|
show: (md) => shown.push(md),
|
||||||
|
resetScroll() {},
|
||||||
|
hide() {},
|
||||||
|
overflowPx: () => overflow,
|
||||||
|
scrollTo: () => scrolled++,
|
||||||
|
};
|
||||||
|
configureBanner([{ weight: 1, messages: ['z0'] }], big);
|
||||||
|
attachBannerHost(host);
|
||||||
|
vi.advanceTimersByTime(big.fadeInMs + 5); // initial measure: fits, no scroll
|
||||||
|
expect(scrolled).toBe(0);
|
||||||
|
|
||||||
|
// The viewport shrank so the message now overflows; remeasure re-presents it and the scroll
|
||||||
|
// engages (the owner accepts the cycle restarting on a size change).
|
||||||
|
overflow = 300;
|
||||||
|
remeasureBanner();
|
||||||
|
expect(shown).toContain('z0');
|
||||||
|
vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 5);
|
||||||
|
expect(scrolled).toBeGreaterThan(0);
|
||||||
|
detachBannerHost(host);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ export interface BannerHost {
|
|||||||
export interface Rotator {
|
export interface Rotator {
|
||||||
start(): void;
|
start(): void;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
|
/** Re-present the current message (re-measure overflow + restart its scroll), e.g. after the
|
||||||
|
* viewport size changed. A no-op before start(). */
|
||||||
|
restart(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,6 +94,7 @@ export function createBannerRotator(
|
|||||||
const sched = createScheduler(campaigns);
|
const sched = createScheduler(campaigns);
|
||||||
let running = false;
|
let running = false;
|
||||||
let cycleStart = 0;
|
let cycleStart = 0;
|
||||||
|
let lastShown = ''; // the message currently presented, for restart() (re-measure)
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
const at = (ms: number, fn: () => void) => {
|
const at = (ms: number, fn: () => void) => {
|
||||||
@@ -104,6 +108,7 @@ export function createBannerRotator(
|
|||||||
function present(md: string) {
|
function present(md: string) {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
clear();
|
clear();
|
||||||
|
lastShown = md;
|
||||||
host.show(md);
|
host.show(md);
|
||||||
at(config.fadeInMs, measure);
|
at(config.fadeInMs, measure);
|
||||||
}
|
}
|
||||||
@@ -151,6 +156,9 @@ export function createBannerRotator(
|
|||||||
running = false;
|
running = false;
|
||||||
clear();
|
clear();
|
||||||
},
|
},
|
||||||
|
restart() {
|
||||||
|
if (running && lastShown) present(lastShown);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,31 @@ export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* attachBannerHost connects a freshly-mounted view as the DOM host and resyncs it to the live
|
* bannerCurrent returns the message the engine is currently displaying (empty before the first
|
||||||
* message, so the banner resumes mid-cycle instead of restarting.
|
* 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 {
|
export function attachBannerHost(host: BannerHost): void {
|
||||||
mounted = host;
|
mounted = host;
|
||||||
if (current) host.show(current);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user