mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): support fullscreen mode in all readers
Introduce a shared useFullscreen composable to unify fullscreen state and toggling across all file format readers. Add fullscreen toggle buttons to the PDF, CBZ (comic), and audiobook readers, and clean up the legacy inline fullscreen logic in the EPUB reader. Closes #561
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useFoliate, type RelocateDetail } from './epub/composables/useFoliate'
|
||||
@@ -11,6 +11,7 @@ import { useReaderSettings } from './shared/composables/useReaderSettings'
|
||||
import { useCustomFonts } from './epub/composables/useCustomFonts'
|
||||
import { useVisibility } from './shared/composables/useVisibility'
|
||||
import { useWakeLock } from './shared/composables/useWakeLock'
|
||||
import { useFullscreen } from './shared/composables/useFullscreen'
|
||||
import { useBookmarks } from './epub/composables/useBookmarks'
|
||||
import { useAnnotations } from './epub/composables/useAnnotations'
|
||||
import { useReaderAnnotationActions } from './epub/composables/useReaderAnnotationActions'
|
||||
@@ -58,7 +59,6 @@ const showSidebar = ref(false)
|
||||
const showSettings = ref(false)
|
||||
const showSearch = ref(false)
|
||||
const searchInitialQuery = ref('')
|
||||
const isFullscreen = ref(false)
|
||||
const sectionFractions = ref<number[]>([])
|
||||
const sidebarLocationMetaByCfi = ref<Record<string, { chapterTitle: string | null; percentage: number | null }>>({})
|
||||
let sidebarLocationResolveSeq = 0
|
||||
@@ -107,6 +107,7 @@ const { cfi, chapterTitle, sectionIndex, totalSections, fraction, locationTotal,
|
||||
|
||||
const visibility = useVisibility()
|
||||
const { headerVisible, footerVisible, handleMiddleTap, setVisibilityLock } = visibility
|
||||
const { toggleFullscreen } = useFullscreen()
|
||||
|
||||
useWakeLock()
|
||||
|
||||
@@ -275,12 +276,6 @@ setTextSelectedHandler(handleTextSelected)
|
||||
setAnnotationClickHandler(handleAnnotationClick)
|
||||
|
||||
onMounted(async () => {
|
||||
const onFullscreenChange = () => {
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
}
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
|
||||
|
||||
// Specialized readers own their own progress/settings/loading lifecycle.
|
||||
if (isAudioFormat || isPdfFormat || isComicFormat) return
|
||||
|
||||
@@ -381,14 +376,6 @@ async function applyUpdate(partial: Partial<ReaderState>) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen?.()
|
||||
} else {
|
||||
document.documentElement.requestFullscreen?.()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => footerMode.value,
|
||||
(mode) => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Minus,
|
||||
Moon,
|
||||
Pause,
|
||||
@@ -32,6 +34,7 @@ import { useAudioQueue } from './composables/useAudioQueue'
|
||||
import { useAudioSettings } from './composables/useAudioSettings'
|
||||
import { useAudioBookmarks, type AudioBookmark } from './composables/useAudioBookmarks'
|
||||
import { useReadingSession } from '../shared/composables/useReadingSession'
|
||||
import { useFullscreen } from '../shared/composables/useFullscreen'
|
||||
|
||||
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
|
||||
const route = useRoute()
|
||||
@@ -46,6 +49,8 @@ const chaptersTab = ref<'chapters' | 'bookmarks'>('chapters')
|
||||
const showSettings = ref(false)
|
||||
const showSleepTimer = ref(false)
|
||||
const showSpeedPicker = ref(false)
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen()
|
||||
const fullscreenLabel = computed(() => (isFullscreen.value ? 'Exit fullscreen' : 'Enter fullscreen'))
|
||||
|
||||
const sleepButtonRef = ref<HTMLButtonElement | null>(null)
|
||||
const speedButtonRef = ref<HTMLButtonElement | null>(null)
|
||||
@@ -815,6 +820,15 @@ onMounted(async () => {
|
||||
<BookmarkCheck v-if="isNearBookmark" class="w-5 h-5" />
|
||||
<Bookmark v-else class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 rounded-full hover:bg-white/10 transition-colors text-white/65"
|
||||
:aria-label="fullscreenLabel"
|
||||
:title="fullscreenLabel"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<Minimize v-if="isFullscreen" class="w-5 h-5" />
|
||||
<Maximize v-else class="w-5 h-5" />
|
||||
</button>
|
||||
<button class="p-2 rounded-full hover:bg-white/10 transition-colors text-white/65" @click="showSettings = !showSettings">
|
||||
<Settings class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Moon,
|
||||
ScanLine,
|
||||
Settings,
|
||||
@@ -30,6 +31,7 @@ import { useCbz } from './composables/useCbz'
|
||||
import { useCbzSettings } from './composables/useCbzSettings'
|
||||
import type { BgColor, Direction, FitMode, ScrollMode, SpreadAlignment, ViewMode, WidePageSingletonMode } from './composables/useCbzSettings'
|
||||
import { useReaderSettings } from '../shared/composables/useReaderSettings'
|
||||
import { useFullscreen } from '../shared/composables/useFullscreen'
|
||||
import type { CbxReaderSettings } from '@bookorbit/types'
|
||||
import { DEFAULT_WIDE_PAGE_RATIO_THRESHOLD, createCbzSpreadLayout } from './lib/spread-layout'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -43,6 +45,7 @@ const router = useRouter()
|
||||
const trackingEnabled = computed(() => !props.peekMode)
|
||||
|
||||
const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter, setVisibilityLock } = useVisibility()
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen()
|
||||
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(
|
||||
props.fileId,
|
||||
@@ -247,6 +250,8 @@ const pageLabel = computed(() => {
|
||||
return `${start + 1} / ${pageCount.value}`
|
||||
})
|
||||
|
||||
const fullscreenLabel = computed(() => (isFullscreen.value ? 'Exit fullscreen' : 'Enter fullscreen'))
|
||||
|
||||
const progressPageIndex = computed(() => {
|
||||
const spread = currentSpread.value
|
||||
if (!spread || spread.pages.length === 0) return currentPage.value
|
||||
@@ -622,6 +627,15 @@ onUnmounted(() => {
|
||||
Start reading
|
||||
</button>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button class="viewer-btn" :aria-label="fullscreenLabel" @click="toggleFullscreen">
|
||||
<Minimize v-if="isFullscreen" :size="15" />
|
||||
<Maximize v-else :size="15" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ fullscreenLabel }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu v-model:open="showSettings">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="viewer-btn" :class="showSettings ? '!bg-muted !text-foreground' : ''">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import {
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
} from '@lucide/vue'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useFullscreen } from '../../shared/composables/useFullscreen'
|
||||
|
||||
const props = defineProps<{
|
||||
chapterTitle: string
|
||||
@@ -37,11 +37,7 @@ const emit = defineEmits<{
|
||||
startReading: []
|
||||
}>()
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
function onFullscreenChange() {
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
}
|
||||
const { isFullscreen } = useFullscreen()
|
||||
|
||||
function onSettingsOpenChange(open: boolean) {
|
||||
emit('update:settingsOpen', open)
|
||||
@@ -58,9 +54,6 @@ function getFooterModeTooltip(mode: 0 | 1 | 2): string {
|
||||
if (mode === 1) return 'Footer info: reading session + time left'
|
||||
return 'Footer info: chapter + chapter time left'
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('fullscreenchange', onFullscreenChange))
|
||||
onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -146,11 +139,7 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="viewer-btn hidden sm:flex"
|
||||
:aria-label="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
|
||||
@click="emit('toggleFullscreen')"
|
||||
>
|
||||
<button class="viewer-btn" :aria-label="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'" @click="emit('toggleFullscreen')">
|
||||
<Minimize v-if="isFullscreen" :size="18" />
|
||||
<Maximize v-else :size="18" />
|
||||
</button>
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('ReaderHeader', () => {
|
||||
expect(wrapper.emitted('cycleFooterMode')?.length).toBe(1)
|
||||
expect(wrapper.emitted('toggleHelp')?.length).toBe(1)
|
||||
expect(wrapper.emitted('toggleFullscreen')?.length).toBe(1)
|
||||
expect(wrapper.get('button[aria-label="Enter fullscreen"]').classes()).not.toContain('hidden')
|
||||
})
|
||||
|
||||
it('forwards dropdown open changes', () => {
|
||||
|
||||
@@ -7,10 +7,12 @@ import { getAccessToken } from '@/lib/api'
|
||||
import { useReaderProgress } from '../shared/composables/useReaderProgress'
|
||||
import { useReadingSession } from '../shared/composables/useReadingSession'
|
||||
import { useReaderSettings } from '../shared/composables/useReaderSettings'
|
||||
import { useFullscreen } from '../shared/composables/useFullscreen'
|
||||
import { useThemeStore, ACCENT_OPTIONS } from '@/stores/theme'
|
||||
import type { PdfReaderSettings } from '@bookorbit/types'
|
||||
import { getIsDark, lookupAccentHex } from './pdf-viewer-utils'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { ArrowLeft, Maximize, Minimize } from '@lucide/vue'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
|
||||
const route = useRoute()
|
||||
@@ -18,6 +20,8 @@ const router = useRouter()
|
||||
const trackingEnabled = computed(() => !props.peekMode)
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen()
|
||||
const fullscreenLabel = computed(() => (isFullscreen.value ? 'Exit fullscreen' : 'Enter fullscreen'))
|
||||
|
||||
const bookSettings = useReaderSettings(props.fileId, 'pdf')
|
||||
const { onActivity, elapsedMinutes } = useReadingSession(
|
||||
@@ -182,6 +186,10 @@ async function startTrackedReading() {
|
||||
onActivity()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
unsubPageChange?.()
|
||||
unsubLayoutReady?.()
|
||||
@@ -192,15 +200,34 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 flex flex-col">
|
||||
<!-- Floating Back Button (FAB) -->
|
||||
<div class="pointer-events-none absolute bottom-6 left-6 z-50 flex">
|
||||
<button
|
||||
class="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full bg-background/95 text-foreground shadow-lg border border-border backdrop-blur-md transition-transform hover:scale-105 active:scale-95"
|
||||
@click="router.back()"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft :size="22" />
|
||||
</button>
|
||||
<!-- Floating reader controls -->
|
||||
<div class="pointer-events-none absolute bottom-6 left-6 z-50 flex gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full bg-background/95 text-foreground shadow-lg border border-border backdrop-blur-md transition-transform hover:scale-105 active:scale-95"
|
||||
aria-label="Go back"
|
||||
@click="goBack"
|
||||
>
|
||||
<ArrowLeft :size="22" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Go back</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full bg-background/95 text-foreground shadow-lg border border-border backdrop-blur-md transition-transform hover:scale-105 active:scale-95"
|
||||
:aria-label="fullscreenLabel"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<Minimize v-if="isFullscreen" :size="22" />
|
||||
<Maximize v-else :size="22" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ fullscreenLabel }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="props.peekMode" class="pointer-events-none absolute left-0 right-0 top-3 z-50 flex justify-center">
|
||||
|
||||
@@ -45,6 +45,9 @@ vi.mock('../../shared/composables/useReadingSession', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const requestFullscreenMock = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const exitFullscreenMock = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getAccessToken: () => 'test-token-abc',
|
||||
}))
|
||||
@@ -82,13 +85,30 @@ describe('PdfV4ReaderView', () => {
|
||||
document.documentElement.className = ''
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
requestFullscreenMock.mockClear()
|
||||
exitFullscreenMock.mockClear()
|
||||
Object.defineProperty(document.documentElement, 'requestFullscreen', {
|
||||
configurable: true,
|
||||
value: requestFullscreenMock,
|
||||
})
|
||||
Object.defineProperty(document, 'exitFullscreen', {
|
||||
configurable: true,
|
||||
value: exitFullscreenMock,
|
||||
})
|
||||
})
|
||||
|
||||
async function mountComponent() {
|
||||
const pinia = createPinia()
|
||||
const wrapper = mount(PdfV4ReaderView, {
|
||||
props: { bookId: 42, fileId: 101 },
|
||||
global: { plugins: [pinia] },
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
@@ -105,6 +125,14 @@ describe('PdfV4ReaderView', () => {
|
||||
const wrapper = await mountComponent()
|
||||
expect(wrapper.find('.mock-pdf-viewer').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('requests fullscreen from the floating fullscreen control', async () => {
|
||||
const wrapper = await mountComponent()
|
||||
await wrapper.get('button[aria-label="Enter fullscreen"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestFullscreenMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('config construction', () => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
export function useFullscreen(target: () => Element | null = () => document.documentElement) {
|
||||
const isFullscreen = ref(false)
|
||||
const isFullscreenSupported = ref(false)
|
||||
|
||||
function getTarget() {
|
||||
if (typeof document === 'undefined') return null
|
||||
return target() ?? document.documentElement
|
||||
}
|
||||
|
||||
function updateFullscreenState() {
|
||||
if (typeof document === 'undefined') return
|
||||
const fullscreenTarget = getTarget()
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
isFullscreenSupported.value = !!document.fullscreenEnabled && !!fullscreenTarget?.requestFullscreen && !!document.exitFullscreen
|
||||
}
|
||||
|
||||
async function enterFullscreen() {
|
||||
const fullscreenTarget = getTarget()
|
||||
if (!fullscreenTarget?.requestFullscreen) return false
|
||||
|
||||
try {
|
||||
await fullscreenTarget.requestFullscreen()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
updateFullscreenState()
|
||||
}
|
||||
}
|
||||
|
||||
async function exitFullscreen() {
|
||||
if (typeof document === 'undefined' || !document.exitFullscreen) return false
|
||||
|
||||
try {
|
||||
await document.exitFullscreen()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
updateFullscreenState()
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (typeof document === 'undefined') return false
|
||||
if (document.fullscreenElement) return exitFullscreen()
|
||||
return enterFullscreen()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateFullscreenState()
|
||||
document.addEventListener('fullscreenchange', updateFullscreenState)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('fullscreenchange', updateFullscreenState)
|
||||
})
|
||||
|
||||
return {
|
||||
isFullscreen,
|
||||
isFullscreenSupported,
|
||||
enterFullscreen,
|
||||
exitFullscreen,
|
||||
toggleFullscreen,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user