mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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
This commit is contained in:
@@ -31,6 +31,7 @@ import { useTtsKeyboard } from '@/features/tts/composables/useTtsKeyboard'
|
|||||||
import { getProviders, getVoices } from '@/features/tts/api/tts.api'
|
import { getProviders, getVoices } from '@/features/tts/api/tts.api'
|
||||||
import { useMediaOverlay } from './media-overlay/composables/useMediaOverlay'
|
import { useMediaOverlay } from './media-overlay/composables/useMediaOverlay'
|
||||||
import { resolveMediaOverlayResume, type MediaOverlayResumePosition } from './media-overlay/lib/media-overlay-resume'
|
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 TtsResumePrompt from '@/features/tts/components/TtsResumePrompt.vue'
|
||||||
import ReaderHeader from './epub/components/ReaderHeader.vue'
|
import ReaderHeader from './epub/components/ReaderHeader.vue'
|
||||||
import ReaderFooter from './epub/components/ReaderFooter.vue'
|
import ReaderFooter from './epub/components/ReaderFooter.vue'
|
||||||
@@ -322,21 +323,6 @@ function applySavedTtsResumeHighlight() {
|
|||||||
void showResumeHighlightFromBlock(savedChapterIdx, savedBlockIdx, sectionIndex.value)
|
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
|
// The most recently loaded chapter document, used to re-mark the resume sentence
|
||||||
// after narration stops (foliate clears its own highlight on stop).
|
// after narration stops (foliate clears its own highlight on stop).
|
||||||
let currentChapterDoc: Document | null = null
|
let currentChapterDoc: Document | null = null
|
||||||
@@ -363,7 +349,7 @@ function showResumeNarrationHighlight(doc: Document) {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
const el = doc.getElementById(id)
|
const el = doc.getElementById(id)
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const cls = getMediaActiveClass() ?? '-epub-media-overlay-active'
|
const cls = getMediaActiveClass() ?? MEDIA_OVERLAY_DEFAULT_ACTIVE_CLASS
|
||||||
el.classList.add(cls)
|
el.classList.add(cls)
|
||||||
resumeNarrationHighlight = { el, cls }
|
resumeNarrationHighlight = { el, cls }
|
||||||
}
|
}
|
||||||
@@ -372,7 +358,7 @@ function onChapterLoadHandler(doc: Document, viewEl: HTMLElement) {
|
|||||||
currentChapterDoc = doc
|
currentChapterDoc = doc
|
||||||
setFoliateSource(doc, viewEl)
|
setFoliateSource(doc, viewEl)
|
||||||
if (getMediaOverlay()) {
|
if (getMediaOverlay()) {
|
||||||
injectMediaOverlayHighlightCss(doc)
|
injectMediaOverlayHighlightCss(doc, getMediaActiveClass())
|
||||||
showResumeNarrationHighlight(doc)
|
showResumeNarrationHighlight(doc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,28 @@ describe('useFoliate.open', () => {
|
|||||||
expect(`${fetchFile}`).toBe('fresh-reader-token')
|
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<typeof vi.fn> }).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<typeof vi.fn> }).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 () => {
|
it('forces fixed-layout EPUB spreads off before opening when requested', async () => {
|
||||||
const book = { type: 'book', rendition: { layout: 'pre-paginated', spread: 'both' } }
|
const book = { type: 'book', rendition: { layout: 'pre-paginated', spread: 'both' } }
|
||||||
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce(book)
|
;(window as unknown as { makeStreamingBook: ReturnType<typeof vi.fn> }).makeStreamingBook.mockResolvedValueOnce(book)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE } from '../../../media-overlay/lib/media-overlay-highlight'
|
||||||
import { useReaderState } from '../useReaderState'
|
import { useReaderState } from '../useReaderState'
|
||||||
|
|
||||||
describe('useReaderState', () => {
|
describe('useReaderState', () => {
|
||||||
@@ -73,6 +74,28 @@ describe('useReaderState', () => {
|
|||||||
expect(dark.bg).not.toBe(light.bg)
|
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', () => {
|
it('applies renderer attributes and CSS in paginated flow', () => {
|
||||||
const state = useReaderState()
|
const state = useReaderState()
|
||||||
state.setFlow('paginated')
|
state.setFlow('paginated')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api, getAccessToken } from '@/lib/api'
|
|||||||
import { useFoliateAnnotations } from './useFoliateAnnotations'
|
import { useFoliateAnnotations } from './useFoliateAnnotations'
|
||||||
import { useFoliateSelection } from './useFoliateSelection'
|
import { useFoliateSelection } from './useFoliateSelection'
|
||||||
import { useFoliateInput } from './useFoliateInput'
|
import { useFoliateInput } from './useFoliateInput'
|
||||||
|
import { ensureMediaOverlayActiveClass } from '../../media-overlay/lib/media-overlay-highlight'
|
||||||
import type { EpubBookInfo, EpubReaderSettings } from '@bookorbit/types'
|
import type { EpubBookInfo, EpubReaderSettings } from '@bookorbit/types'
|
||||||
|
|
||||||
export interface RelocateDetail {
|
export interface RelocateDetail {
|
||||||
@@ -302,6 +303,7 @@ export function useFoliate(
|
|||||||
| undefined
|
| undefined
|
||||||
if (!makeStreamingBook) throw new Error('makeStreamingBook not available')
|
if (!makeStreamingBook) throw new Error('makeStreamingBook not available')
|
||||||
const book = await makeStreamingBook(bookId, '/api/v1/epub', bookInfo, makeFoliateFetchFile(), null, fileId)
|
const book = await makeStreamingBook(bookId, '/api/v1/epub', bookInfo, makeFoliateFetchFile(), null, fileId)
|
||||||
|
ensureMediaOverlayActiveClass(book)
|
||||||
applyEpubOpenOptions(book, options)
|
applyEpubOpenOptions(book, options)
|
||||||
shouldRestoreByFraction = isFixedLayoutBook(book)
|
shouldRestoreByFraction = isFixedLayoutBook(book)
|
||||||
isFixedLayout.value = shouldRestoreByFraction
|
isFixedLayout.value = shouldRestoreByFraction
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@bookorbit/types'
|
} from '@bookorbit/types'
|
||||||
import { themes } from '../constants/themes'
|
import { themes } from '../constants/themes'
|
||||||
import type { Theme, ThemeMode } 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'
|
import type { FoliateRenderer } from './useFoliate'
|
||||||
|
|
||||||
export interface ReaderState {
|
export interface ReaderState {
|
||||||
@@ -135,7 +136,6 @@ export function useReaderState() {
|
|||||||
const mode = activeMode.value
|
const mode = activeMode.value
|
||||||
const theme = currentTheme.value
|
const theme = currentTheme.value
|
||||||
const lightMode = theme.light
|
const lightMode = theme.light
|
||||||
const mediaActiveClass = 'media-active'
|
|
||||||
const dark = isDark.value
|
const dark = isDark.value
|
||||||
// Force bg whenever dark mode is active OR the light theme uses a non-white background.
|
// 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
|
// Styles are applied unconditionally (no prefers-color-scheme wrappers) so the app's
|
||||||
@@ -256,6 +256,7 @@ export function useReaderState() {
|
|||||||
html, body {
|
html, body {
|
||||||
color: ${mode.fg} !important;
|
color: ${mode.fg} !important;
|
||||||
background: none !important;
|
background: none !important;
|
||||||
|
${MEDIA_OVERLAY_HIGHLIGHT_CSS_VARIABLE}: color-mix(in hsl, ${mode.fg}, ${mode.bg} ${dark ? '75%' : '85%'});
|
||||||
}
|
}
|
||||||
body * {
|
body * {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
@@ -269,9 +270,6 @@ export function useReaderState() {
|
|||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
${!dark ? 'mix-blend-mode: multiply;' : ''}
|
${!dark ? 'mix-blend-mode: multiply;' : ''}
|
||||||
}
|
}
|
||||||
.${mediaActiveClass}, .${mediaActiveClass} * {
|
|
||||||
color: ${mode.fg} !important;
|
|
||||||
background: color-mix(in hsl, ${mode.fg}, ${mode.bg} ${dark ? '75%' : '85%'}) !important;
|
|
||||||
}`
|
}`
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<HTMLStyleElement>('#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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user