From 2595c2749655e7234ce2a0ee16726d824c058371 Mon Sep 17 00:00:00 2001 From: Guan Tong Date: Fri, 31 Jul 2026 02:28:35 +0800 Subject: [PATCH] Fix autoscroll cancellation on user input --- __TEST__/hyperaudio-lite.test.js | 37 ++++++++++++++++++++++++++++++++ js/hyperaudio-lite.js | 18 ++++++++++++---- js/hyperaudio-lite.mjs | 18 ++++++++++++---- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/__TEST__/hyperaudio-lite.test.js b/__TEST__/hyperaudio-lite.test.js index 5b9ff0d..8afb90b 100644 --- a/__TEST__/hyperaudio-lite.test.js +++ b/__TEST__/hyperaudio-lite.test.js @@ -716,6 +716,43 @@ test("smoothScrollTo jumps directly under prefers-reduced-motion (#259)", () => delete window.matchMedia; }); +test("user input cancels an active transcript scroll animation (#266)", () => { + const inst = new HyperaudioLite({ + transcript: "hypertranscript", + player: "hyperplayer", + }); + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + let nextAnimationId = 1; + global.requestAnimationFrame = jest.fn(() => nextAnimationId++); + global.cancelAnimationFrame = jest.fn(); + + try { + for (const eventName of ["wheel", "touchstart", "keydown"]) { + inst.scrollContainer.scrollTop = 0; + inst.smoothScrollTo(inst.scrollContainer, 500, 800); + const animationId = inst.scrollAnimationId; + + inst.scrollContainer.dispatchEvent(new Event(eventName, { bubbles: true })); + + expect(global.cancelAnimationFrame).toHaveBeenLastCalledWith(animationId); + expect(inst.scrollAnimationId).toBeNull(); + } + } finally { + inst.destroy(); + if (originalRequestAnimationFrame === undefined) { + delete global.requestAnimationFrame; + } else { + global.requestAnimationFrame = originalRequestAnimationFrame; + } + if (originalCancelAnimationFrame === undefined) { + delete global.cancelAnimationFrame; + } else { + global.cancelAnimationFrame = originalCancelAnimationFrame; + } + } +}); + test("registerPlayer() adds a custom player type (#253)", () => { class FakePlayer { constructor(instance) { diff --git a/js/hyperaudio-lite.js b/js/hyperaudio-lite.js index c86b2a3..f527912 100644 --- a/js/hyperaudio-lite.js +++ b/js/hyperaudio-lite.js @@ -571,6 +571,12 @@ class HyperaudioLite { } else { this.scrollContainer = this.transcript; } + + const cancelAutoscroll = () => this.cancelScrollAnimation(); + const listenerOpts = { signal: this.listenerController.signal }; + ['wheel', 'touchstart', 'keydown'].forEach(eventName => { + this.scrollContainer.addEventListener(eventName, cancelAutoscroll, listenerOpts); + }); } // Setup event listeners for interactions @@ -790,6 +796,13 @@ class HyperaudioLite { this.autoscroll = true; } + cancelScrollAnimation() { + if (this.scrollAnimationId != null) { + cancelAnimationFrame(this.scrollAnimationId); + this.scrollAnimationId = null; + } + } + // Tear down the instance: stop the polling loop, cancel any in-flight // scroll animation and remove every DOM listener this instance added // (transcript, native player, popover). Embed players (YouTube, Vimeo, @@ -798,10 +811,7 @@ class HyperaudioLite { destroy() { this.destroyed = true; this.clearTimer(); - if (this.scrollAnimationId) { - cancelAnimationFrame(this.scrollAnimationId); - this.scrollAnimationId = null; - } + this.cancelScrollAnimation(); this.listenerController.abort(); } diff --git a/js/hyperaudio-lite.mjs b/js/hyperaudio-lite.mjs index 82f9f7c..e22bed2 100644 --- a/js/hyperaudio-lite.mjs +++ b/js/hyperaudio-lite.mjs @@ -571,6 +571,12 @@ class HyperaudioLite { } else { this.scrollContainer = this.transcript; } + + const cancelAutoscroll = () => this.cancelScrollAnimation(); + const listenerOpts = { signal: this.listenerController.signal }; + ['wheel', 'touchstart', 'keydown'].forEach(eventName => { + this.scrollContainer.addEventListener(eventName, cancelAutoscroll, listenerOpts); + }); } // Setup event listeners for interactions @@ -790,6 +796,13 @@ class HyperaudioLite { this.autoscroll = true; } + cancelScrollAnimation() { + if (this.scrollAnimationId != null) { + cancelAnimationFrame(this.scrollAnimationId); + this.scrollAnimationId = null; + } + } + // Tear down the instance: stop the polling loop, cancel any in-flight // scroll animation and remove every DOM listener this instance added // (transcript, native player, popover). Embed players (YouTube, Vimeo, @@ -798,10 +811,7 @@ class HyperaudioLite { destroy() { this.destroyed = true; this.clearTimer(); - if (this.scrollAnimationId) { - cancelAnimationFrame(this.scrollAnimationId); - this.scrollAnimationId = null; - } + this.cancelScrollAnimation(); this.listenerController.abort(); }