From 926f5de22547da797b12f597aa349ec6e7d39c23 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:52:32 +0000 Subject: [PATCH] fix(reader): preserve narration highlights across themes Use the EPUB media-overlay active class and a theme-aware highlight variable so forced page backgrounds cannot hide the narrated sentence. Fixes #1479 --- client/src/features/reader/ReaderView.vue | 20 +---- .../composables/__tests__/useFoliate.spec.ts | 22 +++++ .../__tests__/useReaderState.spec.ts | 23 +++++ .../reader/epub/composables/useFoliate.ts | 2 + .../reader/epub/composables/useReaderState.ts | 6 +- .../lib/media-overlay-highlight.test.ts | 84 +++++++++++++++++++ .../lib/media-overlay-highlight.ts | 33 ++++++++ 7 files changed, 169 insertions(+), 21 deletions(-) create mode 100644 client/src/features/reader/media-overlay/lib/media-overlay-highlight.test.ts create mode 100644 client/src/features/reader/media-overlay/lib/media-overlay-highlight.ts diff --git a/client/src/features/reader/ReaderView.vue b/client/src/features/reader/ReaderView.vue index d6b9d6bb9..fdd6af4b2 100644 --- a/client/src/features/reader/ReaderView.vue +++ b/client/src/features/reader/ReaderView.vue @@ -31,6 +31,7 @@ import { useTtsKeyboard } from '@/features/tts/composables/useTtsKeyboard' import { getProviders, getVoices } from '@/features/tts/api/tts.api' import { useMediaOverlay } from './media-overlay/composables/useMediaOverlay' import { resolveMediaOverlayResume, type MediaOverlayResumePosition } from './media-overlay/lib/media-overlay-resume' +import { injectMediaOverlayHighlightCss, MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS } from './media-overlay/lib/media-overlay-highlight' import TtsResumePrompt from '@/features/tts/components/TtsResumePrompt.vue' import ReaderHeader from './epub/components/ReaderHeader.vue' import ReaderFooter from './epub/components/ReaderFooter.vue' @@ -322,21 +323,6 @@ function applySavedTtsResumeHighlight() { void showResumeHighlightFromBlock(savedChapterIdx, savedBlockIdx, sectionIndex.value) } -const MEDIA_OVERLAY_STYLE_ID = 'bo-media-overlay-highlight' - -// Foliate's media-overlay engine only toggles the book's active class on the -// current element; styling that class is the host app's job. Inject a themed -// highlight rule (matching the TTS highlight colour) into each chapter document -// so narration highlighting is visible even when the EPUB ships no CSS for it. -function injectMediaOverlayHighlightCss(doc: Document) { - if (!doc?.head || doc.getElementById(MEDIA_OVERLAY_STYLE_ID)) return - const activeClass = getMediaActiveClass() ?? '-epub-media-overlay-active' - const style = doc.createElement('style') - style.id = MEDIA_OVERLAY_STYLE_ID - style.textContent = `.${CSS.escape(activeClass)} { background-color: rgba(79, 195, 247, 0.3); border-radius: 0.15em; box-decoration-break: clone; -webkit-box-decoration-break: clone; }` - doc.head.appendChild(style) -} - // The most recently loaded chapter document, used to re-mark the resume sentence // after narration stops (foliate clears its own highlight on stop). let currentChapterDoc: Document | null = null @@ -363,7 +349,7 @@ function showResumeNarrationHighlight(doc: Document) { if (!id) return const el = doc.getElementById(id) if (!el) return - const cls = getMediaActiveClass() ?? '-epub-media-overlay-active' + const cls = getMediaActiveClass() ?? MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS el.classList.add(cls) resumeNarrationHighlight = { el, cls } } @@ -372,7 +358,7 @@ function onChapterLoadHandler(doc: Document, viewEl: HTMLElement) { currentChapterDoc = doc setFoliateSource(doc, viewEl) if (getMediaOverlay()) { - injectMediaOverlayHighlightCss(doc) + injectMediaOverlayHighlightCss(doc, getMediaActiveClass()) showResumeNarrationHighlight(doc) } } diff --git a/client/src/features/reader/epub/composables/__tests__/useFoliate.spec.ts b/client/src/features/reader/epub/composables/__tests__/useFoliate.spec.ts index a6ce01cf0..7194481f6 100644 --- a/client/src/features/reader/epub/composables/__tests__/useFoliate.spec.ts +++ b/client/src/features/reader/epub/composables/__tests__/useFoliate.spec.ts @@ -135,6 +135,28 @@ describe('useFoliate.open', () => { expect(`${fetchFile}`).toBe('fresh-reader-token') }) + it('applies the EPUB default media-overlay active class before Foliate opens the book', async () => { + const book = { media: {}, sections: [{ mediaOverlay: { id: 'overlay' } }] } + ;(window as unknown as { makeStreamingBook: ReturnType }).makeStreamingBook.mockResolvedValueOnce(book) + const foliate = useFoliate(() => container) + + await foliate.open(1, 2, 'epub', null, undefined) + + expect(book.media).toEqual({ activeClass: '-epub-media-overlay-active' }) + expect(mockOpen).toHaveBeenCalledWith(book) + }) + + it('preserves a book-defined media-overlay active class', async () => { + const book = { media: { activeClass: 'book-active' }, sections: [{ mediaOverlay: { id: 'overlay' } }] } + ;(window as unknown as { makeStreamingBook: ReturnType }).makeStreamingBook.mockResolvedValueOnce(book) + const foliate = useFoliate(() => container) + + await foliate.open(1, 2, 'epub', null, undefined) + + expect(book.media.activeClass).toBe('book-active') + expect(mockOpen).toHaveBeenCalledWith(book) + }) + it('forces fixed-layout EPUB spreads off before opening when requested', async () => { const book = { type: 'book', rendition: { layout: 'pre-paginated', spread: 'both' } } ;(window as unknown as { makeStreamingBook: ReturnType }).makeStreamingBook.mockResolvedValueOnce(book) diff --git a/client/src/features/reader/epub/composables/__tests__/useReaderState.spec.ts b/client/src/features/reader/epub/composables/__tests__/useReaderState.spec.ts index 58302cd0a..6d1f1a2ae 100644 --- a/client/src/features/reader/epub/composables/__tests__/useReaderState.spec.ts +++ b/client/src/features/reader/epub/composables/__tests__/useReaderState.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE } from '../../../media-overlay/lib/media-overlay-highlight' import { useReaderState } from '../useReaderState' describe('useReaderState', () => { @@ -73,6 +74,28 @@ describe('useReaderState', () => { expect(dark.bg).not.toBe(light.bg) }) + it('provides a theme-aware media-overlay highlight whenever the page background is forced', () => { + const state = useReaderState() + + for (const theme of state.themes) { + state.setThemeName(theme.name) + for (const dark of [false, true]) { + state.setIsDark(dark) + const css = state.generateCSS() + const shouldForceBackground = dark || theme.light.bg !== '#ffffff' + + expect(css.includes(`${MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE}:`), `${theme.name} ${dark ? 'dark' : 'light'}`).toBe(shouldForceBackground) + } + } + }) + + it('does not couple media-overlay highlighting to a hard-coded active class', () => { + const state = useReaderState() + state.setThemeName('sepia') + + expect(state.generateCSS()).not.toContain('.media-active') + }) + it('applies renderer attributes and CSS in paginated flow', () => { const state = useReaderState() state.setFlow('paginated') diff --git a/client/src/features/reader/epub/composables/useFoliate.ts b/client/src/features/reader/epub/composables/useFoliate.ts index e5760badd..095bb31bb 100644 --- a/client/src/features/reader/epub/composables/useFoliate.ts +++ b/client/src/features/reader/epub/composables/useFoliate.ts @@ -3,6 +3,7 @@ import { api, getAccessToken } from '@/lib/api' import { useFoliateAnnotations } from './useFoliateAnnotations' import { useFoliateSelection } from './useFoliateSelection' import { useFoliateInput } from './useFoliateInput' +import { ensureMediaOverlayActiveClass } from '../../media-overlay/lib/media-overlay-highlight' import type { EpubBookInfo, EpubReaderSettings } from '@bookorbit/types' export interface RelocateDetail { @@ -302,6 +303,7 @@ export function useFoliate( | undefined if (!makeStreamingBook) throw new Error('makeStreamingBook not available') const book = await makeStreamingBook(bookId, '/api/v1/epub', bookInfo, makeFoliateFetchFile(), null, fileId) + ensureMediaOverlayActiveClass(book) applyEpubOpenOptions(book, options) shouldRestoreByFraction = isFixedLayoutBook(book) isFixedLayout.value = shouldRestoreByFraction diff --git a/client/src/features/reader/epub/composables/useReaderState.ts b/client/src/features/reader/epub/composables/useReaderState.ts index 29f1fe2d9..32d8e4f30 100644 --- a/client/src/features/reader/epub/composables/useReaderState.ts +++ b/client/src/features/reader/epub/composables/useReaderState.ts @@ -16,6 +16,7 @@ import { } from '@bookorbit/types' import { themes } from '../constants/themes' import type { Theme, ThemeMode } from '../constants/themes' +import { MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE } from '../../media-overlay/lib/media-overlay-highlight' import type { FoliateRenderer } from './useFoliate' export interface ReaderState { @@ -135,7 +136,6 @@ export function useReaderState() { const mode = activeMode.value const theme = currentTheme.value const lightMode = theme.light - const mediaActiveClass = 'media-active' const dark = isDark.value // Force bg whenever dark mode is active OR the light theme uses a non-white background. // Styles are applied unconditionally (no prefers-color-scheme wrappers) so the app's @@ -256,6 +256,7 @@ export function useReaderState() { html, body { color: ${mode.fg} !important; background: none !important; + ${MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE}: color-mix(in hsl, ${mode.fg}, ${mode.bg} ${dark ? '75%' : '85%'}); } body * { color: inherit !important; @@ -269,9 +270,6 @@ export function useReaderState() { background-color: transparent !important; ${!dark ? 'mix-blend-mode: multiply;' : ''} } - .${mediaActiveClass}, .${mediaActiveClass} * { - color: ${mode.fg} !important; - background: color-mix(in hsl, ${mode.fg}, ${mode.bg} ${dark ? '75%' : '85%'}) !important; }` : '' } diff --git a/client/src/features/reader/media-overlay/lib/media-overlay-highlight.test.ts b/client/src/features/reader/media-overlay/lib/media-overlay-highlight.test.ts new file mode 100644 index 000000000..bfc3ebd13 --- /dev/null +++ b/client/src/features/reader/media-overlay/lib/media-overlay-highlight.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { + ensureMediaOverlayActiveClass, + injectMediaOverlayHighlightCss, + MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS, + MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE, +} from './media-overlay-highlight' + +describe('media overlay active class', () => { + it('applies the EPUB default when an overlay book does not declare a class', () => { + const book: { media?: { activeClass?: string }; sections: { mediaOverlay: unknown }[] } = { + sections: [{ mediaOverlay: { id: 'overlay' } }], + } + + ensureMediaOverlayActiveClass(book) + + expect(book.media?.activeClass).toBe(MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS) + }) + + it('preserves the active class declared by the book', () => { + const book = { media: { activeClass: 'book-active' }, sections: [{ mediaOverlay: { id: 'overlay' } }] } + + ensureMediaOverlayActiveClass(book) + + expect(book.media.activeClass).toBe('book-active') + }) + + it('does not add media metadata to books without overlays', () => { + const book: { media?: { activeClass?: string }; sections: { mediaOverlay?: unknown }[] } = { sections: [{}] } + + ensureMediaOverlayActiveClass(book) + + expect(book.media).toBeUndefined() + }) +}) + +describe('media overlay highlight CSS', () => { + it('targets the book active class with an important theme-aware background', () => { + const frame = document.createElement('iframe') + document.body.appendChild(frame) + const doc = frame.contentDocument! + + injectMediaOverlayHighlightCss(doc, 'book:active') + + const style = doc.head.querySelector('style') + const rule = style?.sheet?.cssRules[0] as CSSStyleRule | undefined + expect(rule?.selectorText).toBe(`.${CSS.escape('book:active')}`) + expect(rule?.style.getPropertyValue('background-color')).toContain(`var(${MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE}`) + expect(style?.textContent).toMatch(/background-color: var\([^;]+\) !important;/) + frame.remove() + }) + + it('uses the EPUB default class and injects only one rule per chapter', () => { + const doc = document.implementation.createHTMLDocument() + + injectMediaOverlayHighlightCss(doc) + injectMediaOverlayHighlightCss(doc, 'different-active-class') + + const styles = doc.head.querySelectorAll('style') + expect(styles).toHaveLength(1) + expect(styles[0]?.textContent).toContain(`.${MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS}`) + expect(styles[0]?.textContent).not.toContain('different-active-class') + }) + + it('keeps the active sentence visible over an important themed page background', () => { + const frame = document.createElement('iframe') + document.body.appendChild(frame) + const doc = frame.contentDocument! + const themeStyle = doc.createElement('style') + themeStyle.textContent = 'body * { background-color: rgb(241, 232, 208) !important; }' + doc.head.appendChild(themeStyle) + injectMediaOverlayHighlightCss(doc, 'book-active') + + const highlightStyle = doc.head.querySelector('#bo-media-overlay-highlight')! + // jsdom does not resolve custom properties, so substitute the theme variable's resolved value to exercise the priority cascade. + highlightStyle.textContent = (highlightStyle.textContent ?? '').replace(/var\([^;]+\)/, 'rgba(79, 195, 247, 0.3)') + const sentence = doc.createElement('span') + sentence.className = 'book-active' + doc.body.appendChild(sentence) + + expect(frame.contentWindow?.getComputedStyle(sentence).backgroundColor).toBe('rgba(79, 195, 247, 0.3)') + frame.remove() + }) +}) diff --git a/client/src/features/reader/media-overlay/lib/media-overlay-highlight.ts b/client/src/features/reader/media-overlay/lib/media-overlay-highlight.ts new file mode 100644 index 000000000..bcdc9b957 --- /dev/null +++ b/client/src/features/reader/media-overlay/lib/media-overlay-highlight.ts @@ -0,0 +1,33 @@ +export const MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS = '-epub-media-overlay-active' +export const MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE = '--bookorbit-media-overlay-highlight' + +const MEDIA_OVERLAY_HIGHLIGHT_STYLE_ID = 'bo-media-overlay-highlight' +const MEDIA_OVERLAY_DEFAULT_HIGHLIGHT = 'rgba(79, 195, 247, 0.3)' + +type MediaOverlayBook = { + media?: { activeClass?: string } + sections?: { mediaOverlay?: unknown }[] +} + +export function ensureMediaOverlayActiveClass(book: unknown): void { + if (!book || typeof book !== 'object') return + + const epubBook = book as MediaOverlayBook + if (!epubBook.sections?.some((section) => section.mediaOverlay)) return + + epubBook.media ??= {} + if (!epubBook.media.activeClass?.trim()) { + epubBook.media.activeClass = MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS + } +} + +export function injectMediaOverlayHighlightCss(doc: Document, activeClass?: string | null): void { + if (!doc.head || doc.getElementById(MEDIA_OVERLAY_HIGHLIGHT_STYLE_ID)) return + + const resolvedActiveClass = activeClass?.trim() || MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS + const escapedActiveClass = (doc.defaultView?.CSS ?? CSS).escape(resolvedActiveClass) + const style = doc.createElement('style') + style.id = MEDIA_OVERLAY_HIGHLIGHT_STYLE_ID + style.textContent = `.${escapedActiveClass} { background-color: var(${MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE}, ${MEDIA_OVERLAY_DEFAULT_HIGHLIGHT}) !important; border-radius: 0.15em; box-decoration-break: clone; -webkit-box-decoration-break: clone; }` + doc.head.appendChild(style) +}