Promote development → master (initial production release: pre-release line + Stage 18) #104
@@ -70,6 +70,16 @@
|
|||||||
txDur = durationMs;
|
txDur = durationMs;
|
||||||
tx = -toPx;
|
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
|
// Attach to the persistent engine on mount, detach on unmount (without stopping it). Declared
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function recordingHost(overflow = 0): { host: BannerHost; calls: string[]; shown
|
|||||||
hide: () => calls.push('hide'),
|
hide: () => calls.push('hide'),
|
||||||
overflowPx: () => overflow,
|
overflowPx: () => overflow,
|
||||||
scrollTo: () => calls.push('scroll'),
|
scrollTo: () => calls.push('scroll'),
|
||||||
|
resumeScroll: () => calls.push('resume'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -175,7 +176,7 @@ describe('bannerEngine', () => {
|
|||||||
const shown: string[] = [];
|
const shown: string[] = [];
|
||||||
return {
|
return {
|
||||||
shown,
|
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() {},
|
hide() {},
|
||||||
overflowPx: () => overflow,
|
overflowPx: () => overflow,
|
||||||
scrollTo: () => scrolled++,
|
scrollTo: () => scrolled++,
|
||||||
|
resumeScroll() {},
|
||||||
};
|
};
|
||||||
configureBanner([{ weight: 1, messages: ['z0'] }], big);
|
configureBanner([{ weight: 1, messages: ['z0'] }], big);
|
||||||
attachBannerHost(host);
|
attachBannerHost(host);
|
||||||
@@ -251,4 +253,28 @@ describe('bannerEngine', () => {
|
|||||||
expect(scrolled).toBeGreaterThan(0);
|
expect(scrolled).toBeGreaterThan(0);
|
||||||
detachBannerHost(host);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export interface BannerHost {
|
|||||||
overflowPx(): number;
|
overflowPx(): number;
|
||||||
/** Animate the horizontal scroll to toPx over durationMs. */
|
/** Animate the horizontal scroll to toPx over durationMs. */
|
||||||
scrollTo(toPx: number, durationMs: number): void;
|
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 {
|
export interface Rotator {
|
||||||
|
|||||||
@@ -10,15 +10,20 @@ let rotator: Rotator | null = null;
|
|||||||
let mounted: BannerHost | null = null;
|
let mounted: BannerHost | null = null;
|
||||||
let current = '';
|
let current = '';
|
||||||
let key = '';
|
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
|
// proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view
|
||||||
// resync to it) and forwards every effect to the currently-attached DOM host, if any.
|
// can resume them) and forwards every effect to the currently-attached DOM host, if any.
|
||||||
const proxy: BannerHost = {
|
const proxy: BannerHost = {
|
||||||
show(md) {
|
show(md) {
|
||||||
current = md;
|
current = md;
|
||||||
|
activeScroll = null;
|
||||||
mounted?.show(md);
|
mounted?.show(md);
|
||||||
},
|
},
|
||||||
resetScroll() {
|
resetScroll() {
|
||||||
|
activeScroll = null;
|
||||||
mounted?.resetScroll();
|
mounted?.resetScroll();
|
||||||
},
|
},
|
||||||
hide(durationMs) {
|
hide(durationMs) {
|
||||||
@@ -28,8 +33,12 @@ const proxy: BannerHost = {
|
|||||||
return mounted?.overflowPx() ?? 0;
|
return mounted?.overflowPx() ?? 0;
|
||||||
},
|
},
|
||||||
scrollTo(toPx, durationMs) {
|
scrollTo(toPx, durationMs) {
|
||||||
|
activeScroll = { toPx, dur: durationMs, start: Date.now() };
|
||||||
mounted?.scrollTo(toPx, durationMs);
|
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
|
// 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 {
|
export function attachBannerHost(host: BannerHost): void {
|
||||||
mounted = host;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user