diff --git a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts index 77f260cd9971..d8a345c23dec 100644 --- a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts +++ b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditor.ts @@ -676,10 +676,21 @@ export default class StyleEditor { if (!rule) { return; // paranoia } + // Keep the original properties for older render paths, but also expose the colors via + // CSS variables so custom highlights can read the same user-configured values. + rule.style.setProperty( + "--bloom-audio-highlight-background", + hiliteBgColor, + ); rule.style.setProperty("background-color", hiliteBgColor); if (hiliteTextColor) { + rule.style.setProperty( + "--bloom-audio-highlight-text-color", + hiliteTextColor, + ); rule.style.setProperty("color", hiliteTextColor); } else { + rule.style.removeProperty("--bloom-audio-highlight-text-color"); rule.style.removeProperty("color"); } } diff --git a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditorSpec.ts b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditorSpec.ts index cfb8b561bd83..89b8f4318d59 100644 --- a/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditorSpec.ts +++ b/src/BloomBrowserUI/bookEdit/StyleEditor/StyleEditorSpec.ts @@ -320,6 +320,41 @@ describe("StyleEditor", () => { if (rule != null) expect(ParseRuleForFontSize(rule.cssText)).toBe(20); }); + it("putAudioHiliteRulesInDom stores audio highlight css variables", () => { + const editor = new StyleEditor( + "file://" + "C:/dev/Bloom/src/BloomBrowserUI/bookEdit", + ); + + editor.putAudioHiliteRulesInDom( + "foo-style", + "rgb(1, 2, 3)", + "rgb(4, 5, 6)", + ); + + const sentenceRule = GetRuleMatchingSelector( + "foo-style span.ui-audioCurrent", + ); + const paddedSentenceRule = GetRuleMatchingSelector( + "foo-style span.ui-audioCurrent > span.ui-enableHighlight", + ); + const paragraphRule = GetRuleMatchingSelector( + "foo-style.ui-audioCurrent p", + ); + + expect(sentenceRule?.cssText).toContain( + "--bloom-audio-highlight-background: rgb(4, 5, 6)", + ); + expect(sentenceRule?.cssText).toContain( + "--bloom-audio-highlight-color: rgb(1, 2, 3)", + ); + expect(paddedSentenceRule?.cssText).toContain( + "--bloom-audio-highlight-background: rgb(4, 5, 6)", + ); + expect(paragraphRule?.cssText).toContain( + "--bloom-audio-highlight-background: rgb(4, 5, 6)", + ); + }); + // Skipped because currently we're running in jsdom. Making use of the existing rule depends on // getComputedStyle, which jsdom does not support. ChatGpt thinks it also depends on actual // dom element sizes, which jsdom also does not support. Attempts to polyfill proved difficult. diff --git a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.less b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.less index b87d9d5ec44e..1057f7faefd2 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.less +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecording.less @@ -31,6 +31,17 @@ div.ui-audioCurrent:not(.ui-suppressHighlight):not(.ui-disableHighlight) p { // color: @textOnLightBackground; } +body.bloom-audio-customHighlights { + span.ui-audioCurrent:not(.ui-suppressHighlight):not(.ui-disableHighlight), + div.ui-audioCurrent:not(.ui-suppressHighlight):not(.ui-disableHighlight) p, + .ui-audioCurrent .ui-enableHighlight { + // When CSS.highlights is available, let the pseudo highlight own the paint so + // the legacy yellow background does not stack on top of it. + background-color: transparent !important; + color: inherit !important; + } +} + .bloom-ui-current-audio-marker:before { background-image: url(currentTextIndicator.svg); background-repeat: no-repeat; @@ -76,24 +87,35 @@ div.ui-audioCurrent p { position: unset; // BL-11633, works around Chromium bug } +::highlight(bloom-audio-current) { + // Read from CSS variables so the Format dialog's stored audio highlight colors still + // control the yellow recording highlight even though it is now painted as a pseudo highlight. + background-color: var( + --bloom-audio-current-highlight-background, + @highlightColor + ); + color: var(--bloom-audio-current-highlight-color, @textOnLightBackground); +} + +::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..cf3580ca0275 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,23 @@ 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; + // The manager keeps both the yellow current highlight and the blue post-split + // highlights in sync so callers do not need separate refresh paths. + this.audioTextHighlightManager.refreshHighlights( + activeHighlight, + currentTextBox, + ); } // Scrolls an element into view. @@ -4375,6 +4399,7 @@ export default class AudioRecording implements IAudioRecorder { const currentTextBox = this.getCurrentTextBox(); if (currentTextBox) { currentTextBox.classList.add("bloom-postAudioSplit"); + this.refreshAudioTextHighlights(currentTextBox); } } @@ -4384,6 +4409,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 +4852,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..4edb8aaaedde 100644 --- a/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecordingSpec.ts +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioRecordingSpec.ts @@ -11,6 +11,78 @@ import { RecordingMode } from "./recordingMode"; import axios from "axios"; import $ from "jquery"; import { mockReplies } from "../../../utils/bloomApi"; +import { + currentHighlightName, + 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()) + : []; + }); +}; + +const getHighlightTexts = (highlightName: string): string[] => { + const highlight = getCustomHighlightsRegistry().get(highlightName); + return highlight ? highlight.ranges.map((range) => range.toString()) : []; +}; // Notes: // For any async tests: @@ -30,13 +102,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 +2139,156 @@ describe("audio recording tests", () => { }); }); }); + + describe("- custom split highlights", () => { + it("registers the current sentence highlight with custom highlights", async () => { + SetupIFrameFromHtml( + '

One.

', + ); + + const recording = new AudioRecording(); + const setHighlightToAsync = ( + recording as unknown as { + setHighlightToAsync(args: { + newElement: Element; + shouldScrollToElement: boolean; + forceRedisplay?: boolean; + }): Promise; + } + ).setHighlightToAsync.bind(recording); + + await setHighlightToAsync({ + newElement: getFrameElementById("page", "span1")!, + shouldScrollToElement: false, + forceRedisplay: true, + }); + + expect(getHighlightTexts(currentHighlightName)).toEqual(["One."]); + }); + + it("uses only ui-enableHighlight descendants for current highlight when its own background is disabled", async () => { + SetupIFrameFromHtml( + '

One   Two

', + ); + + const recording = new AudioRecording(); + const setHighlightToAsync = ( + recording as unknown as { + setHighlightToAsync(args: { + newElement: Element; + shouldScrollToElement: boolean; + forceRedisplay?: boolean; + }): Promise; + } + ).setHighlightToAsync.bind(recording); + + await setHighlightToAsync({ + newElement: getFrameElementById("page", "span1")!, + shouldScrollToElement: false, + forceRedisplay: true, + }); + + expect(getHighlightTexts(currentHighlightName)).toEqual([ + "One", + "Two", + ]); + }); + + it("copies the current highlight colors from CSS variables", async () => { + SetupIFrameFromHtml( + '

One.

', + ); + + const recording = new AudioRecording(); + const setHighlightToAsync = ( + recording as unknown as { + setHighlightToAsync(args: { + newElement: Element; + shouldScrollToElement: boolean; + forceRedisplay?: boolean; + }): Promise; + } + ).setHighlightToAsync.bind(recording); + + await setHighlightToAsync({ + newElement: getFrameElementById("page", "span1")!, + shouldScrollToElement: false, + forceRedisplay: true, + }); + + const pageDocument = ( + parent.window.document.getElementById( + "page", + ) as HTMLIFrameElement + ).contentDocument!; + const documentStyle = pageDocument.documentElement.style; + expect( + documentStyle.getPropertyValue( + "--bloom-audio-current-highlight-background", + ), + ).toBe("rgb(1, 2, 3)"); + expect( + documentStyle.getPropertyValue( + "--bloom-audio-current-highlight-color", + ), + ).toBe("rgb(4, 5, 6)"); + }); + + 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..0a0edd9fd790 --- /dev/null +++ b/src/BloomBrowserUI/bookEdit/toolbox/talkingBook/audioTextHighlightManager.ts @@ -0,0 +1,390 @@ +const kSegmentClass = "bloom-highlightSegment"; +const kEnableHighlightClass = "ui-enableHighlight"; +const kSuppressHighlightClass = "ui-suppressHighlight"; +const kDisableHighlightClass = "ui-disableHighlight"; +const kPostAudioSplitClass = "bloom-postAudioSplit"; +const kTextBoxRecordingMode = "textbox"; +const kCustomHighlightSupportClass = "bloom-audio-customHighlights"; +const kCurrentHighlightBackgroundSourceCssVar = + "--bloom-audio-highlight-background"; +const kCurrentHighlightColorSourceCssVar = "--bloom-audio-highlight-text-color"; +const kCurrentHighlightBackgroundCssVar = + "--bloom-audio-current-highlight-background"; +const kCurrentHighlightColorCssVar = "--bloom-audio-current-highlight-color"; + +// Keep the live recording highlight in the same registry as split highlights so +// AudioRecording only has one place to refresh whenever the current element changes. +export const currentHighlightName = "bloom-audio-current"; + +export const splitHighlightNames = [ + "bloom-audio-split-1", + "bloom-audio-split-2", + "bloom-audio-split-3", +] as const; + +const managedHighlightNames = [currentHighlightName, ...splitHighlightNames]; + +type HighlightRegistry = Map; +type HighlightConstructor = new (...ranges: Range[]) => unknown; + +const getDocumentWindow = (contextNode: Node): Window | undefined => { + return contextNode.ownerDocument?.defaultView ?? undefined; +}; + +const getDocumentElement = (contextNode: Node): HTMLElement | undefined => { + return contextNode.ownerDocument?.documentElement ?? undefined; +}; + +const getDocumentBody = (contextNode: Node): HTMLElement | undefined => { + return contextNode.ownerDocument?.body ?? 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; + } + + managedHighlightNames.forEach((name) => registry.delete(name)); + this.clearCurrentHighlightColors(contextNode); + } + + private clearSplitHighlights(contextNode?: Node): void { + if (!contextNode) { + return; + } + + const registry = getHighlightRegistry(contextNode); + if (!registry) { + return; + } + + splitHighlightNames.forEach((name) => registry.delete(name)); + } + + public refreshHighlights( + currentHighlight: Element | null, + currentTextBox: HTMLElement | null, + ): void { + const contextNode = currentHighlight ?? currentTextBox; + if (!contextNode) { + return; + } + + if (!this.supportsCustomHighlights(contextNode)) { + return; + } + + // Once custom highlights are active, suppress the old element background rules + // so Chromium paints the pseudo highlight instead of double-highlighting the text. + getDocumentBody(contextNode)?.classList.add( + kCustomHighlightSupportClass, + ); + + this.refreshCurrentHighlight(currentHighlight, currentTextBox); + this.refreshSplitHighlights(currentHighlight, currentTextBox); + } + + private supportsCustomHighlights(contextNode: Node): boolean { + return ( + !!getHighlightRegistry(contextNode) && + !!getHighlightConstructor(contextNode) + ); + } + + private refreshCurrentHighlight( + currentHighlight: Element | null, + currentTextBox: HTMLElement | null, + ): void { + const contextNode = currentHighlight ?? currentTextBox; + if (!contextNode) { + return; + } + + const registry = getHighlightRegistry(contextNode); + const Highlight = getHighlightConstructor(contextNode); + if (!registry || !Highlight) { + return; + } + + // The split-complete blue state replaces the yellow current highlight while it is visible, + // so clear the yellow registry entry instead of letting the two overlap. + if (this.shouldShowSplitHighlights(currentHighlight, currentTextBox)) { + registry.delete(currentHighlightName); + this.clearCurrentHighlightColors(contextNode); + return; + } + + const highlightInfo = this.getCurrentHighlightInfo( + currentHighlight, + currentTextBox, + ); + if (!highlightInfo || highlightInfo.ranges.length === 0) { + registry.delete(currentHighlightName); + this.clearCurrentHighlightColors(contextNode); + return; + } + + this.updateCurrentHighlightColors(highlightInfo.styleSource); + registry.set( + currentHighlightName, + new Highlight(...highlightInfo.ranges), + ); + } + + private refreshSplitHighlights( + currentHighlight: Element | null, + currentTextBox: HTMLElement | null, + ): void { + const contextNode = currentHighlight ?? currentTextBox; + if (!contextNode) { + return; + } + + if (!this.shouldShowSplitHighlights(currentHighlight, currentTextBox)) { + this.clearSplitHighlights(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 getCurrentHighlightInfo( + currentHighlight: Element | null, + currentTextBox: HTMLElement | null, + ): + | { + ranges: Range[]; + styleSource: Element; + } + | undefined { + if (!currentHighlight) { + return undefined; + } + + if (currentHighlight.classList.contains(kSuppressHighlightClass)) { + return undefined; + } + + const enabledDescendants = Array.from( + currentHighlight.querySelectorAll(`span.${kEnableHighlightClass}`), + ); + const enabledRanges = enabledDescendants + .map((enabledSpan) => this.makeRange(enabledSpan)) + .filter((range): range is Range => !!range); + + // fixHighlighting() can carve the visible pieces into ui-enableHighlight spans. + // Prefer those pieces so custom highlights preserve the same whitespace behavior. + if (enabledRanges.length > 0) { + return { + ranges: enabledRanges, + styleSource: enabledDescendants[0], + }; + } + + if (currentHighlight.classList.contains(kDisableHighlightClass)) { + return undefined; + } + + if (currentHighlight === currentTextBox) { + const paragraphs = Array.from(currentTextBox.querySelectorAll("p")); + const paragraphRanges = paragraphs + .map((paragraph) => this.makeRange(paragraph)) + .filter((range): range is Range => !!range); + if (paragraphRanges.length > 0) { + return { + ranges: paragraphRanges, + styleSource: paragraphs[0], + }; + } + } + + const wholeElementRange = this.makeRange(currentHighlight); + if (!wholeElementRange) { + return undefined; + } + + return { + ranges: [wholeElementRange], + styleSource: currentHighlight, + }; + } + + private updateCurrentHighlightColors(styleSource: Element): void { + const documentElement = getDocumentElement(styleSource); + if (!documentElement) { + return; + } + + // The actual colors still come from CSS so user-modified highlight colors keep working. + // We copy them into document-level variables that the ::highlight rule can read. + const computedStyle = + getDocumentWindow(styleSource)?.getComputedStyle(styleSource); + const backgroundColor = computedStyle + ?.getPropertyValue(kCurrentHighlightBackgroundSourceCssVar) + .trim(); + const color = computedStyle + ?.getPropertyValue(kCurrentHighlightColorSourceCssVar) + .trim(); + + if (backgroundColor) { + documentElement.style.setProperty( + kCurrentHighlightBackgroundCssVar, + backgroundColor, + ); + } else { + documentElement.style.removeProperty( + kCurrentHighlightBackgroundCssVar, + ); + } + + if (color) { + documentElement.style.setProperty( + kCurrentHighlightColorCssVar, + color, + ); + } else { + documentElement.style.removeProperty(kCurrentHighlightColorCssVar); + } + } + + private clearCurrentHighlightColors(contextNode: Node): void { + const documentElement = getDocumentElement(contextNode); + if (!documentElement) { + return; + } + + documentElement.style.removeProperty(kCurrentHighlightBackgroundCssVar); + documentElement.style.removeProperty(kCurrentHighlightColorCssVar); + } + + 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; + } +} diff --git a/src/BloomExe/Book/HtmlDom.cs b/src/BloomExe/Book/HtmlDom.cs index b2161906ad24..d599fa96397f 100644 --- a/src/BloomExe/Book/HtmlDom.cs +++ b/src/BloomExe/Book/HtmlDom.cs @@ -3803,6 +3803,41 @@ internal static void AddMissingAudioHighlightRules(SafeXmlElement userStylesNode userStyleKeyDict.Add(newKey, value); updatedRule = true; } + + // Custom highlights now read audio colors through CSS variables, so backfill those + // into saved user rules instead of assuming every book will be reopened in the Format dialog. + var updatedSentenceRule = AddAudioHighlightCssVariables(userStyleKeyDict[key]); + if (updatedSentenceRule != userStyleKeyDict[key]) + { + userStyleKeyDict[key] = updatedSentenceRule; + updatedRule = true; + } + + var paragraphRuleKey = key.Replace(" span.ui-audioCurrent", ".ui-audioCurrent p"); + if (userStyleKeyDict.ContainsKey(paragraphRuleKey)) + { + var updatedParagraphRule = AddAudioHighlightCssVariables( + userStyleKeyDict[paragraphRuleKey] + ); + if (updatedParagraphRule != userStyleKeyDict[paragraphRuleKey]) + { + userStyleKeyDict[paragraphRuleKey] = updatedParagraphRule; + updatedRule = true; + } + } + + var paddedSentenceRuleKey = key + " > span.ui-enableHighlight"; + if (userStyleKeyDict.ContainsKey(paddedSentenceRuleKey)) + { + var updatedPaddedSentenceRule = AddAudioHighlightCssVariables( + userStyleKeyDict[paddedSentenceRuleKey] + ); + if (updatedPaddedSentenceRule != userStyleKeyDict[paddedSentenceRuleKey]) + { + userStyleKeyDict[paddedSentenceRuleKey] = updatedPaddedSentenceRule; + updatedRule = true; + } + } } if (updatedRule) { @@ -3812,6 +3847,33 @@ internal static void AddMissingAudioHighlightRules(SafeXmlElement userStylesNode } } + private static string AddAudioHighlightCssVariables(string ruleValue) + { + const string highlightBackgroundVariable = "--bloom-audio-highlight-background"; + const string highlightColorVariable = "--bloom-audio-highlight-text-color"; + + // Leave rules alone once the variables exist so we do not churn user CSS on every load. + if (ruleValue.Contains(highlightBackgroundVariable)) + return ruleValue; + + var backgroundColorMatch = Regex.Match(ruleValue, @"background-color\s*:\s*([^;}]*)"); + if (!backgroundColorMatch.Success) + return ruleValue; + + var colorMatch = Regex.Match(ruleValue, @"(? span.ui-enableHighlight { background-color: transparent; color: rgb(0, 255, 255); }" + ".Bubble-style span.ui-audioCurrent > span.ui-enableHighlight { --bloom-audio-highlight-background: transparent; --bloom-audio-highlight-color: rgb(0, 255, 255); background-color: transparent; color: rgb(0, 255, 255); }" ) ); Assert.That( updatedCssRules, Does.Contain( - ".Title-On-Cover-style span.ui-audioCurrent > span.ui-enableHighlight { background-color: rgb(254, 191, 0); }" + ".Title-On-Cover-style span.ui-audioCurrent > span.ui-enableHighlight { --bloom-audio-highlight-background: rgb(254, 191, 0); background-color: rgb(254, 191, 0); }" + ) + ); + Assert.That( + updatedCssRules, + Does.Contain( + "--bloom-audio-highlight-background: transparent; --bloom-audio-highlight-color: rgb(0, 255, 255);" ) ); + Assert.That( + updatedCssRules, + Does.Contain("--bloom-audio-highlight-background: rgb(254, 191, 0);") + ); } [Test] @@ -1322,7 +1332,68 @@ public void AddMissingAudioHighlightRules_WorksForNoneMissing() HtmlDom.AddMissingAudioHighlightRules(bookStyleNode); var updatedCssRules = bookStyleNode.InnerXml; - Assert.That(updatedCssRules, Is.EqualTo(originalCssRules)); + Assert.That(updatedCssRules, Is.Not.EqualTo(originalCssRules)); + Assert.That( + updatedCssRules, + Does.Contain( + ".Bubble-style span.ui-audioCurrent { --bloom-audio-highlight-background: transparent; --bloom-audio-highlight-color: rgb(0, 255, 255); background-color: transparent; color: rgb(0, 255, 255); }" + ) + ); + Assert.That( + updatedCssRules, + Does.Contain( + ".Bubble-style span.ui-audioCurrent > span.ui-enableHighlight { --bloom-audio-highlight-background: transparent; --bloom-audio-highlight-color: rgb(0, 255, 255); background-color: transparent; color: rgb(0, 255, 255); }" + ) + ); + Assert.That( + updatedCssRules, + Does.Contain( + ".Bubble-style.ui-audioCurrent p { --bloom-audio-highlight-background: transparent; --bloom-audio-highlight-color: rgb(0, 255, 255); background-color: transparent; color: rgb(0, 255, 255); }" + ) + ); + } + + [Test] + public void AddMissingAudioHighlightRules_AddsCssVariablesWhenOnlyThatPartIsMissing() + { + var bookDom = new HtmlDom( + @" + + + + +
+ +" + ); + var bookStyleNode = HtmlDom.GetUserModifiedStyleElement(bookDom.Head); + + HtmlDom.AddMissingAudioHighlightRules(bookStyleNode); + var updatedCssRules = bookStyleNode.InnerXml; + + Assert.That( + updatedCssRules, + Does.Contain( + ".Title-On-Cover-style span.ui-audioCurrent { --bloom-audio-highlight-background: rgb(254, 191, 0); background-color: rgb(254, 191, 0); }" + ) + ); + Assert.That( + updatedCssRules, + Does.Contain( + ".Title-On-Cover-style span.ui-audioCurrent > span.ui-enableHighlight { --bloom-audio-highlight-background: rgb(254, 191, 0); background-color: rgb(254, 191, 0); }" + ) + ); + Assert.That( + updatedCssRules, + Does.Contain( + ".Title-On-Cover-style.ui-audioCurrent p { --bloom-audio-highlight-background: rgb(254, 191, 0); background-color: rgb(254, 191, 0); }" + ) + ); } [Test] diff --git a/src/content/bookLayout/basePage-sharedRules.less b/src/content/bookLayout/basePage-sharedRules.less index 5f1bf20d5bd5..9ee04aca20ef 100644 --- a/src/content/bookLayout/basePage-sharedRules.less +++ b/src/content/bookLayout/basePage-sharedRules.less @@ -28,6 +28,10 @@ body { span.ui-audioCurrent, div.ui-audioCurrent p, .ui-audioCurrent .ui-enableHighlight { + // These variables mirror the default audio highlight colors so edit-mode custom highlights + // and older consumers can share one source of truth. + --bloom-audio-highlight-background: #febf00; + --bloom-audio-highlight-text-color: black; background-color: #febf00; color: black; }