From 9f83962bf75e6a60e8853e28087754e54b3d2ab5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 16 Jun 2026 06:26:12 +0200 Subject: [PATCH] feat(ads): carry the banner scroll position across navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui/src/components/AdBanner.svelte | 10 ++++++++++ ui/src/lib/banner.test.ts | 28 +++++++++++++++++++++++++++- ui/src/lib/banner.ts | 4 ++++ ui/src/lib/bannerEngine.ts | 23 +++++++++++++++++++++-- 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index c5b5a05..66e878f 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -70,6 +70,16 @@ txDur = durationMs; tx = -toPx; }, + resumeScroll(fromTx, toPx, durationMs) { + // Jump to the carried-over offset instantly, then continue to the end over the remaining + // time — so a long message keeps its scroll position across a navigation. + txDur = 0; + tx = fromTx; + requestAnimationFrame(() => { + txDur = durationMs; + tx = -toPx; + }); + }, }; // Attach to the persistent engine on mount, detach on unmount (without stopping it). Declared diff --git a/ui/src/lib/banner.test.ts b/ui/src/lib/banner.test.ts index 6eb0aa8..d0b94f1 100644 --- a/ui/src/lib/banner.test.ts +++ b/ui/src/lib/banner.test.ts @@ -78,6 +78,7 @@ function recordingHost(overflow = 0): { host: BannerHost; calls: string[]; shown hide: () => calls.push('hide'), overflowPx: () => overflow, scrollTo: () => calls.push('scroll'), + resumeScroll: () => calls.push('resume'), }, }; } @@ -175,7 +176,7 @@ describe('bannerEngine', () => { const shown: string[] = []; return { shown, - host: { show: (md) => shown.push(md), resetScroll() {}, hide() {}, overflowPx: () => 0, scrollTo() {} }, + host: { show: (md) => shown.push(md), resetScroll() {}, hide() {}, overflowPx: () => 0, scrollTo() {}, resumeScroll() {} }, }; } @@ -236,6 +237,7 @@ describe('bannerEngine', () => { hide() {}, overflowPx: () => overflow, scrollTo: () => scrolled++, + resumeScroll() {}, }; configureBanner([{ weight: 1, messages: ['z0'] }], big); attachBannerHost(host); @@ -251,4 +253,28 @@ describe('bannerEngine', () => { expect(scrolled).toBeGreaterThan(0); detachBannerHost(host); }); + + it('resumes a long message scroll mid-flight on a new host (navigation)', () => { + vi.useFakeTimers(); + const big: BannerTimings = { holdMs: 10000, edgePauseMs: 100, scrollPxPerSec: 100, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 }; + const noop: BannerHost = { show() {}, resetScroll() {}, hide() {}, overflowPx: () => 500, scrollTo() {}, resumeScroll() {} }; + let resumed: { fromTx: number; toPx: number; dur: number } | null = null; + const b: BannerHost = { ...noop, resumeScroll: (fromTx, toPx, dur) => (resumed = { fromTx, toPx, dur }) }; + + configureBanner([{ weight: 1, messages: ['scrollme'] }], big); + attachBannerHost(noop); + // fadeIn(100) + edgePause(100) -> scrollTo (dur = 500/100*1000 = 5000ms); then 2000ms into it. + vi.advanceTimersByTime(big.fadeInMs + big.edgePauseMs + 2000); + + // Remount mid-scroll: the new host resumes from ~40% of the way, not from the start. + detachBannerHost(noop); + attachBannerHost(b); + expect(resumed).not.toBeNull(); + expect(resumed!.toPx).toBe(500); + expect(resumed!.fromTx).toBeLessThan(0); // already scrolled left of 0 + expect(resumed!.fromTx).toBeGreaterThan(-500); // but not yet at the end + expect(resumed!.dur).toBeGreaterThan(0); + expect(resumed!.dur).toBeLessThan(5000); // only the remaining time + detachBannerHost(b); + }); }); diff --git a/ui/src/lib/banner.ts b/ui/src/lib/banner.ts index 9e3ded5..318c6f0 100644 --- a/ui/src/lib/banner.ts +++ b/ui/src/lib/banner.ts @@ -68,6 +68,10 @@ export interface BannerHost { overflowPx(): number; /** Animate the horizontal scroll to toPx over durationMs. */ scrollTo(toPx: number, durationMs: number): void; + /** Resume an in-flight scroll on a freshly-mounted view: jump to fromTx (px, signed) instantly, + * then continue to toPx over durationMs. Used by the engine on attach to carry the scroll + * position across a navigation; the rotator itself never calls it. */ + resumeScroll(fromTx: number, toPx: number, durationMs: number): void; } export interface Rotator { diff --git a/ui/src/lib/bannerEngine.ts b/ui/src/lib/bannerEngine.ts index 1ea25f8..4fcd0b2 100644 --- a/ui/src/lib/bannerEngine.ts +++ b/ui/src/lib/bannerEngine.ts @@ -10,15 +10,20 @@ 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 (so a freshly-mounted view can -// resync to it) and forwards every effect to the currently-attached DOM host, if any. +// 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) { @@ -28,8 +33,12 @@ const proxy: BannerHost = { 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 @@ -70,6 +79,16 @@ export function bannerCurrent(): string { */ 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); + } + } } /**