feat(ads): carry the banner scroll position across navigation
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).
This commit is contained in:
Ilia Denisov
2026-06-16 06:26:12 +02:00
parent dc582e9f73
commit 9f83962bf7
4 changed files with 62 additions and 3 deletions
+27 -1
View File
@@ -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);
});
});