From 8be73b7e5e20c08059ec8405c52a8703635684b8 Mon Sep 17 00:00:00 2001 From: Guan Tong Date: Fri, 31 Jul 2026 02:06:26 +0800 Subject: [PATCH] Ignore invalid transcript timestamps --- __TEST__/hyperaudio-lite.test.js | 35 +++++++++++++++++++++++ js/hyperaudio-lite.js | 48 ++++++++++++++++++++++---------- js/hyperaudio-lite.mjs | 48 ++++++++++++++++++++++---------- 3 files changed, 103 insertions(+), 28 deletions(-) diff --git a/__TEST__/hyperaudio-lite.test.js b/__TEST__/hyperaudio-lite.test.js index 5b9ff0d..5633920 100644 --- a/__TEST__/hyperaudio-lite.test.js +++ b/__TEST__/hyperaudio-lite.test.js @@ -158,6 +158,22 @@ test("createWordArray", () => { expect(ht.createWordArray(words)).toStrictEqual(expectedResult); }); +test("createWordArray ignores malformed data-m values", () => { + const container = document.createElement("p"); + container.innerHTML = + 'valid' + + 'invalid' + + 'valid'; + + const result = ht.createWordArray(container.querySelectorAll("[data-m]")); + const invalidWord = container.children[1]; + + expect(result.map(({ m }) => m)).toEqual([100, 300]); + expect(invalidWord.classList.contains("unread")).toBe(true); + expect(invalidWord.classList.contains("read")).toBe(false); + expect(invalidWord.classList.contains("active")).toBe(false); +}); + test("getSelectionMediaFragment", () => { document .getSelection() @@ -414,6 +430,25 @@ test("setPlayHead updates currentTime and plays if playOnClick is true", () => { expect(ht.myPlayer.play).toHaveBeenCalled(); }); +test("setPlayHead ignores targets without a numeric data-m", () => { + ht.playOnClick = true; + ht.highlightedText = true; + ht.myPlayer = { setTime: jest.fn(), play: jest.fn(), paused: true }; + const updateSpy = jest.spyOn(ht, "updateTranscriptVisualState"); + const activeWord = document.querySelector('span[data-m="3950"]'); + activeWord.classList.add("active"); + + ht.setPlayHead({ target: activeWord.parentNode }); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(ht.myPlayer.setTime).not.toHaveBeenCalled(); + expect(ht.myPlayer.play).not.toHaveBeenCalled(); + expect(ht.highlightedText).toBe(true); + expect(activeWord.classList.contains("active")).toBe(true); + + updateSpy.mockRestore(); +}); + test("preparePlayHead sets paused to false and calls checkPlayHead", () => { ht.checkPlayHead = jest.fn(); ht.preparePlayHead(); diff --git a/js/hyperaudio-lite.js b/js/hyperaudio-lite.js index c86b2a3..fcc3a8d 100644 --- a/js/hyperaudio-lite.js +++ b/js/hyperaudio-lite.js @@ -631,8 +631,23 @@ class HyperaudioLite { // Create an array of words with metadata from the transcript createWordArray(words) { - return Array.from(words).map(word => { + const wordArr = []; + + Array.from(words).forEach(word => { const m = parseInt(word.getAttribute('data-m')); + + // Reset to a clean baseline — stale read/active classes from a previous + // instance on the same DOM would survive the delta updates in + // updateTranscriptVisualState (#251). + word.classList.remove('read', 'active'); + word.classList.add('unread'); + + // A malformed timestamp breaks every binary-search comparison that + // probes it. Keep the DOM node inert instead of poisoning playback. + if (Number.isNaN(m)) { + return; + } + let p = word.parentNode; while (p !== document) { if (['p', 'figure', 'ul'].includes(p.tagName.toLowerCase())) { @@ -640,13 +655,11 @@ class HyperaudioLite { } p = p.parentNode; } - // Reset to a clean baseline — stale read/active classes from a previous - // instance on the same DOM would survive the delta updates in - // updateTranscriptVisualState (#251). - word.classList.remove('read', 'active'); - word.classList.add('unread'); - return { n: word, m, p }; + + wordArr.push({ n: word, m, p }); }); + + return wordArr; } getSelectionRange = () => { @@ -721,10 +734,19 @@ class HyperaudioLite { } const target = e.target || e.srcElement; + const timeSecs = target && target.dataset + ? parseInt(target.dataset.m) / 1000 + : NaN; + + // Clicks on transcript whitespace or malformed timed nodes must not update + // the visual-state binary search with NaN. + if (Number.isNaN(timeSecs)) { + return; + } + this.highlightedText = false; this.clearActiveClasses(); - const timeSecs = parseInt(target.dataset.m) / 1000; this.updateTranscriptVisualState(timeSecs); // Mark the clicked word as active AFTER updateTranscriptVisualState (which @@ -736,12 +758,10 @@ class HyperaudioLite { target.parentNode.classList.add('active'); } - if (!isNaN(timeSecs)) { - this.end = null; - this.myPlayer.setTime(timeSecs); - if (this.playOnClick) { - this.myPlayer.play(); - } + this.end = null; + this.myPlayer.setTime(timeSecs); + if (this.playOnClick) { + this.myPlayer.play(); } } diff --git a/js/hyperaudio-lite.mjs b/js/hyperaudio-lite.mjs index 82f9f7c..e24a79b 100644 --- a/js/hyperaudio-lite.mjs +++ b/js/hyperaudio-lite.mjs @@ -631,8 +631,23 @@ class HyperaudioLite { // Create an array of words with metadata from the transcript createWordArray(words) { - return Array.from(words).map(word => { + const wordArr = []; + + Array.from(words).forEach(word => { const m = parseInt(word.getAttribute('data-m')); + + // Reset to a clean baseline — stale read/active classes from a previous + // instance on the same DOM would survive the delta updates in + // updateTranscriptVisualState (#251). + word.classList.remove('read', 'active'); + word.classList.add('unread'); + + // A malformed timestamp breaks every binary-search comparison that + // probes it. Keep the DOM node inert instead of poisoning playback. + if (Number.isNaN(m)) { + return; + } + let p = word.parentNode; while (p !== document) { if (['p', 'figure', 'ul'].includes(p.tagName.toLowerCase())) { @@ -640,13 +655,11 @@ class HyperaudioLite { } p = p.parentNode; } - // Reset to a clean baseline — stale read/active classes from a previous - // instance on the same DOM would survive the delta updates in - // updateTranscriptVisualState (#251). - word.classList.remove('read', 'active'); - word.classList.add('unread'); - return { n: word, m, p }; + + wordArr.push({ n: word, m, p }); }); + + return wordArr; } getSelectionRange = () => { @@ -721,10 +734,19 @@ class HyperaudioLite { } const target = e.target || e.srcElement; + const timeSecs = target && target.dataset + ? parseInt(target.dataset.m) / 1000 + : NaN; + + // Clicks on transcript whitespace or malformed timed nodes must not update + // the visual-state binary search with NaN. + if (Number.isNaN(timeSecs)) { + return; + } + this.highlightedText = false; this.clearActiveClasses(); - const timeSecs = parseInt(target.dataset.m) / 1000; this.updateTranscriptVisualState(timeSecs); // Mark the clicked word as active AFTER updateTranscriptVisualState (which @@ -736,12 +758,10 @@ class HyperaudioLite { target.parentNode.classList.add('active'); } - if (!isNaN(timeSecs)) { - this.end = null; - this.myPlayer.setTime(timeSecs); - if (this.playOnClick) { - this.myPlayer.play(); - } + this.end = null; + this.myPlayer.setTime(timeSecs); + if (this.playOnClick) { + this.myPlayer.play(); } }