From 3dd43e2ceacb29b277c61597a390ede44a3e708e Mon Sep 17 00:00:00 2001 From: Noel Chou Date: Tue, 17 Mar 2026 20:05:09 -0400 Subject: [PATCH] BL-15300 Switch TBT highlighting to pseudoelement --- .../toolbox/talkingBook/audioRecording.less | 29 +-- .../toolbox/talkingBook/audioRecording.ts | 28 +++ .../toolbox/talkingBook/audioRecordingSpec.ts | 129 +++++++++++++ .../talkingBook/audioTextHighlightManager.ts | 176 ++++++++++++++++++ 4 files changed, 348 insertions(+), 14 deletions(-) create mode 100644 src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioTextHighlightManager.ts diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.less b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.less index b87d9d5ec44e..720c50c91af8 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.less +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.less @@ -76,24 +76,25 @@ div.ui-audioCurrent p { position: unset; // BL-11633, works around Chromium bug } +::highlight(bloom-audio-split-1) { + background-color: #bfedf3; +} + +::highlight(bloom-audio-split-2) { + background-color: #7fdae6; +} + +::highlight(bloom-audio-split-3) { + background-color: #29c2d6; +} + .ui-audioCurrent.bloom-postAudioSplit[data-audiorecordingmode="TextBox"]:not( .ui-suppressHighlight ):not(.ui-disableHighlight) { // Special highlighting after the Split button completes to show it completed. - // Note: This highlighting is expected to persist across sessions, but to be hidden (displayed with the yellow color) while each segment is playing. - // This is accomplished because this rule temporarily drops out of effect when .ui-audioCurrent is moved to the span as that segment plays. - // (The rule requires a span BELOW the .ui-audioCurrent, so it drops out of effect the span IS the .ui-audioCurrent). - span:nth-child(3n + 1 of .bloom-highlightSegment) { - background-color: #bfedf3; - } - - span:nth-child(3n + 2 of .bloom-highlightSegment) { - background-color: #7fdae6; - } - - span:nth-child(3n + 3 of .bloom-highlightSegment) { - background-color: #29c2d6; - } + // The actual colors are now applied with named ::highlight() rules populated from TS. + // Note: This highlighting is expected to persist across sessions, but to be hidden + // (displayed with the yellow color) while each segment is playing. span { position: unset; // BL-11633, works around Chromium bug diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.ts b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.ts index 107dbc546ab7..3f377cf57551 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.ts @@ -69,6 +69,7 @@ import { } from "../../../react_components/featureStatus"; import { animateStyleName } from "../../../utils/shared"; import jQuery from "jquery"; +import { AudioTextHighlightManager } from "./audioTextHighlightManager"; enum Status { Disabled, // Can't use button now (e.g., Play when there is no recording) @@ -207,6 +208,7 @@ export default class AudioRecording implements IAudioRecorder { private playbackOrderCache: IPlaybackOrderInfo[] = []; private disablingOverlay: HTMLDivElement; + private audioTextHighlightManager = new AudioTextHighlightManager(); constructor(maySetHighlight: boolean = true) { this.audioSplitButton = ( @@ -898,6 +900,10 @@ export default class AudioRecording implements IAudioRecorder { if (pageDocBody) { this.removeAudioCurrent(pageDocBody); } + + this.audioTextHighlightManager.clearManagedHighlights( + pageDocBody ?? undefined, + ); } private removeAudioCurrent(parentElement: Element) { @@ -997,6 +1003,7 @@ export default class AudioRecording implements IAudioRecorder { if (oldElement === newElement && !forceRedisplay) { // No need to do much, and better not to so we can avoid any temporary flashes as the highlight is removed and re-applied + this.refreshAudioTextHighlights(newElement); return; } @@ -1069,6 +1076,21 @@ export default class AudioRecording implements IAudioRecorder { ); } } + + this.refreshAudioTextHighlights(newElement); + } + + private refreshAudioTextHighlights(currentHighlight?: Element | null) { + const activeHighlight = currentHighlight ?? this.getCurrentHighlight(); + const currentTextBox = activeHighlight + ? ((this.getTextBoxOfElement( + activeHighlight, + ) as HTMLElement | null) ?? null) + : null; + this.audioTextHighlightManager.refreshSplitHighlights( + activeHighlight, + currentTextBox, + ); } // Scrolls an element into view. @@ -4375,6 +4397,7 @@ export default class AudioRecording implements IAudioRecorder { const currentTextBox = this.getCurrentTextBox(); if (currentTextBox) { currentTextBox.classList.add("bloom-postAudioSplit"); + this.refreshAudioTextHighlights(currentTextBox); } } @@ -4384,6 +4407,10 @@ export default class AudioRecording implements IAudioRecorder { currentTextBox.classList.remove("bloom-postAudioSplit"); currentTextBox.removeAttribute("data-audioRecordingEndTimes"); } + + this.audioTextHighlightManager.clearManagedHighlights( + currentTextBox ?? undefined, + ); } private getElementsToUpdateForCursor(): (Element | null)[] { @@ -4823,6 +4850,7 @@ export default class AudioRecording implements IAudioRecorder { } }); this.nodesToRestoreAfterPlayEnded.clear(); + this.refreshAudioTextHighlights(); } } diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecordingSpec.ts b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecordingSpec.ts index e57ec82bf40b..b9364c8e1d44 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecordingSpec.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecordingSpec.ts @@ -11,6 +11,70 @@ import { RecordingMode } from "./recordingMode"; import axios from "axios"; import $ from "jquery"; import { mockReplies } from "../../../utils/bloomApi"; +import { splitHighlightNames } from "./audioTextHighlightManager"; + +class FakeHighlight { + public ranges: Range[]; + + public constructor(...ranges: Range[]) { + this.ranges = ranges; + } +} + +type FakeHighlightRegistry = Map; +type TestCssWithHighlights = { + highlights?: FakeHighlightRegistry; +}; + +const installCustomHighlightPolyfill = (targetWindow: Window) => { + const targetWindowWithCss = targetWindow as Window & { + CSS?: TestCssWithHighlights; + }; + if (!targetWindowWithCss.CSS) { + targetWindowWithCss.CSS = {}; + } + + const cssWithHighlights = targetWindowWithCss.CSS; + cssWithHighlights.highlights = new Map(); + ( + targetWindow as Window & { + Highlight?: typeof FakeHighlight; + } + ).Highlight = FakeHighlight; +}; + +const getPageWindow = (): Window | undefined => { + const iframe = parent.window.document.getElementById( + "page", + ) as HTMLIFrameElement | null; + return iframe?.contentWindow ?? undefined; +}; + +const getCustomHighlightsRegistry = (): FakeHighlightRegistry => { + const targetWindow = getPageWindow() ?? globalThis.window; + const cssWithHighlights = ( + targetWindow as Window & { + CSS?: TestCssWithHighlights; + } + ).CSS; + if (!cssWithHighlights?.highlights) { + throw new Error( + "Expected CSS.highlights test polyfill to be installed", + ); + } + + return cssWithHighlights.highlights; +}; + +const getSplitHighlightTexts = (): string[][] => { + const registry = getCustomHighlightsRegistry(); + return splitHighlightNames.map((name) => { + const highlight = registry.get(name); + return highlight + ? highlight.ranges.map((range) => range.toString()) + : []; + }); +}; // Notes: // For any async tests: @@ -30,13 +94,21 @@ import { mockReplies } from "../../../utils/bloomApi"; describe("audio recording tests", () => { beforeAll(async () => { + installCustomHighlightPolyfill(globalThis.window); + await setupForAudioRecordingTests(); + + const pageWindow = getPageWindow(); + if (pageWindow) { + installCustomHighlightPolyfill(pageWindow); + } }); afterEach(() => { // Clean up any pending timers to prevent "parent is not defined" errors // when tests finish before timers fire theOneAudioRecorder?.clearTimeouts(); + getCustomHighlightsRegistry().clear(); }); // In an earlier version of our API, checkForAnyRecording was designed to fail (404) if there was no recording. @@ -2059,6 +2131,63 @@ describe("audio recording tests", () => { }); }); }); + + describe("- custom split highlights", () => { + it("registers the split highlight color groups for the current text box", () => { + SetupIFrameFromHtml( + '

One.Two.Three.Four.

', + ); + + const recording = new AudioRecording(); + recording.markAudioSplit(); + + expect(getSplitHighlightTexts()).toEqual([ + ["One.", "Four."], + ["Two."], + ["Three."], + ]); + }); + + it("clears split highlights while playback moves to an individual segment", async () => { + SetupIFrameFromHtml( + '

One.Two.

', + ); + + const recording = new AudioRecording(); + recording.markAudioSplit(); + + const setHighlightToAsync = ( + recording as unknown as { + setHighlightToAsync(args: { + newElement: Element; + shouldScrollToElement: boolean; + }): Promise; + } + ).setHighlightToAsync.bind(recording); + + await setHighlightToAsync({ + newElement: getFrameElementById("page", "span1")!, + shouldScrollToElement: false, + }); + + expect(getSplitHighlightTexts()).toEqual([[], [], []]); + }); + + it("uses only ui-enableHighlight descendants when a segment disables its own background", () => { + SetupIFrameFromHtml( + '

One.Two   Three

', + ); + + const recording = new AudioRecording(); + recording.markAudioSplit(); + + expect(getSplitHighlightTexts()).toEqual([ + ["One."], + ["Two", "Three"], + [], + ]); + }); + }); }); function StripEmptyClasses(html) { diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioTextHighlightManager.ts b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioTextHighlightManager.ts new file mode 100644 index 000000000000..3b1fa579807c --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioTextHighlightManager.ts @@ -0,0 +1,176 @@ +const kSegmentClass = "bloom-highlightSegment"; +const kEnableHighlightClass = "ui-enableHighlight"; +const kSuppressHighlightClass = "ui-suppressHighlight"; +const kDisableHighlightClass = "ui-disableHighlight"; +const kPostAudioSplitClass = "bloom-postAudioSplit"; +const kTextBoxRecordingMode = "textbox"; + +export const splitHighlightNames = [ + "bloom-audio-split-1", + "bloom-audio-split-2", + "bloom-audio-split-3", +] as const; + +type HighlightRegistry = Map; +type HighlightConstructor = new (...ranges: Range[]) => unknown; + +const getDocumentWindow = (contextNode: Node): Window | undefined => { + return contextNode.ownerDocument?.defaultView ?? undefined; +}; + +const getHighlightRegistry = ( + contextNode: Node, +): HighlightRegistry | undefined => { + const docWindow = getDocumentWindow(contextNode) as + | (Window & typeof globalThis) + | undefined; + const cssWithHighlights = docWindow?.CSS as + | (typeof globalThis.CSS & { + highlights?: HighlightRegistry; + }) + | undefined; + return cssWithHighlights?.highlights; +}; + +const getHighlightConstructor = ( + contextNode: Node, +): HighlightConstructor | undefined => { + const docWindow = getDocumentWindow(contextNode) as + | (Window & { + Highlight?: HighlightConstructor; + }) + | undefined; + return docWindow?.Highlight; +}; + +export class AudioTextHighlightManager { + public clearManagedHighlights(contextNode?: Node): void { + if (!contextNode) { + return; + } + + const registry = getHighlightRegistry(contextNode); + if (!registry) { + return; + } + + splitHighlightNames.forEach((name) => registry.delete(name)); + } + + public refreshSplitHighlights( + currentHighlight: Element | null, + currentTextBox: HTMLElement | null, + ): void { + const contextNode = currentHighlight ?? currentTextBox; + if (!contextNode) { + return; + } + + if (!this.shouldShowSplitHighlights(currentHighlight, currentTextBox)) { + this.clearManagedHighlights(contextNode); + return; + } + + const registry = getHighlightRegistry(contextNode); + const Highlight = getHighlightConstructor(contextNode); + if (!registry || !Highlight) { + return; + } + + const rangesByName = new Map(); + splitHighlightNames.forEach((name) => rangesByName.set(name, [])); + + const segmentGroups = new Map(); + Array.from( + currentTextBox.querySelectorAll(`span.${kSegmentClass}`), + ).forEach((segment) => { + const parent = segment.parentElement; + if (!parent) { + return; + } + + const segments = segmentGroups.get(parent) ?? []; + segments.push(segment); + segmentGroups.set(parent, segments); + }); + + segmentGroups.forEach((segments) => { + segments.forEach((segment, index) => { + const highlightName = + splitHighlightNames[index % splitHighlightNames.length]; + const ranges = rangesByName.get(highlightName); + if (!ranges) { + return; + } + + ranges.push(...this.getRangesForSegment(segment)); + }); + }); + + splitHighlightNames.forEach((name) => { + const ranges = rangesByName.get(name) ?? []; + if (ranges.length > 0) { + registry.set(name, new Highlight(...ranges)); + } else { + registry.delete(name); + } + }); + } + + private shouldShowSplitHighlights( + currentHighlight: Element | null, + currentTextBox: HTMLElement | null, + ): currentTextBox is HTMLElement { + if (!currentHighlight || !currentTextBox) { + return false; + } + + if (currentHighlight !== currentTextBox) { + return false; + } + + if ( + currentTextBox.classList.contains(kSuppressHighlightClass) || + currentTextBox.classList.contains(kDisableHighlightClass) + ) { + return false; + } + + return ( + currentTextBox.classList.contains(kPostAudioSplitClass) && + currentTextBox + .getAttribute("data-audiorecordingmode") + ?.toLowerCase() === kTextBoxRecordingMode + ); + } + + private getRangesForSegment(segment: Element): Range[] { + const enabledRanges = Array.from( + segment.querySelectorAll(`span.${kEnableHighlightClass}`), + ) + .map((enabledSpan) => this.makeRange(enabledSpan)) + .filter((range): range is Range => !!range); + + if (enabledRanges.length > 0) { + return enabledRanges; + } + + const wholeSegmentRange = this.makeRange(segment); + return wholeSegmentRange ? [wholeSegmentRange] : []; + } + + private makeRange(node: Node): Range | undefined { + if (node.textContent === null || node.textContent.length === 0) { + return undefined; + } + + const ownerDocument = node.ownerDocument; + if (!ownerDocument) { + return undefined; + } + + const range = ownerDocument.createRange(); + range.selectNodeContents(node); + return range; + } +}