feat(reader): polish the PDF reading experience

Make long PDF reading sessions easier to navigate across desktop and mobile. Keep document loading recoverable when EmbedPDF initializes or tears down out of order, without enabling document-editing capabilities.
This commit is contained in:
neon
2026-07-11 15:29:34 +00:00
parent 4a8b793c65
commit 870abecb01
38 changed files with 15871 additions and 7541 deletions
+20 -1
View File
@@ -36,7 +36,26 @@
},
"dependencies": {
"@bookorbit/types": "workspace:*",
"@embedpdf/vue-pdf-viewer": "^2.14.4",
"@embedpdf/core": "2.14.4",
"@embedpdf/engines": "2.14.4",
"@embedpdf/models": "2.14.4",
"@embedpdf/pdfium": "2.14.4",
"@embedpdf/plugin-annotation": "2.14.4",
"@embedpdf/plugin-bookmark": "2.14.4",
"@embedpdf/plugin-document-manager": "2.14.4",
"@embedpdf/plugin-history": "2.14.4",
"@embedpdf/plugin-interaction-manager": "2.14.4",
"@embedpdf/plugin-pan": "2.14.4",
"@embedpdf/plugin-render": "2.14.4",
"@embedpdf/plugin-rotate": "2.14.4",
"@embedpdf/plugin-scroll": "2.14.4",
"@embedpdf/plugin-search": "2.14.4",
"@embedpdf/plugin-selection": "2.14.4",
"@embedpdf/plugin-spread": "2.14.4",
"@embedpdf/plugin-thumbnail": "2.14.4",
"@embedpdf/plugin-tiling": "2.14.4",
"@embedpdf/plugin-viewport": "2.14.4",
"@embedpdf/plugin-zoom": "2.14.4",
"@fontsource-variable/fraunces": "^5.2.9",
"@fontsource-variable/inter": "^5.2.8",
"@lucide/vue": "1.24.0",
-10
View File
@@ -353,16 +353,6 @@
}
@layer components {
.pdf-find-match {
background: rgba(255, 220, 0, 0.38);
border-radius: 1px;
}
.pdf-find-match-current {
background: rgba(255, 140, 0, 0.55);
outline: 1px solid rgba(255, 140, 0, 0.85);
border-radius: 1px;
}
.viewer-btn {
@apply flex items-center justify-center w-8 h-8 rounded text-foreground/60 hover:text-foreground hover:bg-muted transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
}
+3 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, 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'
@@ -31,7 +31,6 @@ import DictionaryPopover from './epub/components/DictionaryPopover.vue'
import TranslationPopover from './epub/components/TranslationPopover.vue'
import TranslationSheet from './epub/components/TranslationSheet.vue'
import KeyboardShortcutsModal from './epub/components/KeyboardShortcutsModal.vue'
import PdfV4ReaderView from './pdf-v4/PdfV4ReaderView.vue'
import CbzReaderView from './cbz/CbzReaderView.vue'
import AudiobookReaderView from './audiobook/AudiobookReaderView.vue'
import type { ReaderState } from './epub/composables/useReaderState'
@@ -40,6 +39,8 @@ import type { EpubReaderSettings } from '@bookorbit/types'
import { findMatchingCfiRange } from './epub/utils'
import { getFormatGroup } from '@bookorbit/types'
const PdfV4ReaderView = defineAsyncComponent(() => import('./pdf-v4/PdfV4ReaderView.vue'))
const route = useRoute()
const router = useRouter()
const bookId = Number(route.params.bookId)
@@ -1,29 +1,43 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, shallowRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { PDFViewer, ScrollPlugin, ScrollStrategy, SpreadMode, ZoomMode } from '@embedpdf/vue-pdf-viewer'
import type { PDFViewerConfig, PluginRegistry, ScrollCapability, EmbedPdfContainer } from '@embedpdf/vue-pdf-viewer'
import { getAccessToken } from '@/lib/api'
import { EmbedPDF } from '@embedpdf/core/vue'
import { createPluginRegistration, type PluginBatchRegistrations, type PluginRegistry } from '@embedpdf/core'
import { usePdfiumEngine } from '@embedpdf/engines/vue'
import pdfiumWasmUrl from '@embedpdf/pdfium/pdfium.wasm?url'
import { LockModeType } from '@embedpdf/plugin-annotation'
import { AnnotationPluginPackage } from '@embedpdf/plugin-annotation/vue'
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark/vue'
import { DocumentManagerPlugin, DocumentManagerPluginPackage } from '@embedpdf/plugin-document-manager/vue'
import { HistoryPluginPackage } from '@embedpdf/plugin-history/vue'
import { InteractionManagerPluginPackage } from '@embedpdf/plugin-interaction-manager/vue'
import { PanPluginPackage } from '@embedpdf/plugin-pan/vue'
import { RenderPluginPackage } from '@embedpdf/plugin-render/vue'
import { RotatePluginPackage } from '@embedpdf/plugin-rotate/vue'
import { ScrollPluginPackage } from '@embedpdf/plugin-scroll/vue'
import { SearchPluginPackage } from '@embedpdf/plugin-search/vue'
import { SelectionPluginPackage } from '@embedpdf/plugin-selection/vue'
import { SpreadPluginPackage } from '@embedpdf/plugin-spread/vue'
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/vue'
import { TilingPluginPackage } from '@embedpdf/plugin-tiling/vue'
import { ViewportPluginPackage } from '@embedpdf/plugin-viewport/vue'
import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/vue'
import { LoaderCircle } from '@lucide/vue'
import type { PdfReaderSettings } from '@bookorbit/types'
import { api } 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, Maximize, Minimize } from '@lucide/vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import PdfReaderContent from './components/PdfReaderContent.vue'
import { toRotation, toScrollStrategy, toSpreadMode, toZoomLevel } from './pdf-viewer-utils'
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
const route = useRoute()
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 effectiveSettings = computed(() => bookSettings.effective.value as PdfReaderSettings)
const { onActivity, elapsedMinutes } = useReadingSession(
props.fileId,
() => ({
@@ -33,214 +47,262 @@ const { onActivity, elapsedMinutes } = useReadingSession(
{ trackingEnabled },
)
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes, 0, { trackingEnabled })
const absolutePdfiumWasmUrl = new URL(pdfiumWasmUrl, window.location.href).href
let saveTimer: ReturnType<typeof setTimeout> | null = null
let unsubPageChange: (() => void) | null = null
let unsubLayoutReady: (() => void) | null = null
let themeObserver: MutationObserver | null = null
let viewerContainer: EmbedPdfContainer | null = null
function buildThemeConfig() {
const isDark = getIsDark()
const accentHex = lookupAccentHex(themeStore.accent, ACCENT_OPTIONS)
return {
preference: isDark ? ('dark' as const) : ('light' as const),
light: { accent: { primary: accentHex } },
dark: { accent: { primary: accentHex } },
}
}
function scheduleSave(pageNumber: number, totalPages: number) {
currentPageNumber.value = pageNumber
totalPageCount.value = totalPages
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
progress.pageNumber.value = pageNumber
progress.percentage.value = (pageNumber / totalPages) * 100
progress.save()
}, 2000)
}
const configReady = ref(false)
const viewerConfig = ref<PDFViewerConfig>({})
const currentPageNumber = ref<number | null>(null)
const totalPageCount = ref<number | null>(null)
onMounted(async () => {
await bookSettings.load()
const settings = bookSettings.effective.value as PdfReaderSettings
const token = getAccessToken()
viewerConfig.value = {
theme: buildThemeConfig(),
tabBar: 'never',
disabledCategories: [
'annotation',
'signature',
'form',
'document-print',
'export',
'insert',
'redaction',
'document-open',
'document-close',
'document-protect',
'document-export',
],
scroll: {
defaultStrategy: settings.scrollMode === 'horizontal' ? ScrollStrategy.Horizontal : ScrollStrategy.Vertical,
},
spread: {
defaultSpreadMode: settings.spread === 'odd' ? SpreadMode.Odd : settings.spread === 'even' ? SpreadMode.Even : SpreadMode.None,
},
zoom: {
defaultZoomLevel:
settings.zoomMode === 'fit-width' ? ZoomMode.FitWidth : settings.zoomMode === 'fit-page' ? ZoomMode.FitPage : settings.customScale,
},
documentManager: {
initialDocuments: [
{
url: `/api/v1/books/files/${props.fileId}/serve`,
requestOptions: {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
},
},
],
},
}
configReady.value = true
const {
engine,
isLoading: engineLoading,
error: engineError,
} = usePdfiumEngine({
wasmUrl: absolutePdfiumWasmUrl,
worker: true,
})
const plugins = shallowRef<PluginBatchRegistrations>([])
const documentBuffer = shallowRef<ArrayBuffer | null>(null)
const readerReady = shallowRef(false)
const documentError = shallowRef<Error | null>(null)
const initialPage = shallowRef(1)
let saveTimer: ReturnType<typeof setTimeout> | null = null
let settingsTimer: ReturnType<typeof setTimeout> | null = null
let documentAbortController: AbortController | null = null
let loadSequence = 0
let pendingPageUpdate: { pageNumber: number; totalPages: number } | null = null
let pendingSettingsPatch: Partial<PdfReaderSettings> | null = null
function handleInit(container: EmbedPdfContainer) {
viewerContainer = container
function parseDeepLinkPage(): number | null {
const value = Array.isArray(route.query.page) ? route.query.page[0] : route.query.page
if (typeof value !== 'string') return null
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) && parsed >= 1 ? parsed : null
}
function handleReady(registry: PluginRegistry) {
setupProgressTracking(registry)
setupSettingsPersistence(registry)
setupThemeSync()
function buildPlugins(settings: PdfReaderSettings): PluginBatchRegistrations {
return [
createPluginRegistration(DocumentManagerPluginPackage, {
maxDocuments: 1,
}),
createPluginRegistration(ViewportPluginPackage, { viewportGap: 12 }),
createPluginRegistration(ScrollPluginPackage, {
defaultStrategy: toScrollStrategy(settings.scrollMode),
defaultPageGap: 12,
defaultBufferSize: 3,
}),
createPluginRegistration(InteractionManagerPluginPackage),
createPluginRegistration(ZoomPluginPackage, {
defaultZoomLevel: toZoomLevel(settings),
minZoom: 0.25,
maxZoom: 4,
}),
createPluginRegistration(PanPluginPackage, { defaultMode: 'mobile' }),
createPluginRegistration(SpreadPluginPackage, { defaultSpreadMode: toSpreadMode(settings.spread) }),
createPluginRegistration(RotatePluginPackage, { defaultRotation: toRotation(settings.rotation) }),
createPluginRegistration(RenderPluginPackage),
createPluginRegistration(TilingPluginPackage, {
tileSize: 768,
overlapPx: 2.5,
extraRings: 0,
}),
createPluginRegistration(SelectionPluginPackage, { marquee: { enabled: false } }),
createPluginRegistration(SearchPluginPackage),
createPluginRegistration(HistoryPluginPackage),
createPluginRegistration(AnnotationPluginPackage, {
autoOpenLinks: false,
locked: { type: LockModeType.All },
}),
createPluginRegistration(ThumbnailPluginPackage, {
width: 132,
paddingY: 12,
}),
createPluginRegistration(BookmarkPluginPackage),
]
}
async function setupProgressTracking(registry: PluginRegistry) {
await progress.load()
const scroll = registry.getPlugin<InstanceType<typeof ScrollPlugin>>(ScrollPlugin.id)?.provides() as ScrollCapability | undefined
if (!scroll) return
unsubLayoutReady = scroll.onLayoutReady(({ isInitial, totalPages }) => {
if (!isInitial) return
const deepLinkQuery = route?.query?.page
const deepLinkPage = typeof deepLinkQuery === 'string' ? Number.parseInt(deepLinkQuery, 10) : NaN
const initialPage = Number.isFinite(deepLinkPage) && deepLinkPage >= 1 ? deepLinkPage : (progress.pageNumber.value ?? 1)
currentPageNumber.value = initialPage
totalPageCount.value = totalPages
if (initialPage > 1 && initialPage <= totalPages) {
scroll.scrollToPage({ pageNumber: initialPage })
}
})
unsubPageChange = scroll.onPageChange(({ pageNumber, totalPages }) => {
onActivity()
scheduleSave(pageNumber, totalPages)
})
function applyPendingProgress() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = null
if (!pendingPageUpdate) return false
const { pageNumber, totalPages } = pendingPageUpdate
pendingPageUpdate = null
progress.pageNumber.value = pageNumber
progress.percentage.value = totalPages > 0 ? (pageNumber / totalPages) * 100 : progress.percentage.value
return true
}
function setupSettingsPersistence(registry: PluginRegistry) {
const scroll = registry.getPlugin<InstanceType<typeof ScrollPlugin>>(ScrollPlugin.id)?.provides() as ScrollCapability | undefined
if (scroll) {
scroll.onStateChange((state) => {
const strategy = state.strategy
if (strategy) {
bookSettings.updateBookSettings({ scrollMode: strategy as PdfReaderSettings['scrollMode'] })
}
})
}
function flushProgress() {
if (applyPendingProgress()) void progress.save()
}
function setupThemeSync() {
themeObserver = new MutationObserver(() => {
if (!viewerContainer) return
viewerContainer.setTheme(buildThemeConfig())
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
function handlePageChange(pageNumber: number, totalPages: number) {
onActivity()
pendingPageUpdate = { pageNumber, totalPages }
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(flushProgress, 2000)
}
async function startTrackedReading() {
function flushSettings() {
if (settingsTimer) clearTimeout(settingsTimer)
settingsTimer = null
if (!pendingSettingsPatch) return
const patch = pendingSettingsPatch
pendingSettingsPatch = null
bookSettings.updateBookSettings(patch)
}
function handleSettingsChange(patch: Partial<PdfReaderSettings>) {
pendingSettingsPatch = { ...pendingSettingsPatch, ...patch }
if (settingsTimer) clearTimeout(settingsTimer)
settingsTimer = setTimeout(flushSettings, 200)
}
async function handleStartReading() {
const query = { ...route.query }
delete query.mode
await router.replace({ name: 'reader', params: route.params, query })
await nextTick()
if (currentPageNumber.value !== null) {
progress.pageNumber.value = currentPageNumber.value
progress.percentage.value = totalPageCount.value ? (currentPageNumber.value / totalPageCount.value) * 100 : progress.percentage.value
}
applyPendingProgress()
await progress.save()
onActivity()
}
function goBack() {
function handleBack() {
router.back()
}
async function loadReader() {
const sequence = ++loadSequence
documentAbortController?.abort()
const abortController = new AbortController()
documentAbortController = abortController
documentError.value = null
documentBuffer.value = null
readerReady.value = false
try {
const [, , response] = await Promise.all([
bookSettings.load(),
progress.load(),
api(`/api/v1/books/files/${props.fileId}/serve`, { signal: abortController.signal }),
])
if (!response.ok) throw new Error(`The PDF request failed with status ${response.status}.`)
const buffer = await response.arrayBuffer()
if (buffer.byteLength === 0) throw new Error('The PDF request returned an empty document.')
if (sequence !== loadSequence || abortController.signal.aborted) return
const settings = bookSettings.effective.value as PdfReaderSettings
initialPage.value = parseDeepLinkPage() ?? progress.pageNumber.value ?? 1
documentBuffer.value = buffer
plugins.value = buildPlugins(settings)
readerReady.value = true
} catch (error) {
if (sequence !== loadSequence || abortController.signal.aborted) return
documentError.value = error instanceof Error ? error : new Error('The PDF could not be loaded.')
} finally {
if (documentAbortController === abortController) documentAbortController = null
}
}
async function handleEmbedInitialized(registry: PluginRegistry) {
const sequence = loadSequence
const buffer = documentBuffer.value
if (!buffer) return
await registry.pluginsReady()
if (sequence !== loadSequence || registry.isDestroyed()) return
const documentManager = registry.getPlugin<DocumentManagerPlugin>(DocumentManagerPlugin.id)?.provides()
if (!documentManager) {
documentError.value = new Error('The PDF document manager could not be initialized.')
return
}
let documentId: string | null = null
try {
for (let attempt = 0; attempt < 2; attempt += 1) {
const opened = await documentManager
.openDocumentBuffer({
buffer,
name: 'PDF',
rotation: toRotation(effectiveSettings.value.rotation),
})
.toPromise()
documentId = opened.documentId
const document = await opened.task.toPromise()
if (sequence !== loadSequence || registry.isDestroyed()) return
if (document.pageCount > 0) return
await documentManager.closeDocument(opened.documentId).toPromise()
if (sequence !== loadSequence || registry.isDestroyed()) return
}
documentError.value = new Error('The PDF engine opened this document without any pages.')
} catch (error) {
if (sequence !== loadSequence || registry.isDestroyed()) return
if (documentId && documentManager.getDocumentState(documentId)?.status === 'error') return
documentError.value = error instanceof Error ? error : new Error('The PDF could not be opened by the document engine.')
}
}
function handleRetry() {
if (engineError.value) {
window.location.reload()
return
}
void loadReader()
}
onMounted(loadReader)
onUnmounted(() => {
unsubPageChange?.()
unsubLayoutReady?.()
themeObserver?.disconnect()
if (saveTimer) clearTimeout(saveTimer)
loadSequence += 1
documentAbortController?.abort()
flushProgress()
flushSettings()
})
</script>
<template>
<div class="fixed inset-0 flex flex-col">
<!-- Floating reader controls -->
<div class="pointer-events-none absolute bottom-6 left-6 z-50 flex gap-2">
<Tooltip>
<TooltipTrigger as-child>
<div class="fixed inset-0 overflow-hidden bg-background text-foreground">
<div v-if="engineError || documentError" class="absolute inset-0 flex items-center justify-center p-6">
<div class="max-w-sm text-center">
<p class="mb-2 text-sm font-medium text-foreground">Unable to open this PDF</p>
<p class="text-xs text-muted-foreground">{{ (engineError || documentError)?.message }}</p>
<div class="mt-4 flex justify-center gap-2">
<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"
class="rounded-md border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-foreground"
@click="handleBack"
>
<ArrowLeft :size="22" />
Go back
</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">
<div class="pointer-events-auto flex h-8 items-center gap-2 rounded-md border border-primary/30 bg-background/95 px-2 text-primary shadow-sm">
<span class="text-xs font-medium">Peeking</span>
<button
class="h-6 rounded-sm bg-primary px-2 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90"
@click="startTrackedReading"
>
Start reading
</button>
<button class="rounded-md bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground" @click="handleRetry">Retry</button>
</div>
</div>
</div>
<PDFViewer v-if="configReady" :config="viewerConfig" style="flex: 1; min-height: 0" @init="handleInit" @ready="handleReady" />
<div v-else-if="engineLoading || !engine || !readerReady" class="absolute inset-0 flex items-center justify-center bg-background">
<div class="flex flex-col items-center gap-3 text-muted-foreground" role="status" aria-live="polite">
<LoaderCircle :size="30" class="animate-spin text-primary" />
<span class="text-sm">Preparing PDF reader</span>
</div>
</div>
<EmbedPDF v-else :engine="engine" :plugins="plugins" :on-initialized="handleEmbedInitialized">
<template #default="{ pluginsReady, activeDocumentId, activeDocument }">
<div v-if="!pluginsReady || !activeDocumentId || !activeDocument" class="absolute inset-0 flex items-center justify-center bg-background">
<div class="flex flex-col items-center gap-3 text-muted-foreground" role="status" aria-live="polite">
<LoaderCircle :size="30" class="animate-spin text-primary" />
<span class="text-sm">Opening PDF</span>
</div>
</div>
<PdfReaderContent
v-else
:document-id="activeDocumentId"
:initial-page="initialPage"
:settings="effectiveSettings"
:peek-mode="props.peekMode"
@back="handleBack"
@page-change="handlePageChange"
@retry="handleRetry"
@settings-change="handleSettingsChange"
@start-reading="handleStartReading"
/>
</template>
</EmbedPDF>
</div>
</template>
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import PdfDocumentViewport from '../components/PdfDocumentViewport.vue'
const passthroughStub = (testId: string) =>
defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => h('div', { ...attrs, 'data-testid': testId }, slots.default?.())
},
})
const layerStub = (testId: string) =>
defineComponent({
inheritAttrs: false,
setup(_, { attrs }) {
return () => h('div', { ...attrs, 'data-testid': testId })
},
})
describe('PdfDocumentViewport', () => {
it('keeps rotation outside the sized pointer layer so page backdrops fill at every zoom', () => {
const wrapper = mount(PdfDocumentViewport, {
props: { documentId: 'doc-1' },
global: {
stubs: {
GlobalPointerProvider: passthroughStub('global-pointer'),
Viewport: passthroughStub('viewport'),
ZoomGestureWrapper: passthroughStub('zoom-wrapper'),
Scroller: defineComponent({
setup(_, { slots }) {
return () => h('div', { 'data-testid': 'scroller' }, slots.default?.({ page: { pageIndex: 3 } }))
},
}),
Rotate: passthroughStub('rotate'),
PagePointerProvider: passthroughStub('page-pointer'),
RenderLayer: layerStub('render'),
TilingLayer: layerStub('tiling'),
SearchLayer: layerStub('search'),
SelectionLayer: layerStub('selection'),
AnnotationLayer: layerStub('annotation'),
},
},
})
const rotate = wrapper.get('[data-testid="rotate"]')
const pointer = rotate.get('[data-testid="page-pointer"]')
expect(pointer.get('[data-testid="render"]').attributes('scale')).toBe('0.5')
expect(pointer.find('[data-testid="tiling"]').exists()).toBe(true)
expect(pointer.get('[data-testid="search"]').attributes('highlight-color')).toContain('var(--primary)')
})
})
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import PdfReaderSettingsPanel from '../components/PdfReaderSettingsPanel.vue'
function mountPanel() {
return mount(PdfReaderSettingsPanel, {
props: {
scrollMode: 'vertical',
spread: 'none',
rotation: 0,
zoomMode: 'fit-page',
customScale: 1,
},
})
}
describe('PdfReaderSettingsPanel', () => {
afterEach(() => {
vi.useRealTimers()
})
it('debounces zoom previews and persists only the committed slider value', async () => {
vi.useFakeTimers()
const wrapper = mountPanel()
const slider = wrapper.get('input[type="range"]')
const element = slider.element as HTMLInputElement
element.value = '1.5'
await slider.trigger('input')
element.value = '1.75'
await slider.trigger('input')
await vi.advanceTimersByTimeAsync(99)
expect(wrapper.emitted('previewZoom')).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
expect(wrapper.emitted('previewZoom')).toEqual([[1.75]])
expect(wrapper.emitted('setZoom')).toBeUndefined()
element.value = '2'
await slider.trigger('input')
await slider.trigger('change')
await vi.runAllTimersAsync()
expect(wrapper.emitted('previewZoom')).toEqual([[1.75]])
expect(wrapper.emitted('setZoom')).toEqual([['custom', 2]])
})
it('exposes page, horizontal, automatic zoom, and auto spread choices', async () => {
const wrapper = mountPanel()
const buttons = wrapper.findAll('button')
const clickLabel = async (label: string) => buttons.find((button) => button.text() === label)!.trigger('click')
await clickLabel('Page')
await clickLabel('Horizontal scroll')
await clickLabel('Automatic')
await clickLabel('Auto')
expect(wrapper.emitted('setScrollMode')).toEqual([['page'], ['horizontal']])
expect(wrapper.emitted('setZoom')).toEqual([['automatic']])
expect(wrapper.emitted('setSpread')).toEqual([['auto']])
})
})
@@ -0,0 +1,231 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { MatchFlag, type SearchResult } from '@embedpdf/models'
const mocks = vi.hoisted(() => ({
searchState: {
__v_isRef: true,
value: {
flags: [] as MatchFlag[],
results: [] as SearchResult[],
total: 0,
activeResultIndex: -1,
query: '',
loading: false,
active: false,
},
},
searchScope: {
searchAllPages: vi.fn<(query: string) => { onProgress: (callback: (progress: { page: number }) => void) => void } | undefined>(),
stopSearch: vi.fn<() => void>(),
setFlags: vi.fn<(flags: MatchFlag[]) => void>(),
goToResult: vi.fn<(index: number) => void>(),
},
scrollScope: {
scrollToPage: vi.fn<(options: Record<string, unknown>) => void>(),
},
}))
vi.mock('@embedpdf/plugin-search/vue', async () => {
const { defineComponent, h } = await import('vue')
return {
SearchLayer: defineComponent({
setup:
(_, { slots }) =>
() =>
h('div', slots.default?.()),
}),
useSearch: () => ({ state: mocks.searchState, provides: { value: mocks.searchScope } }),
}
})
vi.mock('@embedpdf/plugin-scroll/vue', () => ({
useScroll: () => ({
state: { __v_isRef: true, value: { currentPage: 1, totalPages: 10 } },
provides: { value: mocks.scrollScope },
}),
}))
vi.mock('@embedpdf/plugin-bookmark/vue', () => ({
useBookmarkCapability: () => ({ provides: { value: null } }),
}))
vi.mock('@embedpdf/plugin-annotation/vue', () => ({
useAnnotationCapability: () => ({ provides: { value: null } }),
}))
vi.mock('@embedpdf/plugin-thumbnail/vue', async () => {
const { defineComponent, h } = await import('vue')
return {
ThumbImg: defineComponent({ setup: () => () => h('img') }),
ThumbnailsPane: defineComponent({
setup:
(_, { slots }) =>
() =>
h('div', slots.default?.()),
}),
}
})
vi.mock('vue-virtual-scroller', async () => {
const { defineComponent, h } = await import('vue')
return {
RecycleScroller: defineComponent({
inheritAttrs: false,
props: { items: { type: Array, required: true } },
setup(props, { attrs, slots }) {
return () =>
h(
'div',
{ ...attrs, 'data-testid': 'search-results' },
props.items.map((item, index) => slots.default?.({ item, index })),
)
},
}),
}
})
import PdfReaderSidebar from '../components/PdfReaderSidebar.vue'
function makeResult(rects: SearchResult['rects'] = []): SearchResult {
return {
pageIndex: 4,
charIndex: 10,
charCount: 4,
rects,
context: {
before: 'before',
match: 'term',
after: 'after',
truncatedLeft: false,
truncatedRight: false,
},
}
}
describe('PdfReaderSidebar', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mocks.searchScope.searchAllPages.mockReset()
mocks.searchState.value = {
flags: [],
results: [],
total: 0,
activeResultIndex: -1,
query: '',
loading: false,
active: false,
}
})
it('debounces full-document searches and cancels superseded work', async () => {
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
const input = wrapper.get('input[type="search"]')
mocks.searchScope.stopSearch.mockClear()
await input.setValue('dis')
await input.setValue('distance')
expect(mocks.searchScope.stopSearch).toHaveBeenCalledTimes(2)
expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(249)
expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(mocks.searchScope.searchAllPages).toHaveBeenCalledTimes(1)
expect(mocks.searchScope.searchAllPages).toHaveBeenCalledWith('distance')
})
it('does not search for a single character', async () => {
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
mocks.searchScope.stopSearch.mockClear()
await wrapper.get('input[type="search"]').setValue('a')
await vi.runAllTimersAsync()
expect(mocks.searchScope.stopSearch).toHaveBeenCalledOnce()
expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Enter at least 2 characters')
})
it('lets the plugin restart an active search once when flags change', async () => {
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
await wrapper.get('input[type="checkbox"]').setValue(true)
expect(mocks.searchScope.setFlags).toHaveBeenCalledWith([MatchFlag.MatchCase])
expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled()
})
it('keeps virtual results bounded and navigates results without rectangles safely', async () => {
mocks.searchState.value = {
...mocks.searchState.value,
results: [makeResult()],
total: 1,
active: true,
}
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
expect(wrapper.get('aside').classes()).toContain('overflow-hidden')
expect(wrapper.get('[data-testid="search-results"]').classes()).toContain('overflow-x-hidden')
await wrapper.get('[data-testid="search-results"] button').trigger('click')
expect(mocks.searchScope.goToResult).toHaveBeenCalledWith(0)
expect(mocks.scrollScope.scrollToPage).toHaveBeenCalledWith({ pageNumber: 5 })
})
it('reports full-document search progress', async () => {
mocks.searchState.value = {
...mocks.searchState.value,
query: 'distance',
loading: true,
}
mocks.searchScope.searchAllPages.mockReturnValue({
onProgress: (callback: (progress: { page: number }) => void) => callback({ page: 4 }),
})
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
await vi.advanceTimersByTimeAsync(250)
expect(wrapper.text()).toContain('Searching page 5 of 10')
})
it('uses Enter and Shift+Enter to navigate search results', async () => {
mocks.searchState.value = {
...mocks.searchState.value,
results: [makeResult(), makeResult()],
total: 2,
activeResultIndex: 0,
active: true,
}
const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } })
const input = wrapper.get('input[type="search"]')
await input.trigger('keydown', { key: 'Enter' })
expect(mocks.searchScope.goToResult).toHaveBeenLastCalledWith(1)
mocks.searchState.value.activeResultIndex = 1
await input.trigger('keydown', { key: 'Enter', shiftKey: true })
expect(mocks.searchScope.goToResult).toHaveBeenLastCalledWith(0)
})
it('clears the query, cancels search work, and restores input focus', async () => {
const wrapper = mount(PdfReaderSidebar, {
props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true },
attachTo: document.body,
})
const input = wrapper.get('input[type="search"]')
await input.setValue('distance')
mocks.searchScope.stopSearch.mockClear()
await wrapper.get('button[aria-label="Clear search"]').trigger('click')
expect((input.element as HTMLInputElement).value).toBe('')
expect(mocks.searchScope.stopSearch).toHaveBeenCalled()
expect(document.activeElement).toBe(input.element)
wrapper.unmount()
})
})
@@ -0,0 +1,84 @@
import { defineComponent, h } from 'vue'
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import PdfReaderToolbar from '../components/PdfReaderToolbar.vue'
const passthrough = defineComponent({
setup:
(_, { slots }) =>
() =>
h('div', slots.default?.()),
})
function mountToolbar(overrides: Partial<InstanceType<typeof PdfReaderToolbar>['$props']> = {}) {
return mount(PdfReaderToolbar, {
props: {
currentPageStart: 12,
currentPageEnd: 13,
totalPages: 240,
zoomPercent: 100,
sidebarOpen: false,
searchOpen: false,
settingsOpen: false,
panActive: true,
fullscreen: false,
fullscreenSupported: true,
headerPinned: false,
...overrides,
},
global: {
stubs: {
Tooltip: passthrough,
TooltipTrigger: passthrough,
TooltipContent: passthrough,
DropdownMenu: passthrough,
DropdownMenuTrigger: passthrough,
DropdownMenuContent: passthrough,
},
},
})
}
describe('PdfReaderToolbar', () => {
it('shows the current spread and commits a clamped page number', async () => {
const wrapper = mountToolbar()
const input = wrapper.get('input[aria-label="Current page"]')
expect(wrapper.text()).toContain('-13')
expect(wrapper.text()).toContain('/ 240')
;(input.element as HTMLInputElement).value = '999'
await input.trigger('change')
expect(wrapper.emitted('goToPage')).toEqual([[240]])
expect((input.element as HTMLInputElement).value).toBe('240')
})
it('keeps search available outside fullscreen and exposes responsive tools', async () => {
const wrapper = mountToolbar()
expect(wrapper.findAll('button[aria-label="Search document"]')).toHaveLength(1)
expect(wrapper.find('button[aria-label="More PDF tools"]').exists()).toBe(true)
const buttons = wrapper.findAll('button')
await buttons.find((button) => button.text().trim() === 'Search')!.trigger('click')
await buttons.find((button) => button.text().trim() === 'Select text')!.trigger('click')
await buttons.find((button) => button.text().trim() === 'Pan')!.trigger('click')
expect(wrapper.emitted('toggleSearch')).toHaveLength(1)
expect(wrapper.emitted('selectTool')).toHaveLength(1)
expect(wrapper.emitted('togglePan')).toHaveLength(1)
})
it('shows the header pin only while fullscreen', async () => {
const windowed = mountToolbar()
expect(windowed.find('button[aria-label="Pin reader header"]').exists()).toBe(false)
const fullscreen = mountToolbar({ fullscreen: true })
const pinButtons = fullscreen.findAll('button[aria-label="Pin reader header"]')
expect(pinButtons).toHaveLength(1)
await pinButtons[0]!.trigger('click')
expect(fullscreen.emitted('toggleHeaderPin')).toHaveLength(1)
})
})
@@ -1,13 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h, nextTick } from 'vue'
import type { PDFViewerConfig } from '@embedpdf/vue-pdf-viewer'
import { defineComponent, h, ref } from 'vue'
import { ScrollStrategy } from '@embedpdf/plugin-scroll'
import { SpreadMode } from '@embedpdf/plugin-spread'
import { ZoomMode } from '@embedpdf/plugin-zoom'
import type { PdfReaderSettings } from '@bookorbit/types'
const mockRouterBack = vi.fn<() => void>()
const mockRouterReplace = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const mockRoute = {
params: { bookId: '42', fileId: '101' },
query: {} as Record<string, string>,
}
vi.mock('vue-router', () => ({
useRoute: () => mockRoute,
useRouter: () => ({ back: mockRouterBack, replace: mockRouterReplace }),
}))
const mockApi = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
vi.mock('@/lib/api', () => ({ api: mockApi }))
const mockSettingsLoad = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const mockUpdateBookSettings = vi.fn<(patch: Partial<PdfReaderSettings>) => void>()
const mockEffective = ref<PdfReaderSettings>({
scrollMode: 'page' as const,
spread: 'none' as const,
zoomMode: 'fit-page' as const,
customScale: 1,
rotation: 0,
})
vi.mock('../../shared/composables/useReaderSettings', () => ({
useReaderSettings: () => ({
load: mockSettingsLoad,
effective: mockEffective,
updateBookSettings: mockUpdateBookSettings,
}),
}))
const mockProgressLoad = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const mockProgressSave = vi.fn<() => void>()
const mockProgressPageNumber = { value: 5 }
const mockProgressPercentage = { value: 25 }
const mockProgressSave = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const mockProgressPageNumber = ref<number | null>(5)
const mockProgressPercentage = ref(25)
vi.mock('../../shared/composables/useReaderProgress', () => ({
useReaderProgress: () => ({
@@ -18,342 +53,247 @@ vi.mock('../../shared/composables/useReaderProgress', () => ({
}),
}))
const mockSettingsLoad = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const mockUpdateBookSettings = vi.fn<() => void>()
const mockEffective = {
value: {
scrollMode: 'vertical' as string,
spread: 'none' as string,
zoomMode: 'fit-page' as string,
customScale: 1.0,
rotation: 0 as number,
},
}
vi.mock('../../shared/composables/useReaderSettings', () => ({
useReaderSettings: () => ({
load: mockSettingsLoad,
updateBookSettings: mockUpdateBookSettings,
effective: mockEffective,
}),
}))
const mockOnActivity = vi.fn<() => void>()
vi.mock('../../shared/composables/useReadingSession', () => ({
useReadingSession: () => ({
onActivity: mockOnActivity,
elapsedMinutes: ref(0),
}),
}))
const requestFullscreenMock = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const exitFullscreenMock = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
vi.mock('@/lib/api', () => ({
getAccessToken: () => 'test-token-abc',
const mockUsePdfiumEngine = vi.fn<() => unknown>(() => ({
engine: ref({ id: 'engine' }),
isLoading: ref(false),
error: ref(null),
}))
vi.mock('@embedpdf/engines/vue', () => ({
usePdfiumEngine: mockUsePdfiumEngine,
}))
let capturedConfig: PDFViewerConfig | null = null
let readyCallback: ((registry: unknown) => void) | null = null
let initCallback: ((container: unknown) => void) | null = null
const mockCreatePluginRegistration = vi.fn<(pluginPackage: unknown, config?: unknown) => { pluginPackage: unknown; config?: unknown }>(
(pluginPackage, config) => ({ pluginPackage, config }),
)
const mockOpenDocumentBuffer = vi.hoisted(() => vi.fn<(options: unknown) => unknown>())
const mockCloseDocument = vi.hoisted(() => vi.fn<(documentId: string) => unknown>())
const mockGetDocumentState = vi.hoisted(() => vi.fn<(documentId: string) => unknown>())
const mockDocumentManager = {
openDocumentBuffer: mockOpenDocumentBuffer,
closeDocument: mockCloseDocument,
getDocumentState: mockGetDocumentState,
}
const mockRegistry = {
pluginsReady: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
isDestroyed: vi.fn<() => boolean>().mockReturnValue(false),
getPlugin: vi.fn<() => { provides: () => typeof mockDocumentManager }>(() => ({ provides: () => mockDocumentManager })),
}
vi.mock('@embedpdf/core', async (importOriginal) => {
const original = await importOriginal<typeof import('@embedpdf/core')>()
return { ...original, createPluginRegistration: mockCreatePluginRegistration }
})
vi.mock('@embedpdf/vue-pdf-viewer', () => ({
PDFViewer: defineComponent({
props: ['config'],
emits: ['ready', 'init'],
setup(props, { emit }) {
capturedConfig = props.config
readyCallback = (registry: unknown) => emit('ready', registry)
initCallback = (container: unknown) => emit('init', container)
return () => h('div', { class: 'mock-pdf-viewer' })
vi.mock('@embedpdf/core/vue', () => ({
EmbedPDF: defineComponent({
props: ['engine', 'plugins', 'onInitialized'],
setup(props, { slots }) {
void props.onInitialized?.(mockRegistry)
return () =>
h(
'div',
{ class: 'mock-embed-pdf' },
slots.default?.({ pluginsReady: true, activeDocumentId: 'document-1', activeDocument: { status: 'loaded' } }),
)
},
}),
ScrollPlugin: { id: 'scroll' },
ScrollStrategy: { Vertical: 'vertical', Horizontal: 'horizontal' },
SpreadMode: { None: 'none', Odd: 'odd', Even: 'even' },
ZoomMode: { Automatic: 'automatic', FitPage: 'fit-page', FitWidth: 'fit-width' },
}))
// Must import AFTER mocks are set up
const { default: PdfV4ReaderView } = await import('../PdfV4ReaderView.vue')
describe('PdfV4ReaderView', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedConfig = null
readyCallback = null
initCallback = null
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,
})
mockSettingsLoad.mockResolvedValue(undefined)
mockApi.mockReset()
mockApi.mockImplementation(async () => new Response(new Uint8Array([37, 80, 68, 70]), { status: 200 }))
mockOpenDocumentBuffer.mockReset()
mockOpenDocumentBuffer.mockImplementation(() => ({
toPromise: async () => ({
documentId: 'document-1',
task: { toPromise: async () => ({ pageCount: 12 }) },
}),
}))
mockCloseDocument.mockReset()
mockCloseDocument.mockImplementation(() => ({ toPromise: async () => undefined }))
mockGetDocumentState.mockReset()
mockRegistry.pluginsReady.mockResolvedValue(undefined)
mockRegistry.isDestroyed.mockReturnValue(false)
mockRegistry.getPlugin.mockReturnValue({ provides: () => mockDocumentManager })
mockRoute.query = {}
mockProgressPageNumber.value = 5
mockProgressPercentage.value = 25
mockEffective.value = {
scrollMode: 'page',
spread: 'none',
zoomMode: 'fit-page',
customScale: 1,
rotation: 0,
}
})
async function mountComponent() {
const pinia = createPinia()
async function mountReader(peekMode = false) {
const wrapper = mount(PdfV4ReaderView, {
props: { bookId: 42, fileId: 101 },
props: { bookId: 42, fileId: 101, peekMode },
global: {
plugins: [pinia],
stubs: {
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
PdfReaderContent: {
name: 'PdfReaderContent',
props: ['documentId', 'initialPage', 'settings', 'peekMode'],
emits: ['back', 'pageChange', 'retry', 'settingsChange', 'startReading'],
template: '<div class="mock-reader-content" />',
},
},
},
})
await flushPromises()
await nextTick()
return wrapper
}
describe('initialization', () => {
it('loads book settings on mount', async () => {
await mountComponent()
expect(mockSettingsLoad).toHaveBeenCalled()
it('loads settings and progress before rendering document content', async () => {
const wrapper = await mountReader()
expect(mockSettingsLoad).toHaveBeenCalledOnce()
expect(mockProgressLoad).toHaveBeenCalledOnce()
expect(wrapper.find('.mock-reader-content').exists()).toBe(true)
})
it('opens the verified document buffer after every plugin is initialized', async () => {
await mountReader()
const documentConfig = mockCreatePluginRegistration.mock.calls[0]?.[1] as {
maxDocuments: number
}
expect(documentConfig.maxDocuments).toBe(1)
expect(documentConfig).not.toHaveProperty('initialDocuments')
expect(mockRegistry.pluginsReady).toHaveBeenCalledOnce()
expect(mockOpenDocumentBuffer).toHaveBeenCalledWith({
name: 'PDF',
buffer: expect.any(ArrayBuffer),
rotation: 0,
})
expect(mockApi).toHaveBeenCalledWith('/api/v1/books/files/101/serve', { signal: expect.any(AbortSignal) })
})
it('renders viewer only after config is ready', async () => {
const wrapper = await mountComponent()
expect(wrapper.find('.mock-pdf-viewer').exists()).toBe(true)
})
it('retries a zero-page engine result once before showing an error', async () => {
mockOpenDocumentBuffer.mockImplementation(() => ({
toPromise: async () => ({
documentId: 'document-zero',
task: { toPromise: async () => ({ pageCount: 0 }) },
}),
}))
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()
const wrapper = await mountReader()
expect(requestFullscreenMock).toHaveBeenCalled()
expect(mockOpenDocumentBuffer).toHaveBeenCalledTimes(2)
expect(mockCloseDocument).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('The PDF engine opened this document without any pages.')
})
it('shows a recoverable error when the PDF response is unsuccessful', async () => {
mockApi.mockResolvedValueOnce(new Response(null, { status: 503 }))
const wrapper = await mountReader()
expect(wrapper.text()).toContain('The PDF request failed with status 503.')
expect(wrapper.text()).toContain('Retry')
})
it('rejects an empty PDF response instead of mounting a zero-page document', async () => {
mockApi.mockResolvedValueOnce(new Response(null, { status: 200 }))
const wrapper = await mountReader()
expect(wrapper.text()).toContain('The PDF request returned an empty document.')
expect(wrapper.find('.mock-embed-pdf').exists()).toBe(false)
})
it('refetches the PDF when document content requests recovery', async () => {
const wrapper = await mountReader()
mockApi.mockClear()
wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('retry')
await flushPromises()
expect(mockApi).toHaveBeenCalledOnce()
expect(wrapper.find('.mock-reader-content').exists()).toBe(true)
})
it('runs PDFium in a worker with an absolute WASM URL', async () => {
await mountReader()
expect(mockUsePdfiumEngine).toHaveBeenCalledWith({
wasmUrl: expect.stringMatching(/^https?:\/\//),
worker: true,
})
})
describe('config construction', () => {
it('sets tabBar to never', async () => {
await mountComponent()
expect(capturedConfig?.tabBar).toBe('never')
})
it('shows a recoverable error when reader state fails to load', async () => {
mockSettingsLoad.mockRejectedValueOnce(new Error('Settings unavailable'))
it('disables annotation, signature, form, print, export, insert, and redaction categories', async () => {
await mountComponent()
expect(capturedConfig?.disabledCategories).toEqual(
expect.arrayContaining(['annotation', 'signature', 'form', 'document-print', 'export', 'insert', 'redaction']),
)
})
const wrapper = await mountReader()
it('includes auth header in document request options', async () => {
await mountComponent()
const docs = capturedConfig?.documentManager?.initialDocuments
expect(docs).toHaveLength(1)
const doc = docs?.[0] as Record<string, unknown> | undefined
expect((doc?.requestOptions as Record<string, unknown>)?.headers).toEqual({
Authorization: 'Bearer test-token-abc',
})
})
it('sets document URL using fileId prop', async () => {
await mountComponent()
const docs = capturedConfig?.documentManager?.initialDocuments
const doc = docs?.[0] as Record<string, unknown> | undefined
expect(doc?.url).toBe('/api/v1/books/files/101/serve')
})
it('maps scroll mode from settings', async () => {
mockEffective.value.scrollMode = 'horizontal'
await mountComponent()
expect(capturedConfig?.scroll?.defaultStrategy).toBe('horizontal')
mockEffective.value.scrollMode = 'vertical'
})
it('maps spread mode from settings', async () => {
mockEffective.value.spread = 'odd'
await mountComponent()
expect(capturedConfig?.spread?.defaultSpreadMode).toBe('odd')
mockEffective.value.spread = 'none'
})
it('maps fit-page zoom from settings', async () => {
mockEffective.value.zoomMode = 'fit-page'
await mountComponent()
expect(capturedConfig?.zoom?.defaultZoomLevel).toBe('fit-page')
})
it('maps custom zoom scale from settings', async () => {
mockEffective.value.zoomMode = 'custom'
mockEffective.value.customScale = 1.5
await mountComponent()
expect(capturedConfig?.zoom?.defaultZoomLevel).toBe(1.5)
mockEffective.value.zoomMode = 'fit-page'
mockEffective.value.customScale = 1.0
})
expect(wrapper.text()).toContain('Unable to open this PDF')
expect(wrapper.text()).toContain('Settings unavailable')
expect(wrapper.text()).toContain('Retry')
expect(wrapper.find('.mock-embed-pdf').exists()).toBe(false)
})
describe('theme configuration', () => {
it('sets light preference when not in dark mode', async () => {
localStorage.setItem('theme', JSON.stringify('light'))
await mountComponent()
expect(capturedConfig?.theme?.preference).toBe('light')
})
it('maps persisted layout settings into headless plugin configuration', async () => {
mockEffective.value = {
scrollMode: 'horizontal',
spread: 'odd',
zoomMode: 'fit-width',
customScale: 1.5,
rotation: 0,
}
await mountReader()
it('sets dark preference when in dark mode', async () => {
document.documentElement.classList.add('dark')
await mountComponent()
expect(capturedConfig?.theme?.preference).toBe('dark')
})
it('includes accent color overrides from theme store', async () => {
await mountComponent()
expect(capturedConfig?.theme?.light?.accent?.primary).toBeDefined()
expect(capturedConfig?.theme?.dark?.accent?.primary).toBeDefined()
expect(capturedConfig?.theme?.light?.accent?.primary).toBe(capturedConfig?.theme?.dark?.accent?.primary)
})
it('syncs theme on class attribute change via setTheme', async () => {
const mockSetTheme = vi.fn<() => void>()
const mockContainer = { setTheme: mockSetTheme }
await mountComponent()
initCallback?.(mockContainer)
const mockRegistry = {
getPlugin: () => ({
provides: () => ({
onLayoutReady: () => () => {},
onPageChange: () => () => {},
onStateChange: () => () => {},
scrollToPage: vi.fn<() => void>(),
}),
}),
}
readyCallback?.(mockRegistry)
await flushPromises()
document.documentElement.classList.add('dark')
await flushPromises()
await new Promise((r) => setTimeout(r, 50))
expect(mockSetTheme).toHaveBeenCalledWith(expect.objectContaining({ preference: 'dark' }))
})
expect(mockCreatePluginRegistration.mock.calls[2]?.[1]).toMatchObject({ defaultStrategy: ScrollStrategy.Horizontal })
expect(mockCreatePluginRegistration.mock.calls[4]?.[1]).toMatchObject({ defaultZoomLevel: ZoomMode.FitWidth })
expect(mockCreatePluginRegistration.mock.calls[6]?.[1]).toMatchObject({ defaultSpreadMode: SpreadMode.Odd })
})
describe('progress tracking', () => {
it('loads progress when viewer becomes ready', async () => {
await mountComponent()
const mockRegistry = {
getPlugin: () => ({
provides: () => ({
onLayoutReady: vi.fn<() => void>(),
onPageChange: vi.fn<() => void>(),
onStateChange: vi.fn<() => void>(),
}),
}),
}
readyCallback?.(mockRegistry)
await flushPromises()
expect(mockProgressLoad).toHaveBeenCalled()
})
it('uses a deep-linked page before saved progress', async () => {
mockRoute.query = { page: '12' }
const wrapper = await mountReader()
it('scrolls to saved page on layout ready', async () => {
const mockScrollToPage = vi.fn<() => void>()
const layoutReadyHandlers: ((event: { isInitial: boolean; totalPages: number }) => void)[] = []
const mockRegistry = {
getPlugin: () => ({
provides: () => ({
onLayoutReady: (handler: (e: { isInitial: boolean; totalPages: number }) => void) => {
layoutReadyHandlers.push(handler)
return () => {}
},
onPageChange: () => () => {},
onStateChange: () => () => {},
scrollToPage: mockScrollToPage,
}),
}),
}
await mountComponent()
readyCallback?.(mockRegistry)
await flushPromises()
layoutReadyHandlers[0]?.({ isInitial: true, totalPages: 20 })
expect(mockScrollToPage).toHaveBeenCalledWith({ pageNumber: 5 })
})
it('does not scroll if saved page is 1', async () => {
mockProgressPageNumber.value = 1
const mockScrollToPage = vi.fn<() => void>()
const layoutReadyHandlers: ((event: { isInitial: boolean; totalPages: number }) => void)[] = []
const mockRegistry = {
getPlugin: () => ({
provides: () => ({
onLayoutReady: (handler: (e: { isInitial: boolean; totalPages: number }) => void) => {
layoutReadyHandlers.push(handler)
return () => {}
},
onPageChange: () => () => {},
onStateChange: () => () => {},
scrollToPage: mockScrollToPage,
}),
}),
}
await mountComponent()
readyCallback?.(mockRegistry)
await flushPromises()
layoutReadyHandlers[0]?.({ isInitial: true, totalPages: 20 })
expect(mockScrollToPage).not.toHaveBeenCalled()
mockProgressPageNumber.value = 5
})
it('calls onActivity on page change', async () => {
const pageChangeHandlers: ((event: { pageNumber: number; totalPages: number }) => void)[] = []
const mockRegistry = {
getPlugin: () => ({
provides: () => ({
onLayoutReady: () => () => {},
onPageChange: (handler: (e: { pageNumber: number; totalPages: number }) => void) => {
pageChangeHandlers.push(handler)
return () => {}
},
onStateChange: () => () => {},
scrollToPage: vi.fn<() => void>(),
}),
}),
}
await mountComponent()
readyCallback?.(mockRegistry)
await flushPromises()
pageChangeHandlers[0]?.({ pageNumber: 3, totalPages: 10 })
expect(mockOnActivity).toHaveBeenCalled()
})
expect(wrapper.getComponent({ name: 'PdfReaderContent' }).props('initialPage')).toBe(12)
})
describe('cleanup', () => {
it('disconnects mutation observer on unmount', async () => {
const wrapper = await mountComponent()
const mockRegistry = {
getPlugin: () => ({
provides: () => ({
onLayoutReady: () => () => {},
onPageChange: () => () => {},
onStateChange: () => () => {},
scrollToPage: vi.fn<() => void>(),
}),
}),
}
readyCallback?.(mockRegistry)
await flushPromises()
it('debounces page progress saves', async () => {
vi.useFakeTimers()
const wrapper = await mountReader()
wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('pageChange', 8, 20)
await vi.advanceTimersByTimeAsync(2000)
expect(() => wrapper.unmount()).not.toThrow()
})
expect(mockProgressPageNumber.value).toBe(8)
expect(mockProgressPercentage.value).toBe(40)
expect(mockProgressSave).toHaveBeenCalledOnce()
expect(mockOnActivity).toHaveBeenCalledOnce()
vi.useRealTimers()
})
it('keeps peek mode out of tracked progress and starts reading on request', async () => {
const wrapper = await mountReader(true)
wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('startReading')
await flushPromises()
expect(mockRouterReplace).toHaveBeenCalledWith({ name: 'reader', params: mockRoute.params, query: {} })
expect(mockProgressSave).toHaveBeenCalledOnce()
expect(mockOnActivity).toHaveBeenCalledOnce()
})
it('routes back from the BookOrbit reader shell', async () => {
const wrapper = await mountReader()
wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('back')
expect(mockRouterBack).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { EmbedPDF } from '@embedpdf/core/vue'
import type { PdfEngine } from '@embedpdf/models'
describe('patched EmbedPDF lifecycle', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('registers teardown before asynchronous plugin initialization completes', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const engine = {} as PdfEngine
const Host = defineComponent({
setup() {
return () => h(EmbedPDF, { engine, plugins: [] }, () => h('div', 'reader'))
},
})
const wrapper = mount(Host)
await flushPromises()
wrapper.unmount()
expect(warn.mock.calls.flat().join(' ')).not.toContain('onBeforeUnmount is called when there is no active component instance')
})
})
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PdfErrorCode } from '@embedpdf/models'
import { createPdfiumEngine } from '@embedpdf/engines/pdfium-worker-engine'
class FailingWorker {
onmessage: ((event: MessageEvent) => void) | null = null
onerror: ((event: ErrorEvent) => void) | null = null
private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>()
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
const listeners = this.listeners.get(type) ?? new Set<EventListenerOrEventListenerObject>()
listeners.add(listener)
this.listeners.set(type, listeners)
}
removeEventListener(type: string, listener: EventListenerOrEventListenerObject) {
this.listeners.get(type)?.delete(listener)
}
postMessage(message: { type?: string }) {
if (message.type !== 'wasmInit') return
queueMicrotask(() => {
const event = new MessageEvent('message', { data: { type: 'wasmError', error: 'WASM unavailable' } })
for (const listener of this.listeners.get('message') ?? []) {
if (typeof listener === 'function') listener(event)
else listener.handleEvent(event)
}
})
}
terminate() {}
}
describe('patched PDFium worker initialization', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('rejects queued document work instead of hanging on a WASM error', async () => {
vi.stubGlobal('Worker', FailingWorker)
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:pdf-worker')
const engine = createPdfiumEngine('https://bookorbit.test/pdfium.wasm', { fontFallback: null })
const task = engine.openDocumentBuffer({ id: 'doc-1', content: new ArrayBuffer(0) })
await expect(task.toPromise()).rejects.toMatchObject({
reason: {
code: PdfErrorCode.Initialization,
message: 'Worker initialization failed',
},
})
})
})
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { Rotation, type PdfPageObjectWithRotatedSize } from '@embedpdf/models'
import type { PageVisibilityMetrics } from '@embedpdf/plugin-scroll'
import { canTurnFromVisiblePage, findAdjacentPage, findPageRange, shouldUseResponsiveSpread } from '../pdf-pagination'
function page(index: number, width = 600, height = 800): PdfPageObjectWithRotatedSize {
return {
index,
size: { width, height },
rotatedSize: { width, height },
rotation: Rotation.Degree0,
objectNumber: index + 1,
}
}
function visibility(pageNumber: number, pageX: number, pageY: number, visibleWidth: number, visibleHeight: number): PageVisibilityMetrics {
return {
pageNumber,
viewportX: 0,
viewportY: 0,
visiblePercentage: 100,
original: { pageX, pageY, visibleWidth, visibleHeight, scale: 1 },
scaled: { pageX, pageY, visibleWidth, visibleHeight, scale: 1 },
}
}
describe('PDF pagination helpers', () => {
const spreads = [[page(0)], [page(1), page(2)], [page(3), page(4)]]
it('finds the visible logical spread for any page in it', () => {
expect(findPageRange(spreads, 3, 5)).toEqual({ start: 2, end: 3 })
expect(findPageRange(spreads, 5, 5)).toEqual({ start: 4, end: 5 })
})
it('moves by logical spreads and stops at document boundaries', () => {
expect(findAdjacentPage(spreads, 1, 1, 5)).toBe(2)
expect(findAdjacentPage(spreads, 2, 1, 5)).toBe(4)
expect(findAdjacentPage(spreads, 4, 1, 5)).toBe(4)
expect(findAdjacentPage(spreads, 1, -1, 5)).toBe(1)
})
it('requires an enlarged page to reach the relevant pan boundary before turning', () => {
const range = { start: 1, end: 1 }
expect(canTurnFromVisiblePage(1, range, [visibility(1, 0, 100, 600, 600)], spreads)).toBe(false)
expect(canTurnFromVisiblePage(1, range, [visibility(1, 0, 200, 600, 600)], spreads)).toBe(true)
expect(canTurnFromVisiblePage(1, range, [visibility(1, 100, 0, 500, 800)], spreads, 'horizontal')).toBe(true)
expect(canTurnFromVisiblePage(-1, range, [visibility(1, 20, 0, 500, 800)], spreads, 'horizontal')).toBe(false)
})
it('uses responsive spreads only for sufficiently wide landscape viewports', () => {
expect(shouldUseResponsiveSpread(1200, 800)).toBe(true)
expect(shouldUseResponsiveSpread(800, 1200)).toBe(false)
expect(shouldUseResponsiveSpread(850, 700)).toBe(false)
})
})
@@ -1,26 +1,71 @@
import { describe, expect, it } from 'vitest'
import { lookupAccentHex } from '../pdf-viewer-utils'
import { Rotation } from '@embedpdf/models'
import { ScrollStrategy } from '@embedpdf/plugin-scroll'
import { SpreadMode } from '@embedpdf/plugin-spread'
import { ZoomMode } from '@embedpdf/plugin-zoom'
import type { PdfReaderSettings } from '@bookorbit/types'
import { flattenPdfBookmarks, fromRotation, safeExternalPdfUrl, toRotation, toScrollStrategy, toSpreadMode, toZoomLevel } from '../pdf-viewer-utils'
describe('lookupAccentHex', () => {
const options = [
{ id: 'rose', label: 'Rose', color: '#e11d48' },
{ id: 'blue', label: 'Blue', color: '#2563eb' },
{ id: 'green', label: 'Green', color: '#16a34a' },
]
const settings: PdfReaderSettings = {
scrollMode: 'vertical',
spread: 'none',
zoomMode: 'fit-page',
customScale: 1,
rotation: 0,
}
it('returns color for matching accent id', () => {
expect(lookupAccentHex('rose', options)).toBe('#e11d48')
describe('PDF viewer settings mapping', () => {
it('maps page mode to the vertical strategy used by paginated navigation', () => {
expect(toScrollStrategy('page')).toBe(ScrollStrategy.Vertical)
expect(toScrollStrategy('horizontal')).toBe(ScrollStrategy.Horizontal)
})
it('returns color for another accent id', () => {
expect(lookupAccentHex('green', options)).toBe('#16a34a')
it('maps spread modes', () => {
expect(toSpreadMode('none')).toBe(SpreadMode.None)
expect(toSpreadMode('odd')).toBe(SpreadMode.Odd)
expect(toSpreadMode('even')).toBe(SpreadMode.Even)
expect(toSpreadMode('auto')).toBe(SpreadMode.None)
})
it('returns fallback blue for unknown accent id', () => {
expect(lookupAccentHex('unknown', options)).toBe('#3b82f6')
it('maps fit and custom zoom levels', () => {
expect(toZoomLevel(settings)).toBe(ZoomMode.FitPage)
expect(toZoomLevel({ ...settings, zoomMode: 'fit-width' })).toBe(ZoomMode.FitWidth)
expect(toZoomLevel({ ...settings, zoomMode: 'automatic' })).toBe(ZoomMode.Automatic)
expect(toZoomLevel({ ...settings, zoomMode: 'custom', customScale: 1.75 })).toBe(1.75)
})
it('returns fallback blue for empty options', () => {
expect(lookupAccentHex('rose', [])).toBe('#3b82f6')
it('round trips persisted rotation values', () => {
expect(toRotation(0)).toBe(Rotation.Degree0)
expect(toRotation(90)).toBe(Rotation.Degree90)
expect(toRotation(180)).toBe(Rotation.Degree180)
expect(toRotation(270)).toBe(Rotation.Degree270)
expect(fromRotation(Rotation.Degree270)).toBe(270)
})
})
describe('flattenPdfBookmarks', () => {
it('preserves hierarchy depth in document order', () => {
const result = flattenPdfBookmarks([
{
title: 'Part one',
children: [{ title: 'Chapter one' }, { title: 'Chapter two', children: [{ title: 'Section' }] }],
},
])
expect(result.map(({ bookmark, depth }) => [bookmark.title, depth])).toEqual([
['Part one', 0],
['Chapter one', 1],
['Chapter two', 1],
['Section', 2],
])
})
})
describe('safeExternalPdfUrl', () => {
it('allows only HTTP and HTTPS links', () => {
expect(safeExternalPdfUrl('https://example.com/reference')?.hostname).toBe('example.com')
expect(safeExternalPdfUrl('http://example.com')?.protocol).toBe('http:')
expect(safeExternalPdfUrl('javascript:alert(1)')).toBeNull()
expect(safeExternalPdfUrl('mailto:test@example.com')).toBeNull()
})
})
@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref } from 'vue'
import { usePdfFullscreenChrome } from '../composables/usePdfFullscreenChrome'
describe('usePdfFullscreenChrome', () => {
afterEach(() => {
vi.useRealTimers()
})
it('auto-hides only in fullscreen and keeps a session pin', async () => {
vi.useFakeTimers()
const fullscreen = ref(false)
const openUi = ref(false)
let chrome!: ReturnType<typeof usePdfFullscreenChrome>
const wrapper = mount(
defineComponent({
setup() {
chrome = usePdfFullscreenChrome(fullscreen, openUi)
return () => h('div')
},
}),
)
await vi.advanceTimersByTimeAsync(3000)
expect(chrome.visible.value).toBe(true)
fullscreen.value = true
await vi.runAllTicks()
await vi.advanceTimersByTimeAsync(2500)
expect(chrome.visible.value).toBe(false)
chrome.reveal()
chrome.togglePinned()
await vi.advanceTimersByTimeAsync(3000)
expect(chrome.visible.value).toBe(true)
fullscreen.value = false
await vi.runAllTicks()
expect(chrome.pinned.value).toBe(true)
fullscreen.value = true
await vi.runAllTicks()
await vi.advanceTimersByTimeAsync(3000)
expect(chrome.visible.value).toBe(true)
chrome.togglePinned()
await vi.advanceTimersByTimeAsync(2500)
expect(chrome.visible.value).toBe(false)
wrapper.unmount()
})
})
@@ -0,0 +1,39 @@
import { defineComponent, h, ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import type { ScrollScope } from '@embedpdf/plugin-scroll'
import { usePdfPagination } from '../composables/usePdfPagination'
describe('usePdfPagination', () => {
it('stays stable while EmbedPDF unloads the document scope', () => {
const scroll = ref({
getSpreadPagesWithRotatedSize() {
throw new Error('Document doc-1 not loaded')
},
scrollToPage() {
throw new Error('Document doc-1 not loaded')
},
} as unknown as Readonly<ScrollScope>)
let pagination!: ReturnType<typeof usePdfPagination>
const wrapper = mount(
defineComponent({
setup() {
pagination = usePdfPagination({
mode: ref('page'),
scrollState: ref({ currentPage: 20, totalPages: 240 }),
scroll,
onActivity: () => {},
})
return () => h('div')
},
}),
)
expect(pagination.pageRange.value).toEqual({ start: 20, end: 20 })
expect(() => pagination.goToPage(21)).not.toThrow()
expect(() => pagination.nextPage()).not.toThrow()
wrapper.unmount()
})
})
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { AnnotationLayer } from '@embedpdf/plugin-annotation/vue'
import { GlobalPointerProvider, PagePointerProvider } from '@embedpdf/plugin-interaction-manager/vue'
import { RenderLayer } from '@embedpdf/plugin-render/vue'
import { Rotate } from '@embedpdf/plugin-rotate/vue'
import { Scroller } from '@embedpdf/plugin-scroll/vue'
import { SearchLayer } from '@embedpdf/plugin-search/vue'
import { SelectionLayer } from '@embedpdf/plugin-selection/vue'
import { TilingLayer } from '@embedpdf/plugin-tiling/vue'
import { Viewport } from '@embedpdf/plugin-viewport/vue'
import { ZoomGestureWrapper } from '@embedpdf/plugin-zoom/vue'
defineProps<{ documentId: string }>()
</script>
<template>
<GlobalPointerProvider :document-id="documentId">
<Viewport :document-id="documentId" class="absolute inset-0 bg-muted/40">
<ZoomGestureWrapper :document-id="documentId">
<Scroller :document-id="documentId">
<template #default="{ page }">
<Rotate :document-id="documentId" :page-index="page.pageIndex" class="bg-background shadow-md">
<PagePointerProvider :document-id="documentId" :page-index="page.pageIndex">
<RenderLayer :document-id="documentId" :page-index="page.pageIndex" :scale="0.5" class="pointer-events-none" />
<TilingLayer :document-id="documentId" :page-index="page.pageIndex" class="pointer-events-none" />
<SearchLayer
:document-id="documentId"
:page-index="page.pageIndex"
highlight-color="color-mix(in oklch, var(--primary) 30%, transparent)"
active-highlight-color="color-mix(in oklch, var(--primary) 55%, transparent)"
/>
<SelectionLayer
:document-id="documentId"
:page-index="page.pageIndex"
:text-style="{ background: 'color-mix(in oklch, var(--primary) 35%, transparent)' }"
/>
<AnnotationLayer :document-id="documentId" :page-index="page.pageIndex" />
</PagePointerProvider>
</Rotate>
</template>
</Scroller>
</ZoomGestureWrapper>
</Viewport>
</GlobalPointerProvider>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { KeyRound, LoaderCircle } from '@lucide/vue'
import type { DocumentState } from '@embedpdf/core'
import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/vue'
const props = defineProps<{ documentState: DocumentState }>()
const emit = defineEmits<{ back: [] }>()
const { provides: documentManager } = useDocumentManagerCapability()
const password = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const passwordInput = ref<HTMLInputElement | null>(null)
function handleBack() {
emit('back')
}
function handleSubmit() {
const value = password.value
if (!value || submitting.value || !documentManager.value) return
submitting.value = true
errorMessage.value = ''
documentManager.value.retryDocument(props.documentState.id, { password: value }).wait(
() => {
submitting.value = false
},
() => {
submitting.value = false
password.value = ''
errorMessage.value = 'That password did not unlock the PDF.'
void nextTick(() => passwordInput.value?.focus())
},
)
}
</script>
<template>
<div class="flex h-full items-center justify-center bg-muted/30 p-6">
<div class="w-full max-w-sm rounded-xl border border-border bg-card p-6 text-card-foreground shadow-xl">
<div class="mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
<KeyRound :size="21" />
</div>
<h2 class="text-center font-serif text-lg font-semibold">Password required</h2>
<p class="mt-1 text-center text-xs text-muted-foreground">Enter the document password to open this PDF.</p>
<form class="mt-5" @submit.prevent="handleSubmit">
<input
ref="passwordInput"
v-model="password"
type="password"
autocomplete="off"
autofocus
:disabled="submitting"
placeholder="Document password"
class="h-10 w-full rounded-md border border-border bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
<p v-if="errorMessage" class="mt-2 text-xs text-destructive">{{ errorMessage }}</p>
<div class="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
class="rounded-md border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-foreground"
@click="handleBack"
>
Go back
</button>
<button
type="submit"
:disabled="!password || submitting"
class="flex items-center justify-center gap-2 rounded-md bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground disabled:opacity-50"
>
<LoaderCircle v-if="submitting" :size="14" class="animate-spin" />
Unlock
</button>
</div>
</form>
</div>
</div>
</template>
@@ -0,0 +1,496 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { AlertTriangle, ExternalLink, LoaderCircle } from '@lucide/vue'
import type { DocumentState } from '@embedpdf/core'
import { PdfErrorCode } from '@embedpdf/models'
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/vue'
import { DocumentContent } from '@embedpdf/plugin-document-manager/vue'
import { usePan } from '@embedpdf/plugin-pan/vue'
import { useRotate } from '@embedpdf/plugin-rotate/vue'
import { ScrollStrategy, useScroll, useScrollCapability } from '@embedpdf/plugin-scroll/vue'
import { useSelectionCapability } from '@embedpdf/plugin-selection/vue'
import { SpreadMode, useSpread } from '@embedpdf/plugin-spread/vue'
import { ZoomMode, useZoom, type ZoomLevel } from '@embedpdf/plugin-zoom/vue'
import type { PdfReaderSettings } from '@bookorbit/types'
import { useFullscreen } from '../../shared/composables/useFullscreen'
import PdfDocumentViewport from './PdfDocumentViewport.vue'
import PdfPasswordPrompt from './PdfPasswordPrompt.vue'
import PdfReaderSettingsPanel from './PdfReaderSettingsPanel.vue'
import PdfReaderSidebar, { type PdfSidebarTab } from './PdfReaderSidebar.vue'
import PdfReaderToolbar from './PdfReaderToolbar.vue'
import { fromRotation, safeExternalPdfUrl } from '../pdf-viewer-utils'
import { usePdfFullscreenChrome } from '../composables/usePdfFullscreenChrome'
import { usePdfPagination } from '../composables/usePdfPagination'
import { usePdfResponsiveSpread } from '../composables/usePdfResponsiveSpread'
const props = defineProps<{
documentId: string
initialPage: number
settings: PdfReaderSettings
peekMode?: boolean
}>()
const emit = defineEmits<{
back: []
pageChange: [pageNumber: number, totalPages: number]
retry: []
settingsChange: [patch: Partial<PdfReaderSettings>]
startReading: []
}>()
const { state: scrollState, provides: scroll } = useScroll(() => props.documentId)
const { provides: scrollCapability } = useScrollCapability()
const { state: zoomState, provides: zoom } = useZoom(() => props.documentId)
const { provides: spread } = useSpread(() => props.documentId)
const { rotation, provides: rotate } = useRotate(() => props.documentId)
const { isPanning, provides: pan } = usePan(() => props.documentId)
const { provides: selectionCapability } = useSelectionCapability()
const { provides: annotationCapability } = useAnnotationCapability()
const { isFullscreen, isFullscreenSupported, toggleFullscreen } = useFullscreen()
const sidebarOpen = ref(false)
const sidebarTab = ref<PdfSidebarTab>('thumbnails')
const settingsOpen = ref(false)
const pendingExternalUrl = ref<URL | null>(null)
const restoredInitialPage = ref(false)
const viewerSurface = ref<HTMLElement | null>(null)
const currentScrollMode = ref<PdfReaderSettings['scrollMode']>(props.settings.scrollMode)
const currentSpreadPreference = ref<PdfReaderSettings['spread']>(props.settings.spread)
let previewingZoom = false
const zoomPercent = computed(() => Math.round(zoomState.value.currentZoomLevel * 100))
const currentZoomMode = computed<PdfReaderSettings['zoomMode']>(() => {
if (zoomState.value.zoomLevel === ZoomMode.FitPage) return 'fit-page'
if (zoomState.value.zoomLevel === ZoomMode.FitWidth) return 'fit-width'
if (zoomState.value.zoomLevel === ZoomMode.Automatic) return 'automatic'
return 'custom'
})
const currentRotation = computed(() => fromRotation(rotation.value))
const externalHost = computed(() => pendingExternalUrl.value?.hostname ?? '')
const searchOpen = computed(() => sidebarOpen.value && sidebarTab.value === 'search')
const hasOpenUi = computed(() => sidebarOpen.value || settingsOpen.value || pendingExternalUrl.value !== null)
const {
pinned: headerPinned,
visible: headerVisible,
reveal: revealHeader,
togglePinned: toggleHeaderPinned,
} = usePdfFullscreenChrome(isFullscreen, hasOpenUi)
const {
pageRange,
getPageRange,
goToPage: handleGoToPage,
previousPage: handlePreviousPage,
nextPage: handleNextPage,
handleWheel: handleViewportWheel,
handleTouchStart,
handleTouchEnd,
} = usePdfPagination({
mode: currentScrollMode,
scrollState,
scroll,
onActivity: revealHeader,
})
const { apply: applyResponsiveSpread } = usePdfResponsiveSpread(viewerSurface, currentSpreadPreference, spread)
function handleBack() {
emit('back')
}
function handleZoomOut() {
zoom.value?.zoomOut()
}
function handleZoomIn() {
zoom.value?.zoomIn()
}
function handleToggleSidebar() {
if (sidebarOpen.value && sidebarTab.value !== 'search') {
sidebarOpen.value = false
return
}
sidebarTab.value = 'thumbnails'
sidebarOpen.value = true
}
function handleToggleSearch() {
if (sidebarOpen.value && sidebarTab.value === 'search') {
sidebarOpen.value = false
return
}
sidebarTab.value = 'search'
sidebarOpen.value = true
}
function handleSidebarClose() {
sidebarOpen.value = false
}
function handleSidebarTab(tab: PdfSidebarTab) {
sidebarTab.value = tab
}
function handleTogglePan() {
pan.value?.togglePan()
}
function handleSelectTool() {
pan.value?.disablePan()
}
function handleSettingsOpen(open: boolean) {
settingsOpen.value = open
}
function handleStartReading() {
emit('startReading')
}
function handleScrollMode(mode: PdfReaderSettings['scrollMode']) {
currentScrollMode.value = mode
scroll.value?.setScrollStrategy(mode === 'horizontal' ? ScrollStrategy.Horizontal : ScrollStrategy.Vertical)
emit('settingsChange', { scrollMode: mode })
handleGoToPage(pageRange.value.start)
}
function handleSpread(mode: PdfReaderSettings['spread']) {
currentSpreadPreference.value = mode
emit('settingsChange', { spread: mode })
if (mode === 'auto') applyResponsiveSpread()
else spread.value?.setSpreadMode(mode === 'odd' ? SpreadMode.Odd : mode === 'even' ? SpreadMode.Even : SpreadMode.None)
}
function handleZoom(mode: PdfReaderSettings['zoomMode'], scale?: number) {
let value: ZoomLevel = ZoomMode.FitPage
if (mode === 'fit-width') value = ZoomMode.FitWidth
if (mode === 'automatic') value = ZoomMode.Automatic
if (mode === 'custom') value = scale ?? zoomState.value.currentZoomLevel
zoom.value?.requestZoom(value)
}
function handleZoomPreview(scale: number) {
previewingZoom = true
try {
zoom.value?.requestZoom(scale)
} finally {
previewingZoom = false
}
}
function handleRotateBackward() {
rotate.value?.rotateBackward()
}
function handleRotateForward() {
rotate.value?.rotateForward()
}
function handleCancelExternalLink() {
pendingExternalUrl.value = null
}
function handleOpenExternalLink() {
const url = pendingExternalUrl.value
pendingExternalUrl.value = null
if (url) window.open(url.href, '_blank', 'noopener,noreferrer')
}
function handleRetryDocument() {
emit('retry')
}
function hasDocumentPages(documentState: DocumentState) {
return (documentState.document?.pageCount ?? 0) > 0
}
function handleReaderActivity() {
revealHeader()
}
function handleKeydown(event: KeyboardEvent) {
revealHeader()
const target = event.target as HTMLElement | null
const editing = target?.matches('input, textarea, select, button, a, [role="button"], [role="menuitem"], [contenteditable="true"]') === true
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
event.preventDefault()
handleToggleSearch()
return
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c' && !editing) {
selectionCapability.value?.forDocument(props.documentId).copyToClipboard()
return
}
if (editing) return
if (event.key === 'Escape') {
if (pendingExternalUrl.value) handleCancelExternalLink()
else if (sidebarOpen.value) handleSidebarClose()
return
}
if (event.key === 'ArrowLeft' || event.key === 'PageUp') {
event.preventDefault()
handlePreviousPage()
} else if (event.key === 'ArrowRight' || event.key === 'PageDown') {
event.preventDefault()
handleNextPage()
} else if (event.key === 'Home') {
event.preventDefault()
handleGoToPage(1)
} else if (event.key === 'End') {
event.preventDefault()
handleGoToPage(scrollState.value.totalPages)
} else if (event.key === '+' || event.key === '=') {
event.preventDefault()
handleZoomIn()
} else if (event.key === '-') {
event.preventDefault()
handleZoomOut()
} else if (event.key === '0') {
event.preventDefault()
handleZoom(currentZoomMode.value === 'custom' ? 'fit-page' : currentZoomMode.value)
}
}
watch(
() => props.settings.scrollMode,
(mode) => {
currentScrollMode.value = mode
},
)
watch(
() => props.settings.spread,
(mode) => {
currentSpreadPreference.value = mode
if (mode === 'auto') applyResponsiveSpread()
},
)
watch(
scrollCapability,
(capability, _previous, onCleanup) => {
if (!capability) return
const unsubscribeLayout = capability.onLayoutReady((event) => {
if (event.documentId !== props.documentId || !event.isInitial || restoredInitialPage.value) return
const page = Math.min(Math.max(props.initialPage, 1), event.totalPages || 1)
restoredInitialPage.value = true
if (page > 1) scroll.value?.scrollToPage({ pageNumber: page })
})
const unsubscribePage = capability.onPageChange((event) => {
if (event.documentId !== props.documentId || !restoredInitialPage.value) return
const range = getPageRange(event.pageNumber, event.totalPages)
emit('pageChange', range.end, event.totalPages)
})
onCleanup(() => {
unsubscribeLayout()
unsubscribePage()
})
},
{ immediate: true },
)
watch(
zoom,
(scope, _previous, onCleanup) => {
if (!scope) return
const unsubscribe = scope.onZoomChange((event) => {
if (previewingZoom) return
if (event.level === ZoomMode.FitPage) emit('settingsChange', { zoomMode: 'fit-page' })
else if (event.level === ZoomMode.FitWidth) emit('settingsChange', { zoomMode: 'fit-width' })
else if (event.level === ZoomMode.Automatic) emit('settingsChange', { zoomMode: 'automatic' })
else emit('settingsChange', { zoomMode: 'custom', customScale: event.newZoom })
})
onCleanup(unsubscribe)
},
{ immediate: true },
)
watch(
spread,
(scope, _previous, onCleanup) => {
if (!scope) return
const unsubscribe = scope.onSpreadChange((mode) => {
if (currentSpreadPreference.value === 'auto') return
emit('settingsChange', { spread: mode === SpreadMode.Odd ? 'odd' : mode === SpreadMode.Even ? 'even' : 'none' })
})
onCleanup(unsubscribe)
},
{ immediate: true },
)
watch(
rotate,
(scope, _previous, onCleanup) => {
if (!scope) return
const unsubscribe = scope.onRotateChange((value) => emit('settingsChange', { rotation: fromRotation(value) }))
onCleanup(unsubscribe)
},
{ immediate: true },
)
watch(
annotationCapability,
(capability, _previous, onCleanup) => {
if (!capability) return
const unsubscribe = capability.forDocument(props.documentId).onNavigate((event) => {
if (event.result.outcome !== 'uri') return
pendingExternalUrl.value = safeExternalPdfUrl(event.result.uri)
})
onCleanup(unsubscribe)
},
{ immediate: true },
)
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<div class="flex h-full min-h-0 flex-col overflow-hidden bg-background" @mousemove="handleReaderActivity" @pointerdown="handleReaderActivity">
<PdfReaderToolbar
v-show="headerVisible"
:current-page-start="pageRange.start"
:current-page-end="pageRange.end"
:total-pages="scrollState.totalPages"
:zoom-percent="zoomPercent"
:sidebar-open="sidebarOpen"
:search-open="searchOpen"
:settings-open="settingsOpen"
:pan-active="isPanning"
:fullscreen="isFullscreen"
:fullscreen-supported="isFullscreenSupported"
:header-pinned="headerPinned"
:peek-mode="props.peekMode"
@back="handleBack"
@previous-page="handlePreviousPage"
@next-page="handleNextPage"
@go-to-page="handleGoToPage"
@zoom-out="handleZoomOut"
@zoom-in="handleZoomIn"
@toggle-sidebar="handleToggleSidebar"
@toggle-search="handleToggleSearch"
@toggle-pan="handleTogglePan"
@select-tool="handleSelectTool"
@toggle-fullscreen="toggleFullscreen"
@toggle-header-pin="toggleHeaderPinned"
@update:settings-open="handleSettingsOpen"
@start-reading="handleStartReading"
>
<template #settings>
<PdfReaderSettingsPanel
:scroll-mode="currentScrollMode"
:spread="currentSpreadPreference"
:rotation="currentRotation"
:zoom-mode="currentZoomMode"
:custom-scale="zoomState.currentZoomLevel"
@preview-zoom="handleZoomPreview"
@set-scroll-mode="handleScrollMode"
@set-spread="handleSpread"
@set-zoom="handleZoom"
@rotate-backward="handleRotateBackward"
@rotate-forward="handleRotateForward"
/>
</template>
</PdfReaderToolbar>
<div class="relative flex min-h-0 flex-1 overflow-hidden">
<PdfReaderSidebar
v-if="sidebarOpen"
:document-id="props.documentId"
:active-tab="sidebarTab"
:header-visible="headerVisible"
@close="handleSidebarClose"
@update:active-tab="handleSidebarTab"
/>
<div
ref="viewerSurface"
class="relative min-w-0 flex-1 overflow-hidden"
@wheel="handleViewportWheel"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
>
<DocumentContent :document-id="props.documentId">
<template #default="{ documentState, isLoading, isError, isLoaded }">
<div v-if="isLoading" class="absolute inset-0 flex items-center justify-center bg-background">
<div class="flex flex-col items-center gap-3 text-muted-foreground" role="status" aria-live="polite">
<LoaderCircle :size="28" class="animate-spin text-primary" />
<span class="text-sm">Loading PDF</span>
</div>
</div>
<PdfPasswordPrompt
v-else-if="isError && documentState.errorCode === PdfErrorCode.Password"
:document-state="documentState"
@back="handleBack"
/>
<div v-else-if="isError" class="absolute inset-0 flex items-center justify-center p-6">
<div class="max-w-sm text-center">
<AlertTriangle :size="30" class="mx-auto mb-3 text-destructive" />
<p class="mb-2 text-sm font-medium text-foreground">Unable to open this PDF</p>
<p class="text-xs text-muted-foreground">{{ documentState.error || 'The document could not be loaded.' }}</p>
<div class="mt-4 flex justify-center gap-2">
<button
class="rounded-md border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-foreground"
@click="handleBack"
>
Go back
</button>
<button class="rounded-md bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground" @click="handleRetryDocument">
Retry
</button>
</div>
</div>
</div>
<div v-else-if="isLoaded && !hasDocumentPages(documentState)" class="absolute inset-0 flex items-center justify-center p-6">
<div class="max-w-sm text-center">
<AlertTriangle :size="30" class="mx-auto mb-3 text-destructive" />
<p class="mb-2 text-sm font-medium text-foreground">This PDF opened without any pages</p>
<p class="text-xs text-muted-foreground">Reload the document to recover from the incomplete PDF engine response.</p>
<div class="mt-4 flex justify-center gap-2">
<button
class="rounded-md border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-foreground"
@click="handleBack"
>
Go back
</button>
<button class="rounded-md bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground" @click="handleRetryDocument">
Retry
</button>
</div>
</div>
</div>
<PdfDocumentViewport v-else-if="isLoaded" :document-id="props.documentId" />
</template>
</DocumentContent>
</div>
</div>
<div v-if="pendingExternalUrl" class="fixed inset-0 z-[70] flex items-center justify-center bg-background/70 p-4 backdrop-blur-sm">
<div class="w-full max-w-md rounded-xl border border-border bg-card p-5 text-card-foreground shadow-2xl">
<div class="mb-4 flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"><ExternalLink :size="18" /></div>
<div class="min-w-0">
<h2 class="font-serif text-base font-semibold">Open external link?</h2>
<p class="mt-1 text-xs text-muted-foreground">This PDF wants to open {{ externalHost }} in a new tab.</p>
<p class="mt-2 truncate rounded-md bg-muted px-2 py-1.5 text-[11px] text-muted-foreground">{{ pendingExternalUrl.href }}</p>
</div>
</div>
<div class="flex justify-end gap-2">
<button
class="rounded-md border border-border px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-foreground"
@click="handleCancelExternalLink"
>
Cancel
</button>
<button class="rounded-md bg-primary px-3 py-2 text-xs font-semibold text-primary-foreground" @click="handleOpenExternalLink">
Open link
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,239 @@
<script setup lang="ts">
import { onUnmounted } from 'vue'
import type { PdfReaderSettings } from '@bookorbit/types'
const props = defineProps<{
scrollMode: PdfReaderSettings['scrollMode']
spread: PdfReaderSettings['spread']
rotation: PdfReaderSettings['rotation']
zoomMode: PdfReaderSettings['zoomMode']
customScale: number
}>()
const emit = defineEmits<{
setScrollMode: [mode: PdfReaderSettings['scrollMode']]
setSpread: [spread: PdfReaderSettings['spread']]
previewZoom: [scale: number]
setZoom: [mode: PdfReaderSettings['zoomMode'], scale?: number]
rotateBackward: []
rotateForward: []
}>()
let zoomPreviewTimer: ReturnType<typeof setTimeout> | null = null
function selectVertical() {
emit('setScrollMode', 'vertical')
}
function selectHorizontal() {
emit('setScrollMode', 'horizontal')
}
function selectPage() {
emit('setScrollMode', 'page')
}
function selectSinglePage() {
emit('setSpread', 'none')
}
function selectOddSpread() {
emit('setSpread', 'odd')
}
function selectEvenSpread() {
emit('setSpread', 'even')
}
function selectAutoSpread() {
emit('setSpread', 'auto')
}
function selectFitPage() {
emit('setZoom', 'fit-page')
}
function selectFitWidth() {
emit('setZoom', 'fit-width')
}
function selectAutomatic() {
emit('setZoom', 'automatic')
}
function readZoomScale(event: Event) {
return Number((event.target as HTMLInputElement).value)
}
function handleCustomZoomPreview(event: Event) {
const scale = readZoomScale(event)
if (zoomPreviewTimer) clearTimeout(zoomPreviewTimer)
zoomPreviewTimer = setTimeout(() => {
zoomPreviewTimer = null
emit('previewZoom', scale)
}, 100)
}
function handleCustomZoomCommit(event: Event) {
if (zoomPreviewTimer) {
clearTimeout(zoomPreviewTimer)
zoomPreviewTimer = null
}
emit('setZoom', 'custom', readZoomScale(event))
}
function handleRotateBackward() {
emit('rotateBackward')
}
function handleRotateForward() {
emit('rotateForward')
}
onUnmounted(() => {
if (zoomPreviewTimer) clearTimeout(zoomPreviewTimer)
})
</script>
<template>
<div class="max-h-[min(80vh,38rem)] overflow-y-auto p-4">
<div class="mb-5">
<p class="settings-group-label">Zoom</p>
<div class="grid grid-cols-3 gap-2">
<button
class="rounded-md border px-3 py-2 text-xs font-medium transition-colors"
:class="
props.zoomMode === 'fit-page' ? 'border-primary bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:text-foreground'
"
@click="selectFitPage"
>
Fit page
</button>
<button
class="rounded-md border px-3 py-2 text-xs font-medium transition-colors"
:class="
props.zoomMode === 'fit-width' ? 'border-primary bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:text-foreground'
"
@click="selectFitWidth"
>
Fit width
</button>
<button
class="rounded-md border px-3 py-2 text-xs font-medium transition-colors"
:class="
props.zoomMode === 'automatic' ? 'border-primary bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:text-foreground'
"
@click="selectAutomatic"
>
Automatic
</button>
</div>
<div class="mt-3 flex items-center gap-3">
<input
type="range"
min="0.25"
max="4"
step="0.05"
:value="props.customScale"
aria-label="Custom zoom"
class="h-1 flex-1 cursor-pointer accent-primary"
@input="handleCustomZoomPreview"
@change="handleCustomZoomCommit"
/>
<span class="w-12 text-right text-xs tabular-nums text-muted-foreground">{{ Math.round(props.customScale * 100) }}%</span>
</div>
</div>
<div class="mb-5">
<p class="settings-group-label">Layout</p>
<div class="grid grid-cols-3 gap-2">
<button
class="rounded-md border px-3 py-2 text-xs font-medium transition-colors"
:class="
props.scrollMode === 'page' ? 'border-primary bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:text-foreground'
"
@click="selectPage"
>
Page
</button>
<button
class="rounded-md border px-3 py-2 text-xs font-medium transition-colors"
:class="
props.scrollMode === 'vertical'
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'
"
@click="selectVertical"
>
Vertical scroll
</button>
<button
class="rounded-md border px-3 py-2 text-xs font-medium transition-colors"
:class="
props.scrollMode === 'horizontal'
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'
"
@click="selectHorizontal"
>
Horizontal scroll
</button>
</div>
</div>
<div class="mb-5">
<p class="settings-group-label">Page spread</p>
<div class="grid grid-cols-4 gap-2">
<button
class="rounded-md border px-2 py-2 text-xs font-medium transition-colors"
:class="props.spread === 'none' ? 'border-primary bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:text-foreground'"
@click="selectSinglePage"
>
Single
</button>
<button
class="rounded-md border px-2 py-2 text-xs font-medium transition-colors"
:class="props.spread === 'odd' ? 'border-primary bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:text-foreground'"
@click="selectOddSpread"
>
Odd
</button>
<button
class="rounded-md border px-2 py-2 text-xs font-medium transition-colors"
:class="props.spread === 'even' ? 'border-primary bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:text-foreground'"
@click="selectEvenSpread"
>
Even
</button>
<button
class="rounded-md border px-2 py-2 text-xs font-medium transition-colors"
:class="props.spread === 'auto' ? 'border-primary bg-primary/10 text-primary' : 'border-border text-muted-foreground hover:text-foreground'"
@click="selectAutoSpread"
>
Auto
</button>
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<p class="settings-group-label !mb-0">Rotation</p>
<span class="text-xs tabular-nums text-muted-foreground">{{ props.rotation }}°</span>
</div>
<div class="grid grid-cols-2 gap-2">
<button
class="rounded-md border border-border px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground"
@click="handleRotateBackward"
>
Rotate left
</button>
<button
class="rounded-md border border-border px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground"
@click="handleRotateForward"
>
Rotate right
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,377 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { BookOpen, ChevronLeft, ChevronRight, FileText, LoaderCircle, Search, X } from '@lucide/vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { MatchFlag, type SearchResult } from '@embedpdf/models'
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/vue'
import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/vue'
import { useScroll } from '@embedpdf/plugin-scroll/vue'
import { useSearch } from '@embedpdf/plugin-search/vue'
import { ThumbImg, ThumbnailsPane } from '@embedpdf/plugin-thumbnail/vue'
import { flattenPdfBookmarks, type FlatPdfBookmark } from '../pdf-viewer-utils'
export type PdfSidebarTab = 'thumbnails' | 'contents' | 'search'
const props = defineProps<{
documentId: string
activeTab: PdfSidebarTab
headerVisible: boolean
}>()
const emit = defineEmits<{
close: []
'update:activeTab': [tab: PdfSidebarTab]
}>()
const { state: scrollState, provides: scroll } = useScroll(() => props.documentId)
const { state: searchState, provides: search } = useSearch(() => props.documentId)
const { provides: bookmarksCapability } = useBookmarkCapability()
const { provides: annotationCapability } = useAnnotationCapability()
const searchQuery = ref(searchState.value.query ?? '')
const searchPending = ref(false)
const searchProgressPage = ref(0)
const searchInput = ref<HTMLInputElement | null>(null)
const bookmarks = ref<FlatPdfBookmark[]>([])
const bookmarksLoading = ref(false)
const bookmarksLoaded = ref(false)
const normalizedSearchQuery = computed(() => searchQuery.value.trim())
const matchCase = computed(() => searchState.value.flags.includes(MatchFlag.MatchCase))
const wholeWord = computed(() => searchState.value.flags.includes(MatchFlag.MatchWholeWord))
const activeResultNumber = computed(() => (searchState.value.activeResultIndex >= 0 ? searchState.value.activeResultIndex + 1 : 0))
function getSearchResultKey(_result: SearchResult, index: number) {
return index
}
function handleClose() {
emit('close')
}
function selectThumbnails() {
emit('update:activeTab', 'thumbnails')
}
function selectContents() {
emit('update:activeTab', 'contents')
}
function selectSearch() {
emit('update:activeTab', 'search')
}
function handleThumbnail(pageIndex: number) {
scroll.value?.scrollToPage({ pageNumber: pageIndex + 1, behavior: 'smooth' })
}
function scrollToSearchResult(index: number) {
const result = searchState.value.results[index]
if (!result) return
if (result.rects.length === 0) {
scroll.value?.scrollToPage({ pageNumber: result.pageIndex + 1 })
return
}
const coordinates = result.rects.reduce(
(minimum, rect) => ({
x: Math.min(minimum.x, rect.origin.x),
y: Math.min(minimum.y, rect.origin.y),
}),
{ x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY },
)
scroll.value?.scrollToPage({
pageNumber: result.pageIndex + 1,
pageCoordinates: coordinates,
alignX: 50,
alignY: 30,
})
}
function handleSearchResult(index: number) {
search.value?.goToResult(index)
scrollToSearchResult(index)
}
function handlePreviousResult() {
const total = searchState.value.total
if (total <= 0) return
const current = searchState.value.activeResultIndex
const index = current <= 0 ? total - 1 : current - 1
search.value?.goToResult(index)
scrollToSearchResult(index)
}
function handleNextResult() {
const total = searchState.value.total
if (total <= 0) return
const current = searchState.value.activeResultIndex
const index = current >= total - 1 ? 0 : current + 1
search.value?.goToResult(index)
scrollToSearchResult(index)
}
function handleSearchKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return
event.preventDefault()
if (event.shiftKey) handlePreviousResult()
else handleNextResult()
}
function handleClearSearch() {
searchQuery.value = ''
searchProgressPage.value = 0
search.value?.stopSearch()
void nextTick(() => searchInput.value?.focus())
}
function handleMatchCase(event: Event) {
updateSearchFlag(MatchFlag.MatchCase, (event.target as HTMLInputElement).checked)
}
function handleWholeWord(event: Event) {
updateSearchFlag(MatchFlag.MatchWholeWord, (event.target as HTMLInputElement).checked)
}
function updateSearchFlag(flag: MatchFlag, enabled: boolean) {
const flags = new Set(searchState.value.flags)
if (enabled) flags.add(flag)
else flags.delete(flag)
search.value?.setFlags([...flags])
}
function handleBookmark(entry: FlatPdfBookmark) {
const target = entry.bookmark.target
if (!target) return
annotationCapability.value?.forDocument(props.documentId).navigateTarget(target)
}
function getBookmarkIndent(depth: number) {
return `${10 + Math.min(depth, 6) * 16}px`
}
function loadBookmarks() {
if (bookmarksLoaded.value || bookmarksLoading.value || !bookmarksCapability.value) return
bookmarksLoading.value = true
bookmarksCapability.value
.forDocument(props.documentId)
.getBookmarks()
.wait(
({ bookmarks: items }) => {
bookmarks.value = flattenPdfBookmarks(items)
bookmarksLoaded.value = true
bookmarksLoading.value = false
},
() => {
bookmarksLoaded.value = true
bookmarksLoading.value = false
},
)
}
watch(
[searchQuery, () => props.activeTab],
([query, activeTab], _previous, onCleanup) => {
const normalized = query.trim()
searchPending.value = false
searchProgressPage.value = 0
search.value?.stopSearch()
if (activeTab !== 'search' || normalized.length < 2) return
searchPending.value = true
const timer = window.setTimeout(() => {
searchPending.value = false
const task = search.value?.searchAllPages(normalized)
task?.onProgress((progress) => {
searchProgressPage.value = progress.page + 1
})
}, 250)
onCleanup(() => window.clearTimeout(timer))
},
{ flush: 'post', immediate: true },
)
watch(
() => [props.activeTab, bookmarksCapability.value] as const,
([tab]) => {
if (tab === 'contents') {
loadBookmarks()
return
}
if (tab === 'search') void nextTick(() => searchInput.value?.focus())
},
{ immediate: true },
)
onUnmounted(() => search.value?.stopSearch())
</script>
<template>
<div
data-pdf-reader-panel
class="fixed inset-x-0 bottom-0 z-40 flex bg-background/40 md:static md:z-auto md:h-full md:w-[19rem] md:shrink-0 md:bg-transparent"
:class="props.headerVisible ? 'top-11' : 'top-0'"
@click.self="handleClose"
>
<aside
class="pointer-events-auto flex h-full w-[18rem] max-w-[88vw] flex-col overflow-hidden border-r border-border bg-card text-card-foreground shadow-2xl sm:w-[19rem]"
>
<div class="flex h-11 shrink-0 items-center border-b border-border px-2">
<div class="grid min-w-0 flex-1 grid-cols-3">
<button
class="relative flex min-w-0 items-center justify-center gap-1 px-1 py-2.5 text-[13px] transition-colors"
:class="props.activeTab === 'thumbnails' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="selectThumbnails"
>
<FileText :size="15" />
<span>Pages</span>
<span v-if="props.activeTab === 'thumbnails'" class="absolute inset-x-0 bottom-0 h-0.5 rounded-t-full bg-primary" />
</button>
<button
class="relative flex min-w-0 items-center justify-center gap-1 px-1 py-2.5 text-[13px] transition-colors"
:class="props.activeTab === 'contents' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="selectContents"
>
<BookOpen :size="15" />
<span>Contents</span>
<span v-if="props.activeTab === 'contents'" class="absolute inset-x-0 bottom-0 h-0.5 rounded-t-full bg-primary" />
</button>
<button
class="relative flex min-w-0 items-center justify-center gap-1 px-1 py-2.5 text-[13px] transition-colors"
:class="props.activeTab === 'search' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'"
@click="selectSearch"
>
<Search :size="15" />
<span>Search</span>
<span v-if="props.activeTab === 'search'" class="absolute inset-x-0 bottom-0 h-0.5 rounded-t-full bg-primary" />
</button>
</div>
<button class="viewer-btn ml-1" aria-label="Close navigation" @click="handleClose">
<X :size="17" />
</button>
</div>
<div v-if="props.activeTab === 'thumbnails'" class="min-h-0 flex-1 overflow-hidden bg-muted/25">
<ThumbnailsPane :document-id="props.documentId" class="h-full w-full">
<template #default="{ meta }">
<button
class="absolute flex w-full cursor-pointer flex-col items-center"
:style="{ height: `${meta.wrapperHeight}px`, top: `${meta.top}px` }"
:aria-label="`Go to page ${meta.pageIndex + 1}`"
@click="handleThumbnail(meta.pageIndex)"
>
<span
class="overflow-hidden rounded border-2 bg-background shadow-sm transition-colors"
:class="
scrollState.currentPage === meta.pageIndex + 1
? 'border-primary ring-2 ring-primary/15'
: 'border-border hover:border-muted-foreground/50'
"
:style="{ width: `${meta.width}px`, height: `${meta.height}px` }"
>
<ThumbImg :document-id="props.documentId" :meta="meta" class="h-full w-full object-contain" />
</span>
<span class="mt-1 text-xs tabular-nums text-muted-foreground">{{ meta.pageIndex + 1 }}</span>
</button>
</template>
</ThumbnailsPane>
</div>
<div v-else-if="props.activeTab === 'contents'" class="min-h-0 flex-1 overflow-y-auto p-2">
<div v-if="bookmarksLoading" class="flex h-32 items-center justify-center text-muted-foreground">
<LoaderCircle :size="22" class="animate-spin" />
</div>
<p v-else-if="bookmarks.length === 0" class="px-3 py-8 text-center text-xs text-muted-foreground">This PDF has no document outline.</p>
<button
v-for="(entry, entryIndex) in bookmarks"
:key="entryIndex"
class="flex w-full min-w-0 items-start overflow-hidden rounded-md py-2 pr-2 text-left text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
:style="{ paddingLeft: getBookmarkIndent(entry.depth) }"
@click="handleBookmark(entry)"
>
<span class="line-clamp-2 min-w-0 break-words">{{ entry.bookmark.title }}</span>
</button>
</div>
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="shrink-0 border-b border-border p-3">
<div class="relative">
<Search :size="15" class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
placeholder="Search this PDF"
class="h-9 w-full rounded-md border border-border bg-background pl-9 pr-9 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
@keydown="handleSearchKeydown"
/>
<button
v-if="searchQuery"
class="absolute right-1.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:text-foreground"
aria-label="Clear search"
@click="handleClearSearch"
>
<X :size="14" />
</button>
</div>
<div class="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<label class="flex items-center gap-1.5">
<input type="checkbox" :checked="matchCase" class="accent-primary" @change="handleMatchCase" />
Match case
</label>
<label class="flex items-center gap-1.5">
<input type="checkbox" :checked="wholeWord" class="accent-primary" @change="handleWholeWord" />
Whole word
</label>
</div>
<div v-if="searchState.active && !searchState.loading" class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ activeResultNumber }} of {{ searchState.total }}</span>
<div v-if="searchState.total > 1" class="flex items-center">
<button class="viewer-btn !h-7 !w-7" aria-label="Previous result" @click="handlePreviousResult"><ChevronLeft :size="15" /></button>
<button class="viewer-btn !h-7 !w-7" aria-label="Next result" @click="handleNextResult"><ChevronRight :size="15" /></button>
</div>
</div>
</div>
<div v-if="searchPending || searchState.loading" class="flex flex-1 items-center justify-center text-muted-foreground">
<div class="flex flex-col items-center gap-2" role="status" aria-live="polite">
<LoaderCircle :size="22" class="animate-spin" />
<span class="text-xs">Searching page {{ searchProgressPage || 1 }} of {{ scrollState.totalPages || 1 }}</span>
</div>
</div>
<p v-else-if="normalizedSearchQuery.length === 1" class="px-4 py-8 text-center text-xs text-muted-foreground">
Enter at least 2 characters to search this PDF.
</p>
<p v-else-if="normalizedSearchQuery.length >= 2 && searchState.total === 0" class="px-4 py-8 text-center text-xs text-muted-foreground">
No searchable text matched your query.
</p>
<RecycleScroller
v-else
v-slot="{ item, index }"
class="min-h-0 min-w-0 flex-1 overflow-x-hidden p-2"
:items="searchState.results"
:item-size="76"
:key-field="getSearchResultKey"
>
<button
class="mb-1.5 h-[70px] w-full min-w-0 overflow-hidden rounded-md border p-2 text-left text-xs transition-colors"
:class="
index === searchState.activeResultIndex
? 'border-primary bg-primary/10 text-foreground'
: 'border-border bg-background text-muted-foreground hover:text-foreground'
"
@click="handleSearchResult(index)"
>
<span class="mb-1 block text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Page {{ item.pageIndex + 1 }}</span>
<span class="line-clamp-2 min-w-0 break-words">
<template v-if="item.context.truncatedLeft">… </template>{{ item.context.before
}}<mark class="rounded-sm bg-primary/20 px-0.5 text-foreground">{{ item.context.match }}</mark
>{{ item.context.after }}<template v-if="item.context.truncatedRight"> …</template>
</span>
</button>
</RecycleScroller>
<span class="sr-only" aria-live="polite">{{ searchState.total }} search results</span>
</div>
</aside>
</div>
</template>
@@ -0,0 +1,346 @@
<script setup lang="ts">
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Hand,
Maximize,
Minimize,
Minus,
MoreHorizontal,
MousePointer2,
PanelLeft,
Pin,
PinOff,
Plus,
Search,
Settings,
} from '@lucide/vue'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{
currentPageStart: number
currentPageEnd: number
totalPages: number
zoomPercent: number
sidebarOpen: boolean
searchOpen: boolean
settingsOpen: boolean
panActive: boolean
fullscreen: boolean
fullscreenSupported: boolean
headerPinned: boolean
peekMode?: boolean
}>()
const emit = defineEmits<{
back: []
previousPage: []
nextPage: []
goToPage: [page: number]
zoomOut: []
zoomIn: []
toggleSidebar: []
toggleSearch: []
togglePan: []
selectTool: []
toggleFullscreen: []
toggleHeaderPin: []
'update:settingsOpen': [open: boolean]
startReading: []
}>()
function handleBack() {
emit('back')
}
function handlePreviousPage() {
emit('previousPage')
}
function handleNextPage() {
emit('nextPage')
}
function handlePageCommit(event: Event) {
const input = event.target as HTMLInputElement
const page = Number.parseInt(input.value, 10)
if (!Number.isFinite(page)) {
input.value = String(props.currentPageStart)
return
}
const clamped = Math.min(Math.max(page, 1), props.totalPages || 1)
input.value = String(clamped)
emit('goToPage', clamped)
}
function handleZoomOut() {
emit('zoomOut')
}
function handleZoomIn() {
emit('zoomIn')
}
function handleToggleSidebar() {
emit('toggleSidebar')
}
function handleToggleSearch() {
emit('toggleSearch')
}
function handleTogglePan() {
emit('togglePan')
}
function handleSelectTool() {
emit('selectTool')
}
function handleToggleFullscreen() {
emit('toggleFullscreen')
}
function handleToggleHeaderPin() {
emit('toggleHeaderPin')
}
function handleSettingsOpenChange(open: boolean) {
emit('update:settingsOpen', open)
}
function handleStartReading() {
emit('startReading')
}
</script>
<template>
<header class="relative z-50 flex h-11 shrink-0 items-center gap-1 border-b border-border bg-background/95 px-2 backdrop-blur-md sm:px-3">
<div class="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" aria-label="Go back" @click="handleBack">
<ArrowLeft :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Go back</TooltipContent>
</Tooltip>
<div class="viewer-sep" />
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn"
:class="props.sidebarOpen ? '!bg-muted !text-foreground' : ''"
aria-label="Document navigation"
@click="handleToggleSidebar"
>
<PanelLeft :size="18" />
</button>
</TooltipTrigger>
<TooltipContent>Document navigation</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn hidden md:flex"
:class="props.searchOpen ? '!bg-muted !text-foreground' : ''"
aria-label="Search document"
@click="handleToggleSearch"
>
<Search :size="17" />
</button>
</TooltipTrigger>
<TooltipContent>Search document</TooltipContent>
</Tooltip>
</div>
<div class="mx-auto flex min-w-0 items-center gap-1 sm:absolute sm:inset-x-0 sm:pointer-events-none sm:justify-center">
<div class="flex items-center gap-1 sm:pointer-events-auto">
<button
class="viewer-btn hidden min-[380px]:flex"
:disabled="props.currentPageStart <= 1"
aria-label="Previous page"
@click="handlePreviousPage"
>
<ChevronLeft :size="17" />
</button>
<div class="flex items-center gap-1 text-xs tabular-nums text-muted-foreground">
<input
:value="props.currentPageStart"
type="number"
min="1"
:max="props.totalPages"
aria-label="Current page"
class="h-7 w-11 rounded-md border border-border bg-muted/60 px-1 text-center text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@change="handlePageCommit"
@keydown.enter="handlePageCommit"
/>
<span v-if="props.currentPageEnd > props.currentPageStart">-{{ props.currentPageEnd }}</span>
<span>/ {{ props.totalPages || 0 }}</span>
</div>
<button
class="viewer-btn hidden min-[380px]:flex"
:disabled="props.currentPageEnd >= props.totalPages"
aria-label="Next page"
@click="handleNextPage"
>
<ChevronRight :size="17" />
</button>
</div>
</div>
<div class="ml-auto flex shrink-0 items-center gap-1">
<div v-if="props.peekMode" class="flex h-7 items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-1.5 text-primary">
<span class="hidden text-[11px] font-medium lg:inline">Peeking</span>
<button
class="h-6 rounded-sm bg-primary px-2 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90"
@click="handleStartReading"
>
Start reading
</button>
</div>
<div class="viewer-sep hidden md:block" />
<div class="hidden items-center md:flex">
<button class="viewer-btn" aria-label="Zoom out" @click="handleZoomOut">
<Minus :size="15" />
</button>
<span class="min-w-12 text-center text-xs tabular-nums text-muted-foreground">{{ props.zoomPercent }}%</span>
<button class="viewer-btn" aria-label="Zoom in" @click="handleZoomIn">
<Plus :size="15" />
</button>
</div>
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn hidden md:flex"
:class="props.panActive ? '!bg-muted !text-foreground' : ''"
aria-label="Pan tool"
@click="handleTogglePan"
>
<Hand :size="17" />
</button>
</TooltipTrigger>
<TooltipContent>Pan tool</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn hidden md:flex"
:class="!props.panActive ? '!bg-muted !text-foreground' : ''"
aria-label="Select text tool"
@click="handleSelectTool"
>
<MousePointer2 :size="17" />
</button>
</TooltipTrigger>
<TooltipContent>Select text</TooltipContent>
</Tooltip>
<Tooltip v-if="props.fullscreenSupported">
<TooltipTrigger as-child>
<button
class="viewer-btn hidden md:flex"
:aria-label="props.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
@click="handleToggleFullscreen"
>
<Minimize v-if="props.fullscreen" :size="17" />
<Maximize v-else :size="17" />
</button>
</TooltipTrigger>
<TooltipContent>{{ props.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen' }}</TooltipContent>
</Tooltip>
<Tooltip v-if="props.fullscreen">
<TooltipTrigger as-child>
<button
class="viewer-btn hidden md:flex"
:class="props.headerPinned ? '!bg-muted !text-foreground' : ''"
:aria-label="props.headerPinned ? 'Unpin reader header' : 'Pin reader header'"
@click="handleToggleHeaderPin"
>
<PinOff v-if="props.headerPinned" :size="17" />
<Pin v-else :size="17" />
</button>
</TooltipTrigger>
<TooltipContent>{{ props.headerPinned ? 'Unpin header' : 'Pin header' }}</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="viewer-btn md:hidden" aria-label="More PDF tools">
<MoreHorizontal :size="18" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" :side-offset="8" class="w-56 border-border bg-card p-1.5 shadow-xl">
<button class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs hover:bg-muted" @click="handleToggleSearch">
<Search :size="15" /> Search
</button>
<button
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs hover:bg-muted"
:class="!props.panActive ? 'bg-muted text-foreground' : ''"
@click="handleSelectTool"
>
<MousePointer2 :size="15" /> Select text
</button>
<button
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs hover:bg-muted"
:class="props.panActive ? 'bg-muted text-foreground' : ''"
@click="handleTogglePan"
>
<Hand :size="15" /> Pan
</button>
<div class="my-1 border-t border-border" />
<div class="flex items-center justify-between px-2.5 py-1.5 text-xs">
<button class="viewer-btn !h-7 !w-7" aria-label="Zoom out" @click="handleZoomOut"><Minus :size="14" /></button>
<span class="tabular-nums text-muted-foreground">{{ props.zoomPercent }}%</span>
<button class="viewer-btn !h-7 !w-7" aria-label="Zoom in" @click="handleZoomIn"><Plus :size="14" /></button>
</div>
<button
v-if="props.fullscreenSupported"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs hover:bg-muted"
@click="handleToggleFullscreen"
>
<Minimize v-if="props.fullscreen" :size="15" />
<Maximize v-else :size="15" />
{{ props.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen' }}
</button>
<button
v-if="props.fullscreen"
class="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs hover:bg-muted"
:class="props.headerPinned ? 'bg-muted text-foreground' : ''"
@click="handleToggleHeaderPin"
>
<PinOff v-if="props.headerPinned" :size="15" />
<Pin v-else :size="15" />
{{ props.headerPinned ? 'Unpin header' : 'Pin header' }}
</button>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu :open="props.settingsOpen" @update:open="handleSettingsOpenChange">
<DropdownMenuTrigger as-child>
<button class="viewer-btn" :class="props.settingsOpen ? '!bg-muted !text-foreground' : ''" aria-label="PDF settings">
<Settings :size="18" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="bottom"
:side-offset="8"
class="max-h-[min(80vh,38rem)] w-[22rem] max-w-[calc(100vw-1rem)] overflow-hidden rounded-lg border-border bg-card p-0 shadow-2xl"
>
<slot name="settings" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
</template>
@@ -0,0 +1,45 @@
import { computed, onUnmounted, ref, watch, type Ref } from 'vue'
const AUTO_HIDE_DELAY_MS = 2500
export function usePdfFullscreenChrome(isFullscreen: Readonly<Ref<boolean>>, hasOpenUi: Readonly<Ref<boolean>>) {
const pinned = ref(false)
const visible = ref(true)
let hideTimer: ReturnType<typeof setTimeout> | null = null
const canAutoHide = computed(() => isFullscreen.value && !pinned.value && !hasOpenUi.value)
function clearHideTimer() {
if (!hideTimer) return
clearTimeout(hideTimer)
hideTimer = null
}
function scheduleHide() {
clearHideTimer()
if (!canAutoHide.value) return
hideTimer = setTimeout(() => {
visible.value = false
hideTimer = null
}, AUTO_HIDE_DELAY_MS)
}
function reveal() {
visible.value = true
scheduleHide()
}
function togglePinned() {
pinned.value = !pinned.value
reveal()
}
watch([isFullscreen, hasOpenUi, pinned], () => {
visible.value = true
scheduleHide()
})
onUnmounted(clearHideTimer)
return { pinned, visible, reveal, togglePinned }
}
@@ -0,0 +1,150 @@
import { computed, onUnmounted, type Ref } from 'vue'
import type { ScrollScope } from '@embedpdf/plugin-scroll'
import type { PdfReaderSettings } from '@bookorbit/types'
import { canTurnFromVisiblePage, findAdjacentPage, findPageRange } from '../pdf-pagination'
type ReadonlyRef<T> = Readonly<Ref<T>>
export interface UsePdfPaginationOptions {
mode: ReadonlyRef<PdfReaderSettings['scrollMode']>
scrollState: ReadonlyRef<{ currentPage: number; totalPages: number }>
scroll: ReadonlyRef<Readonly<ScrollScope> | null>
onActivity: () => void
}
export function usePdfPagination({ mode, scrollState, scroll, onActivity }: UsePdfPaginationOptions) {
let wheelDelta = 0
let wheelUnlockTimer: ReturnType<typeof setTimeout> | null = null
let touchStart: { x: number; y: number } | null = null
function getScrollScope() {
try {
return scroll.value
} catch {
return null
}
}
function getSpreads(scope = getScrollScope()) {
try {
return scope?.getSpreadPagesWithRotatedSize() ?? []
} catch {
return []
}
}
function getPageRange(pageNumber: number, totalPages: number) {
return findPageRange(getSpreads(), pageNumber, totalPages)
}
const pageRange = computed(() => getPageRange(scrollState.value.currentPage, scrollState.value.totalPages))
const behavior = computed(() => (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth'))
function goToPage(page: number) {
const clamped = Math.min(Math.max(page, 1), scrollState.value.totalPages || 1)
try {
getScrollScope()?.scrollToPage({ pageNumber: clamped, behavior: behavior.value })
} catch {
return
}
}
function turnPage(direction: -1 | 1) {
const scope = getScrollScope()
if (!scope) return
try {
if (scope.getPageChangeState().isChanging) return
const spreads = getSpreads(scope)
if (spreads.length === 0) return
const target = findAdjacentPage(spreads, pageRange.value.start, direction, scrollState.value.totalPages)
if (target === pageRange.value.start) return
scope.scrollToPage({ pageNumber: target, behavior: behavior.value })
} catch {
return
}
}
function previousPage() {
onActivity()
if (mode.value === 'page') turnPage(-1)
else {
try {
getScrollScope()?.scrollToPreviousPage(behavior.value)
} catch {
return
}
}
}
function nextPage() {
onActivity()
if (mode.value === 'page') turnPage(1)
else {
try {
getScrollScope()?.scrollToNextPage(behavior.value)
} catch {
return
}
}
}
function canTurnPage(direction: -1 | 1, axis: 'horizontal' | 'vertical' = 'vertical') {
const scope = getScrollScope()
if (!scope) return false
const spreads = getSpreads(scope)
if (spreads.length === 0) return false
try {
return canTurnFromVisiblePage(direction, pageRange.value, scope.getMetrics().pageVisibilityMetrics, spreads, axis)
} catch {
return false
}
}
function handleWheel(event: WheelEvent) {
onActivity()
if (mode.value !== 'page' || event.ctrlKey || event.metaKey) return
const axis = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? 'horizontal' : 'vertical'
const delta = axis === 'horizontal' ? event.deltaX : event.deltaY
const direction: -1 | 1 = delta < 0 ? -1 : 1
if (!canTurnPage(direction, axis)) return
event.preventDefault()
wheelDelta += delta
if (Math.abs(wheelDelta) < 80 || wheelUnlockTimer) return
turnPage(direction)
wheelDelta = 0
wheelUnlockTimer = setTimeout(() => {
wheelUnlockTimer = null
}, 350)
}
function handleTouchStart(event: TouchEvent) {
onActivity()
if (event.touches.length !== 1) {
touchStart = null
return
}
touchStart = { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY }
}
function handleTouchEnd(event: TouchEvent) {
if (mode.value !== 'page' || !touchStart || event.changedTouches.length !== 1) {
touchStart = null
return
}
const touch = event.changedTouches[0]!
const deltaX = touch.clientX - touchStart.x
const deltaY = touch.clientY - touchStart.y
touchStart = null
if (Math.abs(deltaX) < 60 || Math.abs(deltaX) < Math.abs(deltaY) * 1.2) return
const direction: -1 | 1 = deltaX > 0 ? -1 : 1
if (!canTurnPage(direction, 'horizontal')) return
event.preventDefault()
turnPage(direction)
}
onUnmounted(() => {
if (wheelUnlockTimer) clearTimeout(wheelUnlockTimer)
})
return { pageRange, getPageRange, goToPage, previousPage, nextPage, handleWheel, handleTouchStart, handleTouchEnd }
}
@@ -0,0 +1,45 @@
import { onMounted, onUnmounted, watch, type Ref } from 'vue'
import { SpreadMode, type SpreadScope } from '@embedpdf/plugin-spread'
import type { PdfReaderSettings } from '@bookorbit/types'
import { shouldUseResponsiveSpread } from '../pdf-pagination'
type ReadonlyRef<T> = Readonly<Ref<T>>
export function usePdfResponsiveSpread(
surface: ReadonlyRef<HTMLElement | null>,
preference: ReadonlyRef<PdfReaderSettings['spread']>,
spread: ReadonlyRef<Readonly<SpreadScope> | null>,
) {
let resizeObserver: ResizeObserver | null = null
function getSpreadScope() {
try {
return spread.value
} catch {
return null
}
}
function apply() {
if (preference.value !== 'auto' || !surface.value) return
const { width, height } = surface.value.getBoundingClientRect()
try {
getSpreadScope()?.setSpreadMode(shouldUseResponsiveSpread(width, height) ? SpreadMode.Odd : SpreadMode.None)
} catch {
return
}
}
onMounted(() => {
if (!surface.value) return
resizeObserver = new ResizeObserver(apply)
resizeObserver.observe(surface.value)
apply()
})
watch([surface, preference, getSpreadScope], apply, { flush: 'post' })
onUnmounted(() => resizeObserver?.disconnect())
return { apply }
}
@@ -0,0 +1,58 @@
import type { PdfPageObjectWithRotatedSize } from '@embedpdf/models'
import type { PageVisibilityMetrics } from '@embedpdf/plugin-scroll'
export interface PdfPageRange {
start: number
end: number
}
export function findPageRange(spreads: PdfPageObjectWithRotatedSize[][], pageNumber: number, totalPages: number): PdfPageRange {
const spread = spreads.find((pages) => pages.some((page) => page.index + 1 === pageNumber))
if (!spread?.length) {
const page = Math.min(Math.max(pageNumber, 1), Math.max(totalPages, 1))
return { start: page, end: page }
}
return {
start: Math.min(...spread.map((page) => page.index + 1)),
end: Math.max(...spread.map((page) => page.index + 1)),
}
}
export function findAdjacentPage(spreads: PdfPageObjectWithRotatedSize[][], pageNumber: number, direction: -1 | 1, totalPages: number): number {
const currentIndex = spreads.findIndex((pages) => pages.some((page) => page.index + 1 === pageNumber))
if (currentIndex < 0) return Math.min(Math.max(pageNumber + direction, 1), Math.max(totalPages, 1))
const target = spreads[currentIndex + direction]
if (!target?.length) return Math.min(...spreads[currentIndex]!.map((page) => page.index + 1))
return Math.min(...target.map((page) => page.index + 1))
}
export function canTurnFromVisiblePage(
direction: -1 | 1,
pageRange: PdfPageRange,
visibility: PageVisibilityMetrics[],
spreads: PdfPageObjectWithRotatedSize[][],
axis: 'horizontal' | 'vertical' = 'vertical',
): boolean {
const pageNumbers = new Set(Array.from({ length: pageRange.end - pageRange.start + 1 }, (_, index) => pageRange.start + index))
const relevantMetrics = visibility.filter((metric) => pageNumbers.has(metric.pageNumber))
if (relevantMetrics.length === 0) return true
const pages = spreads.flat().filter((page) => pageNumbers.has(page.index + 1))
if (pages.length === 0) return true
return relevantMetrics.every((metric) => {
const page = pages.find((candidate) => candidate.index + 1 === metric.pageNumber)
if (!page) return true
if (axis === 'horizontal') {
if (direction < 0) return metric.original.pageX <= 1
return metric.original.pageX + metric.original.visibleWidth >= page.rotatedSize.width - 1
}
if (direction < 0) return metric.original.pageY <= 1
return metric.original.pageY + metric.original.visibleHeight >= page.rotatedSize.height - 1
})
}
export function shouldUseResponsiveSpread(width: number, height: number): boolean {
return width >= 900 && width / Math.max(height, 1) >= 1.1
}
@@ -1,7 +1,54 @@
export function lookupAccentHex(accentId: string, options: readonly { id: string; color: string }[]): string {
return options.find((o) => o.id === accentId)?.color ?? '#3b82f6'
import { Rotation, type PdfBookmarkObject } from '@embedpdf/models'
import { ScrollStrategy } from '@embedpdf/plugin-scroll'
import { SpreadMode } from '@embedpdf/plugin-spread'
import { ZoomMode, type ZoomLevel } from '@embedpdf/plugin-zoom'
import type { PdfReaderSettings } from '@bookorbit/types'
export interface FlatPdfBookmark {
bookmark: PdfBookmarkObject
depth: number
}
export function getIsDark(): boolean {
return document.documentElement.classList.contains('dark')
export function toScrollStrategy(mode: PdfReaderSettings['scrollMode']): ScrollStrategy {
return mode === 'horizontal' ? ScrollStrategy.Horizontal : ScrollStrategy.Vertical
}
export function toSpreadMode(mode: PdfReaderSettings['spread']): SpreadMode {
if (mode === 'odd') return SpreadMode.Odd
if (mode === 'even') return SpreadMode.Even
return SpreadMode.None
}
export function toZoomLevel(settings: PdfReaderSettings): ZoomLevel {
if (settings.zoomMode === 'fit-width') return ZoomMode.FitWidth
if (settings.zoomMode === 'automatic') return ZoomMode.Automatic
if (settings.zoomMode === 'custom') return settings.customScale
return ZoomMode.FitPage
}
export function toRotation(rotation: PdfReaderSettings['rotation']): Rotation {
if (rotation === 90) return Rotation.Degree90
if (rotation === 180) return Rotation.Degree180
if (rotation === 270) return Rotation.Degree270
return Rotation.Degree0
}
export function fromRotation(rotation: Rotation): PdfReaderSettings['rotation'] {
if (rotation === Rotation.Degree90) return 90
if (rotation === Rotation.Degree180) return 180
if (rotation === Rotation.Degree270) return 270
return 0
}
export function flattenPdfBookmarks(bookmarks: PdfBookmarkObject[], depth = 0): FlatPdfBookmark[] {
return bookmarks.flatMap((bookmark) => [{ bookmark, depth }, ...flattenPdfBookmarks(bookmark.children ?? [], depth + 1)])
}
export function safeExternalPdfUrl(uri: string): URL | null {
try {
const url = new URL(uri, window.location.href)
return url.protocol === 'http:' || url.protocol === 'https:' ? url : null
} catch {
return null
}
}
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { CBX_READER_DEFAULTS, READER_GROUP_DEFAULTS } from '@bookorbit/types'
import { CBX_READER_DEFAULTS, READER_GROUP_DEFAULTS, type PdfReaderSettings } from '@bookorbit/types'
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<unknown>>())
vi.mock('@/lib/api', () => ({ api: apiMock }))
@@ -108,6 +108,47 @@ describe('useReaderSettings - load() with invalid localStorage value', () => {
})
})
describe('useReaderSettings - PDF validation', () => {
it('keeps valid PDF settings and removes values that can break reader layout', async () => {
localStorage.setItem(
`reader:book:${BOOK_FILE_ID}`,
JSON.stringify({
scrollMode: 'horizontal',
spread: 'odd',
zoomMode: 'custom',
customScale: 40,
rotation: 45,
}),
)
const settings = useReaderSettings(BOOK_FILE_ID, 'pdf')
await settings.load()
expect(settings.bookDelta.value).toEqual({
scrollMode: 'horizontal',
spread: 'odd',
zoomMode: 'custom',
})
const effective = settings.effective.value as PdfReaderSettings
expect(effective.customScale).toBe(1)
expect(effective.rotation).toBe(0)
})
it('migrates wrapped mode and retains new read-only layout choices', async () => {
localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ scrollMode: 'wrapped', spread: 'auto', zoomMode: 'automatic' }))
const settings = useReaderSettings(BOOK_FILE_ID, 'pdf')
await settings.load()
expect(settings.bookDelta.value).toEqual({ scrollMode: 'vertical', spread: 'auto', zoomMode: 'automatic' })
expect(JSON.parse(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`) ?? '{}')).toEqual({
scrollMode: 'vertical',
spread: 'auto',
zoomMode: 'automatic',
})
})
})
describe('useReaderSettings - load() with valid cbx localStorage delta', () => {
it('filters out invalid fitMode and keeps valid fields', async () => {
const raw = { fitMode: 'stretch', viewMode: 'single' }
@@ -5,8 +5,10 @@ import { useAuth } from '@/features/auth/composables/useAuth'
import {
CBX_READER_DEFAULTS,
EPUB_READER_DEFAULTS,
PDF_READER_DEFAULTS,
type CbxReaderSettings,
type EpubReaderSettings,
type PdfReaderSettings,
type ReaderFormatGroup,
type ReaderSettings,
READER_GROUP_DEFAULTS,
@@ -138,8 +140,35 @@ function sanitizeCbxPartialSettings(settings: unknown): Partial<CbxReaderSetting
return out
}
function sanitizePdfPartialSettings(settings: unknown): Partial<PdfReaderSettings> | null {
if (!isRecord(settings)) return null
const out: Partial<PdfReaderSettings> = {}
if (settings.scrollMode === 'vertical' || settings.scrollMode === 'horizontal' || settings.scrollMode === 'page') {
out.scrollMode = settings.scrollMode
} else if (settings.scrollMode === 'wrapped') {
out.scrollMode = 'vertical'
}
if (settings.spread === 'none' || settings.spread === 'odd' || settings.spread === 'even' || settings.spread === 'auto') {
out.spread = settings.spread
}
if (settings.zoomMode === 'fit-width' || settings.zoomMode === 'fit-page' || settings.zoomMode === 'automatic' || settings.zoomMode === 'custom') {
out.zoomMode = settings.zoomMode
}
if (isNumberInRange(settings.customScale, 0.25, 4)) {
out.customScale = settings.customScale
}
if (settings.rotation === 0 || settings.rotation === 90 || settings.rotation === 180 || settings.rotation === 270) {
out.rotation = settings.rotation
}
return out
}
function sanitizeBookDelta(group: ReaderFormatGroup, raw: unknown): Partial<ReaderSettings> | null {
if (group === 'epub') return sanitizeEpubPartialSettings(raw) as Partial<ReaderSettings> | null
if (group === 'pdf') return sanitizePdfPartialSettings(raw) as Partial<ReaderSettings> | null
if (group !== 'cbx') return isRecord(raw) ? (raw as Partial<ReaderSettings>) : null
const sanitized = sanitizeCbxPartialSettings(raw)
return sanitized as Partial<ReaderSettings> | null
@@ -154,6 +183,14 @@ function sanitizeDefaultSettings(group: ReaderFormatGroup, raw: unknown): Reader
...sanitized,
} as ReaderSettings
}
if (group === 'pdf') {
const sanitized = sanitizePdfPartialSettings(raw)
if (!sanitized) return null
return {
...PDF_READER_DEFAULTS,
...sanitized,
} as ReaderSettings
}
if (group !== 'cbx') return isRecord(raw) ? (raw as unknown as ReaderSettings) : null
const sanitized = sanitizeCbxPartialSettings(raw)
+69 -10
View File
@@ -18,6 +18,47 @@ const { effective, load, update, reset } = useReaderDefaultSettings<PdfReaderSet
onMounted(load)
const showZoom = computed(() => effective.value.zoomMode === 'custom')
function resetSettings() {
reset()
}
function selectPageLayout() {
update({ scrollMode: 'page' })
}
function selectVerticalLayout() {
update({ scrollMode: 'vertical' })
}
function selectHorizontalLayout() {
update({ scrollMode: 'horizontal' })
}
function selectSingleSpread() {
update({ spread: 'none' })
}
function selectOddSpread() {
update({ spread: 'odd' })
}
function selectEvenSpread() {
update({ spread: 'even' })
}
function selectAutoSpread() {
update({ spread: 'auto' })
}
function selectZoomMode(event: Event) {
const mode = (event.currentTarget as HTMLButtonElement).dataset.zoomMode as PdfReaderSettings['zoomMode']
update({ zoomMode: mode })
}
function handleCustomScale(event: Event) {
update({ customScale: Number((event.target as HTMLInputElement).value) })
}
</script>
<template>
@@ -25,7 +66,7 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
class="[&_.settings-hint]:overflow-hidden [&_.settings-hint]:text-ellipsis [&_.settings-hint]:whitespace-nowrap md:[&_.settings-hint]:overflow-visible md:[&_.settings-hint]:whitespace-normal"
>
<SettingsPageHeader v-if="!props.embedded" title="PDF Reader" subtitle="Default settings applied when opening PDF files.">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="resetSettings">
Reset to defaults
</button>
</SettingsPageHeader>
@@ -33,12 +74,12 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
<div
class="md:hidden sticky top-11 z-10 -mx-4 mb-4 px-4 py-2 border-y border-border/70 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/75"
>
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="resetSettings">
Reset to defaults
</button>
</div>
<div class="hidden md:flex justify-end mb-4">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="reset()">
<button class="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" @click="resetSettings">
Reset to defaults
</button>
</div>
@@ -60,17 +101,26 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
<button
class="h-8 px-3 rounded-md text-xs font-medium transition-colors"
:class="effective.scrollMode === 'page' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="update({ scrollMode: 'page' })"
@click="selectPageLayout"
>
Page
</button>
<button
class="h-8 px-3 rounded-md text-xs font-medium transition-colors"
:class="effective.scrollMode === 'vertical' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="update({ scrollMode: 'vertical' })"
@click="selectVerticalLayout"
>
Scrolled
</button>
<button
class="h-8 px-3 rounded-md text-xs font-medium transition-colors"
:class="
effective.scrollMode === 'horizontal' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'
"
@click="selectHorizontalLayout"
>
Horizontal
</button>
</div>
</div>
@@ -86,24 +136,31 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
<button
class="h-8 px-3 rounded-md text-xs font-medium transition-colors"
:class="effective.spread === 'none' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="update({ spread: 'none' })"
@click="selectSingleSpread"
>
None
</button>
<button
class="h-8 px-3 rounded-md text-xs font-medium transition-colors"
:class="effective.spread === 'odd' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="update({ spread: 'odd' })"
@click="selectOddSpread"
>
Odd
</button>
<button
class="h-8 px-3 rounded-md text-xs font-medium transition-colors"
:class="effective.spread === 'even' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="update({ spread: 'even' })"
@click="selectEvenSpread"
>
Even
</button>
<button
class="h-8 px-3 rounded-md text-xs font-medium transition-colors"
:class="effective.spread === 'auto' ? 'bg-background shadow-xs text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="selectAutoSpread"
>
Auto
</button>
</div>
</div>
</div>
@@ -126,16 +183,18 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
v-for="opt in [
{ id: 'fit-page' as const, label: 'Fit Page' },
{ id: 'fit-width' as const, label: 'Fit Width' },
{ id: 'automatic' as const, label: 'Automatic' },
{ id: 'custom' as const, label: 'Custom' },
]"
:key="opt.id"
:data-zoom-mode="opt.id"
class="h-8 px-3 text-xs border-2 transition-colors font-medium rounded-md"
:class="
effective.zoomMode === opt.id
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="update({ zoomMode: opt.id })"
@click="selectZoomMode"
>
{{ opt.label }}
</button>
@@ -160,7 +219,7 @@ const showZoom = computed(() => effective.value.zoomMode === 'custom')
step="0.05"
class="w-full accent-primary cursor-pointer"
:value="effective.customScale"
@input="update({ customScale: Number(($event.target as HTMLInputElement).value) })"
@input="handleCustomScale"
/>
</div>
</div>
+13
View File
@@ -53,3 +53,16 @@ if (needsSetup.value !== true) {
app.use(router)
app.mount('#app')
function prefetchPdfReader() {
void Promise.all([import('./features/reader/pdf-v4/PdfV4ReaderView.vue'), import('@embedpdf/pdfium/pdfium.wasm?url')]).then(([, wasm]) => {
void fetch(wasm.default, { cache: 'force-cache' })
})
}
const idleWindow = window as Window & { requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number }
if (idleWindow.requestIdleCallback) {
idleWindow.requestIdleCallback(prefetchPdfReader, { timeout: 10_000 })
} else {
window.setTimeout(prefetchPdfReader, 5_000)
}
+1
View File
@@ -128,6 +128,7 @@ export default defineConfig({
},
optimizeDeps: {
include: ['@tanstack/vue-table', '@tanstack/vue-virtual'],
exclude: ['@embedpdf/core', '@embedpdf/core/vue'],
},
server: {
host: true,
+12 -1
View File
@@ -88,6 +88,13 @@
"fast-xml-builder": "^1.1.7",
"ws": "^8.21.0"
},
"packageExtensions": {
"@embedpdf/utils@2.14.4": {
"dependencies": {
"@embedpdf/models": "2.14.4"
}
}
},
"onlyBuiltDependencies": [
"@nestjs/core",
"@parcel/watcher",
@@ -95,6 +102,10 @@
"esbuild",
"sharp",
"unrs-resolver"
]
],
"patchedDependencies": {
"@embedpdf/core@2.14.4": "patches/@embedpdf__core@2.14.4.patch",
"@embedpdf/engines@2.14.4": "patches/@embedpdf__engines@2.14.4.patch"
}
}
}
+3 -3
View File
@@ -70,9 +70,9 @@ export interface EpubReaderSettings {
}
export interface PdfReaderSettings {
scrollMode: "vertical" | "horizontal" | "wrapped" | "page";
spread: "none" | "odd" | "even";
zoomMode: "fit-width" | "fit-page" | "custom";
scrollMode: "vertical" | "horizontal" | "page";
spread: "none" | "odd" | "even" | "auto";
zoomMode: "fit-width" | "fit-page" | "automatic" | "custom";
customScale: number; // 0.25-4.0, used when zoomMode is 'custom'
rotation: 0 | 90 | 180 | 270;
}
+65
View File
@@ -0,0 +1,65 @@
diff --git a/dist/vue/index.js b/dist/vue/index.js
index 0794dffc1ec042f66f9eb36192f46df2615ebb06..fc83b348e33658d615e178c638b69167eb0d905e 100644
--- a/dist/vue/index.js
+++ b/dist/vue/index.js
@@ -270,6 +270,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
const coreState = ref(null);
const isInit = ref(true);
const pluginsOk = ref(false);
+ let pendingRegistry = null;
+ let unsubscribe = null;
const activeDocumentId = computed(() => {
var _a;
return ((_a = coreState.value) == null ? void 0 : _a.activeDocumentId) ?? null;
@@ -302,6 +304,11 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
documents,
documentStates
});
+ onBeforeUnmount(() => {
+ unsubscribe == null ? void 0 : unsubscribe();
+ pendingRegistry == null ? void 0 : pendingRegistry.destroy();
+ registry.value == null ? void 0 : registry.value.destroy();
+ });
onMounted(async () => {
var _a, _b;
const finalConfig = {
@@ -309,6 +316,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
logger: ((_a = props.config) == null ? void 0 : _a.logger) ?? props.logger
};
const reg = new PluginRegistry(props.engine, finalConfig);
+ pendingRegistry = reg;
reg.registerPluginBatch(props.plugins);
await reg.initialize();
if (reg.isDestroyed()) {
@@ -316,28 +324,24 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
}
const store = reg.getStore();
coreState.value = store.getState().core;
- const unsubscribe = store.subscribe((action, newState, oldState) => {
+ unsubscribe = store.subscribe((action, newState, oldState) => {
if (store.isCoreAction(action) && newState.core !== oldState.core) {
coreState.value = newState.core;
}
});
await ((_b = props.onInitialized) == null ? void 0 : _b.call(props, reg));
if (reg.isDestroyed()) {
- unsubscribe();
+ unsubscribe == null ? void 0 : unsubscribe();
return;
}
registry.value = reg;
+ pendingRegistry = null;
isInit.value = false;
reg.pluginsReady().then(() => {
if (!reg.isDestroyed()) {
pluginsOk.value = true;
}
});
- onBeforeUnmount(() => {
- var _a2;
- unsubscribe();
- (_a2 = registry.value) == null ? void 0 : _a2.destroy();
- });
});
return (_ctx, _cache) => {
return pluginsOk.value && __props.autoMountDomElements ? (openBlock(), createBlock(_sfc_main$1, {
+21
View File
@@ -0,0 +1,21 @@
diff --git a/dist/lib/pdfium/web/worker-engine.js b/dist/lib/pdfium/web/worker-engine.js
index 1c40ef8dd3aa8050f3a23242d23258dbd04b3604..b81a6a9b879ecb4016a8ee1ad7da552f0ae5a13e 100644
--- a/dist/lib/pdfium/web/worker-engine.js
+++ b/dist/lib/pdfium/web/worker-engine.js
@@ -10,6 +10,16 @@ const _RemoteExecutor = class _RemoteExecutor {
this.requestCounter = 0;
this.handleMessage = (event) => {
const response = event.data;
+ if (response.type === "wasmError") {
+ const message = typeof response.error === "string" ? response.error : "Unknown worker initialization error";
+ this.logger.error(LOG_SOURCE$1, LOG_CATEGORY$1, "Worker initialization failed:", message);
+ this.readyTask.reject({
+ code: PdfErrorCode.Initialization,
+ message
+ });
+ this.pendingRequests.delete(_RemoteExecutor.READY_TASK_ID);
+ return;
+ }
if (response.type === "ready") {
this.logger.debug(LOG_SOURCE$1, LOG_CATEGORY$1, "Worker is ready");
this.readyTask.resolve(true);
+12434 -7007
View File
File diff suppressed because it is too large Load Diff
@@ -102,6 +102,18 @@ describe('ReaderPreferencesService', () => {
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(4, 10, { zoomMode: 'custom', customScale: 1.4 });
});
it('accepts new PDF layout choices and normalizes legacy wrapped mode', async () => {
const user = makeUser({ id: 4 });
await service.upsertPreference(user, 10, { scrollMode: 'wrapped', spread: 'auto', zoomMode: 'automatic' });
expect(mockRepo.upsertPreference).toHaveBeenCalledWith(4, 10, {
scrollMode: 'vertical',
spread: 'auto',
zoomMode: 'automatic',
});
});
it('maps cbz file format to cbx group and validates against comics settings', async () => {
const user = makeUser();
mockBookService.verifyFileAccess.mockResolvedValueOnce({ format: 'cbz' });
@@ -31,9 +31,9 @@ const EPUB_SETTINGS_SCHEMA = z
const PDF_SETTINGS_SCHEMA = z
.object({
scrollMode: z.enum(['vertical', 'horizontal', 'wrapped', 'page']),
spread: z.enum(['none', 'odd', 'even']),
zoomMode: z.enum(['fit-width', 'fit-page', 'custom']),
scrollMode: z.enum(['vertical', 'horizontal', 'wrapped', 'page']).transform((mode) => (mode === 'wrapped' ? 'vertical' : mode)),
spread: z.enum(['none', 'odd', 'even', 'auto']),
zoomMode: z.enum(['fit-width', 'fit-page', 'automatic', 'custom']),
customScale: z.number().min(0.25).max(4),
rotation: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]),
})