diff --git a/__TEST__/hyperaudio-lite.test.js b/__TEST__/hyperaudio-lite.test.js index 5b9ff0d..42a6383 100644 --- a/__TEST__/hyperaudio-lite.test.js +++ b/__TEST__/hyperaudio-lite.test.js @@ -403,6 +403,49 @@ test("updateTranscriptVisualState self-heals after external class rewrites (#251 expect(spans[5].classList.contains("unread")).toBe(true); }); +test("updateTranscriptVisualState tolerates a detached cached word (#265)", () => { + ht.myPlayer = { paused: false }; + const detachedWord = ht.wordArr[4].n; + const parent = detachedWord.parentNode; + const nextSibling = detachedWord.nextSibling; + const exactStart = parseInt(detachedWord.dataset.m) / 1000; + + detachedWord.remove(); + expect(() => ht.updateTranscriptVisualState(exactStart)).not.toThrow(); + + parent.insertBefore(detachedWord, nextSibling); + ht.refreshWords(); +}); + +test("refreshWords rebuilds DOM caches after transcript edits (#265)", () => { + const firstWord = ht.wordArr[0].n; + const firstParent = firstWord.parentNode; + const firstNextSibling = firstWord.nextSibling; + const replacement = document.createElement("span"); + replacement.dataset.m = "12000"; + replacement.dataset.d = "500"; + replacement.textContent = "replacement "; + + firstWord.remove(); + document.querySelector("#hypertranscript p:last-of-type").appendChild(replacement); + ht.prevWordIndex = 5; + ht.activeWordElement = ht.wordArr[4].n; + ht.activeParentElement = ht.wordArr[4].n.parentNode; + + const refreshed = ht.refreshWords(); + + expect(refreshed.some(entry => entry.n === firstWord)).toBe(false); + expect(refreshed.some(entry => entry.n === replacement)).toBe(true); + expect(ht.prevWordIndex).toBe(0); + expect(ht.activeWordElement).toBeNull(); + expect(ht.activeParentElement).toBeNull(); + expect(Array.from(ht.parentElements)).toContain(replacement.parentNode); + + replacement.remove(); + firstParent.insertBefore(firstWord, firstNextSibling); + ht.refreshWords(); +}); + test("setPlayHead updates currentTime and plays if playOnClick is true", () => { ht.playOnClick = true; ht.myPlayer = { setTime: jest.fn(), play: jest.fn(), paused: true }; diff --git a/js/hyperaudio-lite.d.ts b/js/hyperaudio-lite.d.ts index 0a65b8e..7e397ce 100644 --- a/js/hyperaudio-lite.d.ts +++ b/js/hyperaudio-lite.d.ts @@ -73,6 +73,8 @@ export declare class HyperaudioLite { pauseAutoscroll(): void; /** Re-enable autoscroll after pauseAutoscroll(). */ resumeAutoscroll(): void; + /** Rebuild cached word and parent references after transcript DOM edits. */ + refreshWords(): Array<{ n: HTMLElement; m: number; p: Node }>; /** Remove all listeners added by this instance and stop the polling loop. */ destroy(): void; } diff --git a/js/hyperaudio-lite.js b/js/hyperaudio-lite.js index c86b2a3..0c01cb3 100644 --- a/js/hyperaudio-lite.js +++ b/js/hyperaudio-lite.js @@ -542,8 +542,24 @@ class HyperaudioLite { // Setup the transcript words setupTranscriptWords() { + this.refreshWords(); + } + + // Rebuild cached word and parent references after transcript DOM edits. + refreshWords() { + if (this.activeWordElement) { + this.activeWordElement.classList.remove('active'); + } + if (this.activeParentElement) { + this.activeParentElement.classList.remove('active'); + } + this.prevWordIndex = 0; + this.activeWordElement = null; + this.activeParentElement = null; + const words = this.transcript.querySelectorAll('[data-m]'); this.wordArr = this.createWordArray(words); + this.parentTag = null; // check for elements with data-m attributes that are not directly below
// these will contain

or similar that we can scroll to @@ -556,7 +572,10 @@ class HyperaudioLite { } } - this.parentElements = this.transcript.getElementsByTagName(this.parentTag); + this.parentElements = this.parentTag + ? this.transcript.getElementsByTagName(this.parentTag) + : []; + return this.wordArr; } setupAutoScroll(autoscroll, scrollContainer) { @@ -1051,7 +1070,9 @@ class HyperaudioLite { if (!this.myPlayer.paused || forceActiveWord) { activeWord.classList.add('active'); } - activeParent.classList.add('active'); + if (activeParent) { + activeParent.classList.add('active'); + } } this.activeWordElement = activeWord; diff --git a/js/hyperaudio-lite.mjs b/js/hyperaudio-lite.mjs index 82f9f7c..62454e1 100644 --- a/js/hyperaudio-lite.mjs +++ b/js/hyperaudio-lite.mjs @@ -542,8 +542,24 @@ class HyperaudioLite { // Setup the transcript words setupTranscriptWords() { + this.refreshWords(); + } + + // Rebuild cached word and parent references after transcript DOM edits. + refreshWords() { + if (this.activeWordElement) { + this.activeWordElement.classList.remove('active'); + } + if (this.activeParentElement) { + this.activeParentElement.classList.remove('active'); + } + this.prevWordIndex = 0; + this.activeWordElement = null; + this.activeParentElement = null; + const words = this.transcript.querySelectorAll('[data-m]'); this.wordArr = this.createWordArray(words); + this.parentTag = null; // check for elements with data-m attributes that are not directly below

// these will contain

or similar that we can scroll to @@ -556,7 +572,10 @@ class HyperaudioLite { } } - this.parentElements = this.transcript.getElementsByTagName(this.parentTag); + this.parentElements = this.parentTag + ? this.transcript.getElementsByTagName(this.parentTag) + : []; + return this.wordArr; } setupAutoScroll(autoscroll, scrollContainer) { @@ -1051,7 +1070,9 @@ class HyperaudioLite { if (!this.myPlayer.paused || forceActiveWord) { activeWord.classList.add('active'); } - activeParent.classList.add('active'); + if (activeParent) { + activeParent.classList.add('active'); + } } this.activeWordElement = activeWord;