mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
+20
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
@@ -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);
|
||||
Generated
+12434
-7007
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)]),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user