feat(reader): replace pdf reader with embedpdf vue viewer

Replace the v1 canvas-based PDF reader (pdfjs-dist) with @embedpdf/vue-pdf-viewer
which renders correctly on iOS Chrome. Configure theme sync with app accent colors
via ACCENT_OPTIONS lookup instead of unreliable DOM oklch resolution, disable unused
toolbar categories, and persist per-book reader settings (scroll, spread, zoom).
This commit is contained in:
neon
2026-05-12 19:30:05 +00:00
parent f79f810c8c
commit 431053e510
18 changed files with 10875 additions and 6555 deletions
+1 -1
View File
@@ -24,6 +24,7 @@
},
"dependencies": {
"@bookorbit/types": "workspace:*",
"@embedpdf/vue-pdf-viewer": "^2.14.2",
"@fontsource-variable/fraunces": "^5.2.9",
"@fontsource-variable/inter": "^5.2.8",
"@tailwindcss/vite": "^4.2.4",
@@ -38,7 +39,6 @@
"echarts": "^6.0.0",
"howler": "^2.2.4",
"lucide-vue-next": "^1.0.0",
"pdfjs-dist": "^5.7.284",
"pinia": "^3.0.4",
"reka-ui": "^2.9.6",
"socket.io-client": "^4.8.3",
+2 -2
View File
@@ -20,7 +20,7 @@ import ReaderSettingsPanel from './epub/components/ReaderSettingsPanel.vue'
import SelectionPopup from './epub/components/SelectionPopup.vue'
import ReaderSearchPanel from './epub/components/ReaderSearchPanel.vue'
import NoteDialog from './epub/components/NoteDialog.vue'
import PdfReaderView from './pdf/PdfReaderView.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'
@@ -301,7 +301,7 @@ function closeSearch() {
</script>
<template>
<PdfReaderView v-if="isPdfFormat" :bookId="bookId" :fileId="fileId" />
<PdfV4ReaderView v-if="isPdfFormat" :bookId="bookId" :fileId="fileId" />
<CbzReaderView v-else-if="isComicFormat" :bookId="bookId" :fileId="fileId" />
<AudiobookReaderView v-else-if="isAudioFormat" :bookId="bookId" :fileId="fileId" />
<div
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
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 { useReaderProgress } from '../shared/composables/useReaderProgress'
import { useReadingSession } from '../shared/composables/useReadingSession'
import { useReaderSettings } from '../shared/composables/useReaderSettings'
import { useThemeStore, ACCENT_OPTIONS } from '@/stores/theme'
import type { PdfReaderSettings } from '@bookorbit/types'
import { getIsDark, lookupAccentHex } from './pdf-viewer-utils'
const props = defineProps<{ bookId: number; fileId: number }>()
const themeStore = useThemeStore()
const progress = useReaderProgress(props.bookId, props.fileId)
const bookSettings = useReaderSettings(props.fileId, 'pdf')
const { onActivity } = useReadingSession(props.fileId, () => ({
percentage: progress.percentage.value,
pageNumber: progress.pageNumber.value,
}))
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) {
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>({})
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
})
function handleInit(container: EmbedPdfContainer) {
viewerContainer = container
}
function handleReady(registry: PluginRegistry) {
setupProgressTracking(registry)
setupSettingsPersistence(registry)
setupThemeSync()
}
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 initialPage = progress.pageNumber.value ?? 1
if (initialPage > 1 && initialPage <= totalPages) {
scroll.scrollToPage({ pageNumber: initialPage })
}
})
unsubPageChange = scroll.onPageChange(({ pageNumber, totalPages }) => {
onActivity()
scheduleSave(pageNumber, totalPages)
})
}
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 setupThemeSync() {
themeObserver = new MutationObserver(() => {
if (!viewerContainer) return
viewerContainer.setTheme(buildThemeConfig())
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
}
onUnmounted(() => {
unsubPageChange?.()
unsubLayoutReady?.()
themeObserver?.disconnect()
if (saveTimer) clearTimeout(saveTimer)
})
</script>
<template>
<div class="fixed inset-0 flex flex-col">
<PDFViewer v-if="configReady" :config="viewerConfig" style="flex: 1; min-height: 0" @init="handleInit" @ready="handleReady" />
</div>
</template>
@@ -0,0 +1,331 @@
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'
const mockProgressLoad = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const mockProgressSave = vi.fn<() => void>()
const mockProgressPageNumber = { value: 5 }
const mockProgressPercentage = { value: 25 }
vi.mock('../../shared/composables/useReaderProgress', () => ({
useReaderProgress: () => ({
load: mockProgressLoad,
save: mockProgressSave,
pageNumber: mockProgressPageNumber,
percentage: mockProgressPercentage,
}),
}))
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,
}),
}))
vi.mock('@/lib/api', () => ({
getAccessToken: () => 'test-token-abc',
}))
let capturedConfig: PDFViewerConfig | null = null
let readyCallback: ((registry: unknown) => void) | null = null
let initCallback: ((container: unknown) => void) | null = null
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' })
},
}),
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())
})
async function mountComponent() {
const pinia = createPinia()
const wrapper = mount(PdfV4ReaderView, {
props: { bookId: 42, fileId: 101 },
global: { plugins: [pinia] },
})
await flushPromises()
await nextTick()
return wrapper
}
describe('initialization', () => {
it('loads book settings on mount', async () => {
await mountComponent()
expect(mockSettingsLoad).toHaveBeenCalled()
})
it('renders viewer only after config is ready', async () => {
const wrapper = await mountComponent()
expect(wrapper.find('.mock-pdf-viewer').exists()).toBe(true)
})
})
describe('config construction', () => {
it('sets tabBar to never', async () => {
await mountComponent()
expect(capturedConfig?.tabBar).toBe('never')
})
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']),
)
})
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
})
})
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('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' }))
})
})
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('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()
})
})
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()
expect(() => wrapper.unmount()).not.toThrow()
})
})
})
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { lookupAccentHex } 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' },
]
it('returns color for matching accent id', () => {
expect(lookupAccentHex('rose', options)).toBe('#e11d48')
})
it('returns color for another accent id', () => {
expect(lookupAccentHex('green', options)).toBe('#16a34a')
})
it('returns fallback blue for unknown accent id', () => {
expect(lookupAccentHex('unknown', options)).toBe('#3b82f6')
})
it('returns fallback blue for empty options', () => {
expect(lookupAccentHex('rose', [])).toBe('#3b82f6')
})
})
@@ -0,0 +1,7 @@
export function lookupAccentHex(accentId: string, options: readonly { id: string; color: string }[]): string {
return options.find((o) => o.id === accentId)?.color ?? '#3b82f6'
}
export function getIsDark(): boolean {
return document.documentElement.classList.contains('dark')
}
@@ -1,483 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import type { CSSProperties } from 'vue'
import { useRouter } from 'vue-router'
import { usePdf } from './composables/usePdf'
import { usePdfZoom } from './composables/usePdfZoom'
import { usePdfLayout, PAGE_GAP, type ScrollMode } from './composables/usePdfLayout'
import { usePdfRenderer } from './composables/usePdfRenderer'
import { usePdfFind } from './composables/usePdfFind'
import { usePdfOutline } from './composables/usePdfOutline'
import { useReaderProgress } from '../shared/composables/useReaderProgress'
import { useReadingSession } from '../shared/composables/useReadingSession'
import { useReaderSettings } from '../shared/composables/useReaderSettings'
import PdfToolbar from './components/PdfToolbar.vue'
import PdfFindBar from './components/FindBar.vue'
import PdfSidebar from './components/PdfSidebar.vue'
import type { PageDim } from './composables/usePdf'
import type { PdfReaderSettings } from '@bookorbit/types'
const props = defineProps<{ bookId: number; fileId: number }>()
const router = useRouter()
const { pdfDoc, totalPages, loading, error, load, getPageDim, startRenderPage, getTextContent } = usePdf()
const progress = useReaderProgress(props.bookId, props.fileId)
const bookSettings = useReaderSettings(props.fileId, 'pdf')
const { onActivity } = useReadingSession(props.fileId, () => ({
percentage: progress.percentage.value,
pageNumber: progress.pageNumber.value,
}))
// ── Layout container ──────────────────────────────────────────────────────────
const scrollRef = ref<HTMLElement | null>(null)
const containerW = ref(0)
const containerH = ref(0)
function measure() {
if (!scrollRef.value) return
containerW.value = scrollRef.value.clientWidth
containerH.value = scrollRef.value.clientHeight
}
// ── Page dims ─────────────────────────────────────────────────────────────────
const pageDims = ref<PageDim[]>([])
const effectiveDims = computed(() => pageDims.value)
// ── Spread ────────────────────────────────────────────────────────────────────
const spread = ref<'none' | 'odd' | 'even'>('none')
// ── Composables ───────────────────────────────────────────────────────────────
const zoom = usePdfZoom(containerW, containerH, effectiveDims, spread)
const { scale, zoomMode, customScale, zoomLabel, adjustZoom, applyZoomPreset } = zoom
const layout = usePdfLayout(scrollRef, totalPages, effectiveDims, scale, containerH, spread)
const { scrollMode, currentPage, pageInput, pageRows, rowHeights, goToPage, onScroll } = layout
function onDimUpdate(pageNum: number, dim: PageDim) {
const prev = pageDims.value[pageNum - 1]
if (prev && prev.width === dim.width && prev.height === dim.height) return
pageDims.value = pageDims.value.map((d, i) => (i === pageNum - 1 ? dim : d))
}
const renderer = usePdfRenderer(startRenderPage, getTextContent, scale, totalPages, onDimUpdate)
const { canvasMap, textLayerMap, invalidate, renderPage, setupIO, reset, destroy } = renderer
// ── Find ──────────────────────────────────────────────────────────────────────
const find = usePdfFind(textLayerMap)
const findBarRef = ref<InstanceType<typeof PdfFindBar> | null>(null)
const showFind = ref(false)
function openFind() {
showFind.value = true
nextTick(() => findBarRef.value?.focus())
}
function closeFind() {
showFind.value = false
find.clear()
findBarRef.value?.clear()
}
// ── Outline ───────────────────────────────────────────────────────────────────
const outline = usePdfOutline(pdfDoc)
// ── Sidebar ───────────────────────────────────────────────────────────────────
const showSidebar = ref(true)
// ── Cursor tool ───────────────────────────────────────────────────────────────
const cursorTool = ref<'select' | 'hand'>('select')
// Hand tool drag state
let isDragging = false
let lastX = 0
let lastY = 0
let pendingDx = 0
let pendingDy = 0
let rafId: number | null = null
function flushScroll() {
rafId = null
if (pendingDx !== 0 || pendingDy !== 0) {
scrollRef.value?.scrollBy({ left: -pendingDx, top: -pendingDy })
pendingDx = 0
pendingDy = 0
}
}
function onScrollMouseDown(e: MouseEvent) {
if (cursorTool.value !== 'hand') return
isDragging = true
lastX = e.clientX
lastY = e.clientY
}
function onScrollMouseMove(e: MouseEvent) {
if (!isDragging || cursorTool.value !== 'hand') return
pendingDx += e.clientX - lastX
pendingDy += e.clientY - lastY
lastX = e.clientX
lastY = e.clientY
if (rafId === null) rafId = requestAnimationFrame(flushScroll)
}
function onScrollMouseUp() {
isDragging = false
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
flushScroll()
}
}
// ── Fullscreen ────────────────────────────────────────────────────────────────
const isFullscreen = ref(false)
function toggleFullscreen() {
if (document.fullscreenElement) document.exitFullscreen?.()
else document.documentElement.requestFullscreen?.()
}
// ── Invalidation ──────────────────────────────────────────────────────────────
watch(scale, invalidate)
watch([spread, scrollMode], async () => {
const page = currentPage.value
invalidate()
await nextTick()
if (scrollRef.value) setupIO(scrollRef.value)
goToPage(page, 'instant')
})
// ── Progress ──────────────────────────────────────────────────────────────────
let saveTimer: ReturnType<typeof setTimeout> | null = null
watch(currentPage, (page) => {
onActivity()
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
progress.pageNumber.value = page
progress.percentage.value = (page / totalPages.value) * 100
progress.save()
}, 2000)
})
const progressPct = computed(() => (totalPages.value ? Math.round((currentPage.value / totalPages.value) * 100) : 0))
const readerReady = ref(false)
const isReaderLoading = computed(() => !error.value && (!readerReady.value || loading.value))
// ── Scroll container style ────────────────────────────────────────────────────
const scrollStyle = computed((): CSSProperties => {
if (scrollMode.value === 'horizontal') return { overflowX: 'auto', overflowY: 'hidden' }
if (scrollMode.value === 'page') return { overflowY: 'auto', scrollSnapType: 'y mandatory' }
return { overflowY: 'auto' }
})
// ── Keyboard ──────────────────────────────────────────────────────────────────
function onKeyDown(e: KeyboardEvent) {
if (showFind.value && (e.target as HTMLElement)?.tagName === 'INPUT') return
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault()
openFind()
return
}
if (e.key === 'Escape') {
closeFind()
return
}
const el = scrollRef.value
if (!el) return
const ph = (rowHeights.value[pageRows.value.findIndex((r) => r.includes(currentPage.value))] ?? containerH.value) + PAGE_GAP
if (e.key === 'ArrowDown' || e.key === 'PageDown') {
e.preventDefault()
el.scrollBy({ top: ph, behavior: 'smooth' })
} else if (e.key === 'ArrowUp' || e.key === 'PageUp') {
e.preventDefault()
el.scrollBy({ top: -ph, behavior: 'smooth' })
} else if (e.key === 'Home') goToPage(1)
else if (e.key === 'End') goToPage(totalPages.value)
}
function onPageCommit(page: number) {
if (!isNaN(page)) goToPage(page)
}
function onFindSearch(q: string) {
find.query.value = q
find.search()
}
function onFindMatchCase(v: boolean) {
find.matchCase.value = v
find.search()
}
function onFindWholeWord(v: boolean) {
find.wholeWord.value = v
find.search()
}
// ── Mount / unmount ───────────────────────────────────────────────────────────
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
window.addEventListener('keydown', onKeyDown)
window.addEventListener('mouseup', onScrollMouseUp)
window.addEventListener('mousemove', onScrollMouseMove, { passive: true })
document.addEventListener('fullscreenchange', () => {
isFullscreen.value = !!document.fullscreenElement
})
try {
await progress.load()
await bookSettings.load()
const ps = bookSettings.effective.value as PdfReaderSettings
// Coerce saved 'continuous' (old value) to 'vertical'
const savedMode = ps.scrollMode as string
scrollMode.value = savedMode === 'continuous' ? 'vertical' : (ps.scrollMode as ScrollMode)
spread.value = ps.spread
zoomMode.value = ps.zoomMode
customScale.value = ps.customScale
watch(spread, (v) => bookSettings.updateBookSettings({ spread: v }))
watch(zoomMode, (v) => bookSettings.updateBookSettings({ zoomMode: v }))
watch(customScale, (v) => bookSettings.updateBookSettings({ customScale: v }))
watch(scrollMode, (v) => bookSettings.updateBookSettings({ scrollMode: v } as Parameters<typeof bookSettings.updateBookSettings>[0]))
await load(props.fileId)
if (!pdfDoc.value) return
const firstDim = await getPageDim(1)
pageDims.value = Array.from({ length: totalPages.value }, () => ({ ...firstDim }))
reset()
await nextTick()
measure()
const initialPage =
progress.pageNumber.value && progress.pageNumber.value > 1
? progress.pageNumber.value
: progress.percentage.value > 0
? Math.max(1, Math.round((progress.percentage.value / 100) * totalPages.value))
: 1
goToPage(initialPage, 'instant')
await renderPage(initialPage)
resizeObserver = new ResizeObserver(async () => {
measure()
invalidate()
await nextTick()
if (scrollRef.value) setupIO(scrollRef.value)
})
if (scrollRef.value) {
resizeObserver.observe(scrollRef.value)
setupIO(scrollRef.value)
}
readerReady.value = true
outline.load().catch(() => {})
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to initialize PDF reader'
}
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('mouseup', onScrollMouseUp)
window.removeEventListener('mousemove', onScrollMouseMove)
if (rafId !== null) cancelAnimationFrame(rafId)
resizeObserver?.disconnect()
resizeObserver = null
destroy()
if (saveTimer) clearTimeout(saveTimer)
})
</script>
<template>
<div class="fixed inset-0 flex flex-col overflow-hidden select-none bg-muted">
<div v-if="isReaderLoading" class="absolute inset-0 z-[60] flex items-center justify-center bg-background">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<p class="text-sm text-muted-foreground">Loading PDF...</p>
</div>
</div>
<!-- Toolbar (always visible) -->
<PdfToolbar
:current-page="currentPage"
:total-pages="totalPages"
:page-input="pageInput"
:zoom-label="zoomLabel"
:zoom-mode="zoomMode"
:custom-scale="customScale"
:scale="scale"
:spread="spread"
:scroll-mode="scrollMode"
:is-fullscreen="isFullscreen"
:show-sidebar="showSidebar"
:show-find="showFind"
:cursor-tool="cursorTool"
:file-id="props.fileId"
@back="router.back()"
@toggle-sidebar="showSidebar = !showSidebar"
@toggle-find="showFind ? closeFind() : openFind()"
@prev-page="goToPage(currentPage - 1)"
@next-page="goToPage(currentPage + 1)"
@first-page="goToPage(1)"
@last-page="goToPage(totalPages)"
@commit-page="onPageCommit"
@zoom-out="adjustZoom(-0.1)"
@zoom-in="adjustZoom(0.1)"
@apply-zoom-preset="applyZoomPreset"
@toggle-fullscreen="toggleFullscreen"
@update:spread="spread = $event"
@update:scroll-mode="scrollMode = $event"
@update:cursor-tool="cursorTool = $event"
/>
<!-- Find bar -->
<PdfFindBar
v-if="showFind"
ref="findBarRef"
:match-count="find.matchCount.value"
:current-index="find.currentIndex.value"
@close="closeFind()"
@search="onFindSearch"
@next="find.next()"
@prev="find.prev()"
@update:match-case="onFindMatchCase"
@update:whole-word="onFindWholeWord"
@update:highlight-all="find.highlightAll.value = $event"
/>
<!-- Body: sidebar + pages -->
<div class="flex-1 min-h-0 min-w-0 relative">
<!-- Mobile sidebar mask -->
<button
v-if="showSidebar"
class="absolute inset-0 z-10 bg-black/35 md:hidden"
aria-label="Close sidebar overlay"
@click="showSidebar = false"
/>
<!-- Sidebar -->
<PdfSidebar
v-if="showSidebar"
class="absolute left-0 top-0 bottom-0 z-20 shadow-xl"
:total-pages="totalPages"
:current-page="currentPage"
:outline="outline.outline.value"
:outline-loading="outline.loading.value"
@go-to-page="(page) => goToPage(page, 'instant')"
/>
<!-- PDF Viewport -->
<div class="h-full w-full min-w-0 relative">
<div v-if="error" class="absolute inset-0 flex items-center justify-center p-8 text-center bg-background">
<div>
<p class="text-sm font-medium text-foreground mb-1">Failed to load PDF</p>
<p class="text-xs text-muted-foreground">{{ error }}</p>
</div>
</div>
<div
v-show="!error"
ref="scrollRef"
class="absolute inset-0"
:class="cursorTool === 'hand' ? 'cursor-grab active:cursor-grabbing' : 'cursor-auto'"
:style="scrollStyle"
@scroll.passive="onScroll"
@mousedown="onScrollMouseDown"
>
<!-- Vertical / Wrapped / Page modes -->
<div
v-if="scrollMode !== 'horizontal'"
class="flex flex-col items-center"
:style="scrollMode === 'page' ? { gap: '0', padding: '0' } : { gap: `${PAGE_GAP}px`, padding: '16px 16px 32px' }"
>
<div
v-for="(row, ri) in pageRows"
:key="ri"
:data-pages="row.join(',')"
class="flex gap-1"
:style="
scrollMode === 'page'
? { height: `${containerH}px`, scrollSnapAlign: 'start', alignItems: 'center', justifyContent: 'center' }
: { height: `${rowHeights[ri] ?? 0}px`, alignItems: 'flex-start' }
"
>
<div
v-for="pageNum in row"
:key="pageNum"
class="relative bg-white shadow-xl overflow-hidden"
:style="{
width: `${Math.round((pageDims[pageNum - 1]?.width ?? 595) * scale)}px`,
height: `${Math.round((pageDims[pageNum - 1]?.height ?? 842) * scale)}px`,
}"
>
<canvas
class="block"
:ref="
(el) => {
if (el) canvasMap.set(pageNum, el as HTMLCanvasElement)
}
"
/>
<div
data-text-layer
class="absolute inset-0 overflow-hidden"
:class="cursorTool === 'select' ? 'select-text' : 'select-none pointer-events-none'"
:ref="
(el) => {
if (el) textLayerMap.set(pageNum, el as HTMLElement)
}
"
/>
</div>
</div>
</div>
<!-- Horizontal mode: single row, scrolls left-right -->
<div v-else class="flex flex-row items-center" :style="{ gap: `${PAGE_GAP}px`, padding: '16px', height: '100%' }">
<div
v-for="pageNum in totalPages"
:key="pageNum"
:data-pages="`${pageNum}`"
class="relative bg-white shadow-xl overflow-hidden shrink-0"
:style="{
width: `${Math.round((pageDims[pageNum - 1]?.width ?? 595) * scale)}px`,
height: `${Math.round((pageDims[pageNum - 1]?.height ?? 842) * scale)}px`,
}"
>
<canvas
class="block"
:ref="
(el) => {
if (el) canvasMap.set(pageNum, el as HTMLCanvasElement)
}
"
/>
<div
data-text-layer
class="absolute inset-0 overflow-hidden"
style="pointer-events: none"
:ref="
(el) => {
if (el) textLayerMap.set(pageNum, el as HTMLElement)
}
"
/>
</div>
</div>
</div>
<!-- Progress bar -->
<div v-if="readerReady && !error && totalPages > 0" class="absolute bottom-0 left-0 right-0 h-0.5 bg-border z-10">
<div class="h-full bg-primary/60 transition-all duration-500" :style="{ width: `${progressPct}%` }" />
</div>
</div>
</div>
</div>
</template>
@@ -1,100 +0,0 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { ChevronDown, ChevronUp, X } from 'lucide-vue-next'
defineProps<{
matchCount: number
currentIndex: number
}>()
const emit = defineEmits<{
close: []
search: [query: string]
next: []
prev: []
'update:matchCase': [v: boolean]
'update:wholeWord': [v: boolean]
'update:highlightAll': [v: boolean]
}>()
const query = ref('')
const matchCase = ref(false)
const wholeWord = ref(false)
const highlightAll = ref(true)
const inputRef = ref<HTMLInputElement | null>(null)
watch(query, (q) => emit('search', q))
watch(matchCase, () => emit('search', query.value))
watch(wholeWord, () => emit('search', query.value))
watch(matchCase, (v) => emit('update:matchCase', v))
watch(wholeWord, (v) => emit('update:wholeWord', v))
watch(highlightAll, (v) => emit('update:highlightAll', v))
defineExpose({
focus() {
nextTick(() => inputRef.value?.focus())
},
clear() {
query.value = ''
},
})
</script>
<template>
<div class="px-2.5 py-2 sm:px-3 sm:py-1.5 shrink-0 bg-card border-b border-border">
<div class="flex items-center gap-1.5 min-w-0">
<input
ref="inputRef"
v-model="query"
type="search"
placeholder="Find in document..."
class="flex-1 min-w-0 sm:max-w-[260px] bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/70"
@keydown.enter.exact="emit('next')"
@keydown.enter.shift.exact.prevent="emit('prev')"
@keydown.escape="emit('close')"
/>
<!-- Match count -->
<span class="text-xs text-muted-foreground tabular-nums text-center shrink-0 min-w-[52px]">
<template v-if="query && matchCount > 0">{{ currentIndex + 1 }} of {{ matchCount }}</template>
<template v-else-if="query && matchCount === 0">No results</template>
</span>
<!-- Prev / Next -->
<button class="viewer-btn w-7 h-7" :disabled="matchCount === 0" title="Previous match" @click="emit('prev')">
<ChevronUp :size="13" />
</button>
<button class="viewer-btn w-7 h-7" :disabled="matchCount === 0" title="Next match" @click="emit('next')">
<ChevronDown :size="13" />
</button>
<div class="hidden sm:block w-px h-4 bg-border mx-0.5 shrink-0" />
<button class="viewer-btn w-7 h-7" title="Close find bar" @click="emit('close')">
<X :size="13" />
</button>
</div>
<div class="mt-2 sm:mt-1 flex items-center gap-3 overflow-x-auto pb-0.5 sm:pb-0">
<label
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none transition-colors whitespace-nowrap"
>
<input v-model="matchCase" type="checkbox" class="w-3 h-3 shrink-0" style="accent-color: var(--primary)" />
Match Case
</label>
<label
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none transition-colors whitespace-nowrap"
>
<input v-model="wholeWord" type="checkbox" class="w-3 h-3 shrink-0" style="accent-color: var(--primary)" />
Whole Words
</label>
<label
class="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground cursor-pointer select-none transition-colors whitespace-nowrap"
>
<input v-model="highlightAll" type="checkbox" class="w-3 h-3 shrink-0" style="accent-color: var(--primary)" />
Highlight All
</label>
</div>
</div>
</template>
@@ -1,39 +0,0 @@
<script setup lang="ts">
import { ChevronDown, ChevronRight } from 'lucide-vue-next'
import type { OutlineItem } from '../composables/usePdfOutline'
import PdfOutlineTree from './OutlineTree.vue'
defineProps<{
items: OutlineItem[]
depth: number
}>()
const emit = defineEmits<{
goToPage: [page: number]
}>()
function toggle(item: OutlineItem) {
item.expanded = !item.expanded
}
</script>
<template>
<div>
<div v-for="(item, i) in items" :key="i">
<div
class="flex items-start gap-1 py-1 pr-2 rounded cursor-pointer hover:bg-muted/50 transition-colors text-xs text-foreground/70 hover:text-foreground"
:style="{ paddingLeft: 8 + depth * 12 + 'px' }"
@click="item.pageNum ? emit('goToPage', item.pageNum) : toggle(item)"
>
<button v-if="item.items.length" class="shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" @click.stop="toggle(item)">
<ChevronDown v-if="item.expanded" :size="11" />
<ChevronRight v-else :size="11" />
</button>
<span v-else class="w-2.75 shrink-0" />
<span class="leading-relaxed line-clamp-2">{{ item.title }}</span>
<span v-if="item.pageNum" class="ml-auto shrink-0 text-muted-foreground tabular-nums">{{ item.pageNum }}</span>
</div>
<PdfOutlineTree v-if="item.expanded && item.items.length" :items="item.items" :depth="depth + 1" @go-to-page="emit('goToPage', $event)" />
</div>
</div>
</template>
@@ -1,37 +0,0 @@
<script setup lang="ts">
import { List } from 'lucide-vue-next'
import PdfOutlineTree from './OutlineTree.vue'
import type { OutlineItem } from '../composables/usePdfOutline'
defineProps<{
totalPages: number
currentPage: number
outline: OutlineItem[]
outlineLoading: boolean
}>()
const emit = defineEmits<{
goToPage: [page: number]
}>()
</script>
<template>
<div class="flex flex-col shrink-0 overflow-hidden border-r border-border bg-card px-1.5 w-64">
<!-- Header -->
<div class="flex items-center gap-1.5 px-3 py-2.5 shrink-0 text-xs text-muted-foreground border-b border-border">
<List :size="12" />
Outline
</div>
<!-- Outline -->
<div class="flex-1 overflow-y-auto py-2">
<div v-if="outlineLoading" class="flex items-center justify-center h-16">
<div class="w-4 h-4 rounded-full border border-muted-foreground/30 border-t-muted-foreground animate-spin" />
</div>
<div v-else-if="!outline.length" class="px-4 py-6 text-xs text-muted-foreground text-center leading-relaxed">
No outline available for this document.
</div>
<PdfOutlineTree v-else :items="outline" :depth="0" @go-to-page="emit('goToPage', $event)" />
</div>
</div>
</template>
@@ -1,359 +0,0 @@
<script setup lang="ts">
import { type Component } from 'vue'
import {
ArrowLeft,
ArrowDownUp,
ArrowLeftRight,
ChevronDown,
ChevronFirst,
ChevronLast,
ChevronLeft,
ChevronRight,
Download,
Grab,
LayoutGrid,
Maximize,
Minimize,
Minus,
MoveHorizontal,
MoreHorizontal,
MousePointer,
PanelLeft,
PanelLeftOpen,
PanelRightOpen,
Plus,
Ratio,
Search,
ScanLine,
Square,
} from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { ScrollMode } from '../composables/usePdfLayout'
import type { AcceptableValue } from 'reka-ui'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const { hasPermission } = usePermissions()
const props = defineProps<{
currentPage: number
totalPages: number
pageInput: number
zoomLabel: string
zoomMode: string
customScale: number
scale: number
spread: 'none' | 'odd' | 'even'
scrollMode: ScrollMode
isFullscreen: boolean
showSidebar: boolean
showFind: boolean
cursorTool: 'select' | 'hand'
fileId: number
}>()
const emit = defineEmits<{
back: []
toggleSidebar: []
toggleFind: []
prevPage: []
nextPage: []
firstPage: []
lastPage: []
commitPage: [page: number]
zoomOut: []
zoomIn: []
applyZoomPreset: [value: string]
toggleFullscreen: []
'update:spread': [v: 'none' | 'odd' | 'even']
'update:scrollMode': [v: ScrollMode]
'update:cursorTool': [v: 'select' | 'hand']
}>()
const ZOOM_PERCENT_PRESETS = [
{ label: '50%', value: '0.5' },
{ label: '75%', value: '0.75' },
{ label: '100%', value: '1' },
{ label: '125%', value: '1.25' },
{ label: '150%', value: '1.5' },
{ label: '175%', value: '1.75' },
{ label: '200%', value: '2' },
{ label: '300%', value: '3' },
{ label: '400%', value: '4' },
] as const
const ZOOM_MODE_PRESETS: { label: string; value: 'fit-page' | 'fit-width'; icon: Component }[] = [
{ label: 'Page Fit', value: 'fit-page', icon: Ratio },
{ label: 'Page Width', value: 'fit-width', icon: MoveHorizontal },
]
const MOBILE_ZOOM_PERCENT_PRESETS = [
{ label: '75%', value: '0.75' },
{ label: '100%', value: '1' },
{ label: '125%', value: '1.25' },
{ label: '150%', value: '1.5' },
{ label: '200%', value: '2' },
] as const
const SCROLL_MODES: { label: string; value: ScrollMode; icon: Component }[] = [
{ label: 'Vertical Scrolling', value: 'vertical', icon: ArrowDownUp },
{ label: 'Horizontal Scrolling', value: 'horizontal', icon: ArrowLeftRight },
{ label: 'Wrapped Scrolling', value: 'wrapped', icon: LayoutGrid },
{ label: 'Page Scrolling', value: 'page', icon: ScanLine },
]
const SPREAD_MODES: { label: string; value: 'none' | 'odd' | 'even'; icon: Component }[] = [
{ label: 'No Spreads', value: 'none', icon: Square },
{ label: 'Odd Spreads', value: 'odd', icon: PanelLeftOpen },
{ label: 'Even Spreads', value: 'even', icon: PanelRightOpen },
]
function onPageInput(e: Event) {
const val = parseInt((e.target as HTMLInputElement).value)
if (!isNaN(val)) emit('commitPage', val)
}
function currentZoomValue(): string {
if (props.zoomMode === 'fit-page') return 'fit-page'
if (props.zoomMode === 'fit-width') return 'fit-width'
return String(props.customScale)
}
function handleApplyZoomPreset(value: AcceptableValue) {
emit('applyZoomPreset', String(value))
}
</script>
<template>
<div class="h-11 flex items-center px-2 gap-0.5 shrink-0 z-50 bg-background border-b border-border shadow-sm">
<!-- Back -->
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('back')"><ArrowLeft :size="15" /></button>
</TooltipTrigger>
<TooltipContent>Back</TooltipContent>
</Tooltip>
<div class="viewer-sep" />
<!-- Sidebar toggle -->
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :class="showSidebar ? '!bg-muted !text-foreground' : ''" @click="emit('toggleSidebar')">
<PanelLeft :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>Toggle Sidebar</TooltipContent>
</Tooltip>
<!-- Find toggle -->
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :class="showFind ? '!bg-muted !text-foreground' : ''" @click="emit('toggleFind')">
<Search :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>Find (Ctrl+F)</TooltipContent>
</Tooltip>
<div class="viewer-sep" />
<!-- Page navigation -->
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :disabled="currentPage <= 1" @click="emit('prevPage')">
<ChevronLeft :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>Previous Page</TooltipContent>
</Tooltip>
<div class="hidden md:flex items-center gap-1 mx-0.5">
<input
:value="pageInput"
type="number"
min="1"
:max="totalPages"
class="w-10 text-center rounded px-1 py-0.5 bg-muted text-foreground text-xs outline-none focus:ring-1 focus:ring-primary [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@keydown.enter="onPageInput"
@blur="onPageInput"
/>
<span class="text-muted-foreground text-xs">/</span>
<span class="text-muted-foreground text-xs tabular-nums">{{ totalPages }}</span>
</div>
<div class="md:hidden min-w-[3.6rem] text-center text-xs tabular-nums text-muted-foreground px-0.5">{{ currentPage }} / {{ totalPages }}</div>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :disabled="currentPage >= totalPages" @click="emit('nextPage')">
<ChevronRight :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>Next Page</TooltipContent>
</Tooltip>
<div class="viewer-sep hidden md:block" />
<!-- Zoom controls -->
<div class="hidden md:block">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('zoomOut')"><Minus :size="13" /></button>
</TooltipTrigger>
<TooltipContent>Zoom Out</TooltipContent>
</Tooltip>
</div>
<div class="hidden md:block">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="flex items-center gap-1 px-2 h-7 rounded text-foreground/80 hover:text-foreground hover:bg-muted text-xs tabular-nums transition-colors"
style="min-width: 84px"
>
<span class="flex-1 text-center">{{ zoomLabel }}</span>
<ChevronDown :size="10" class="text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" class="min-w-[9rem]">
<DropdownMenuRadioGroup :model-value="currentZoomValue()" @update:model-value="handleApplyZoomPreset">
<DropdownMenuRadioItem v-for="preset in ZOOM_MODE_PRESETS" :key="preset.value" :value="preset.value" class="text-xs gap-2">
<component :is="preset.icon" :size="13" />
{{ preset.label }}
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
<DropdownMenuRadioItem v-for="preset in ZOOM_PERCENT_PRESETS" :key="preset.value" :value="preset.value" class="text-xs">
{{ preset.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div class="hidden md:block">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('zoomIn')"><Plus :size="13" /></button>
</TooltipTrigger>
<TooltipContent>Zoom In</TooltipContent>
</Tooltip>
</div>
<!-- Spacer -->
<div class="flex-1" />
<!-- Right side controls -->
<div class="hidden md:block">
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" @click="emit('toggleFullscreen')">
<Minimize v-if="isFullscreen" :size="14" />
<Maximize v-else :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ isFullscreen ? 'Exit Fullscreen' : 'Fullscreen' }}</TooltipContent>
</Tooltip>
</div>
<div v-if="hasPermission('library_download')" class="hidden md:block">
<Tooltip>
<TooltipTrigger as-child>
<a :href="`/api/v1/books/files/${fileId}/download`" class="viewer-btn flex items-center justify-center">
<Download :size="14" />
</a>
</TooltipTrigger>
<TooltipContent>Download</TooltipContent>
</Tooltip>
</div>
<div class="viewer-sep hidden md:block" />
<!-- Secondary toolbar (More tools) -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="viewer-btn" title="More Tools">
<MoreHorizontal :size="16" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-52 max-h-[72vh] overflow-y-auto">
<div class="md:hidden">
<DropdownMenuGroup>
<DropdownMenuItem class="text-xs gap-2" @click="emit('zoomOut')"> <Minus :size="13" /> Zoom Out </DropdownMenuItem>
<DropdownMenuItem class="text-xs gap-2" @click="emit('zoomIn')"> <Plus :size="13" /> Zoom In </DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Zoom</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="currentZoomValue()" @update:model-value="handleApplyZoomPreset">
<DropdownMenuRadioItem v-for="preset in ZOOM_MODE_PRESETS" :key="`mobile-${preset.value}`" :value="preset.value" class="text-xs gap-2">
<component :is="preset.icon" :size="13" />
{{ preset.label }}
</DropdownMenuRadioItem>
<DropdownMenuSeparator />
<DropdownMenuRadioItem
v-for="preset in MOBILE_ZOOM_PERCENT_PRESETS"
:key="`mobile-${preset.value}`"
:value="preset.value"
class="text-xs"
>
{{ preset.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem class="text-xs gap-2" @click="emit('toggleFullscreen')">
<Minimize v-if="isFullscreen" :size="13" />
<Maximize v-else :size="13" />
{{ isFullscreen ? 'Exit Fullscreen' : 'Fullscreen' }}
</DropdownMenuItem>
<DropdownMenuItem v-if="hasPermission('library_download')" as-child class="text-xs gap-2">
<a :href="`/api/v1/books/files/${fileId}/download`"> <Download :size="13" /> Download </a>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</div>
<DropdownMenuGroup>
<DropdownMenuItem class="text-xs gap-2" @click="emit('firstPage')"> <ChevronFirst :size="13" /> First Page </DropdownMenuItem>
<DropdownMenuItem class="text-xs gap-2" @click="emit('lastPage')"> <ChevronLast :size="13" /> Last Page </DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Cursor Tool</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="cursorTool" @update:model-value="emit('update:cursorTool', $event as 'select' | 'hand')">
<DropdownMenuRadioItem value="select" class="text-xs gap-2"> <MousePointer :size="13" /> Text Selection </DropdownMenuRadioItem>
<DropdownMenuRadioItem value="hand" class="text-xs gap-2"> <Grab :size="13" /> Hand Tool </DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Scroll Mode</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="scrollMode" @update:model-value="emit('update:scrollMode', $event as ScrollMode)">
<DropdownMenuRadioItem v-for="sm in SCROLL_MODES" :key="sm.value" :value="sm.value" class="text-xs gap-2">
<component :is="sm.icon" :size="13" />
{{ sm.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel class="text-muted-foreground text-xs px-2 py-1">Page Spread</DropdownMenuLabel>
<DropdownMenuRadioGroup :model-value="spread" @update:model-value="emit('update:spread', $event as 'none' | 'odd' | 'even')">
<DropdownMenuRadioItem v-for="sp in SPREAD_MODES" :key="sp.value" :value="sp.value" class="text-xs gap-2">
<component :is="sp.icon" :size="13" />
{{ sp.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
@@ -1,112 +0,0 @@
import { onUnmounted, shallowRef, ref } from 'vue'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
export interface PageDim {
width: number
height: number
}
export interface CancellableRender {
promise: Promise<void>
cancel: () => void
}
export function usePdf() {
// shallowRef is critical — Vue's deep proxy breaks PDF.js internal state
const pdfDoc = shallowRef<pdfjsLib.PDFDocumentProxy | null>(null)
const totalPages = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
async function load(fileId: number): Promise<void> {
loading.value = true
error.value = null
try {
const doc = await pdfjsLib.getDocument({
url: `/api/v1/books/files/${fileId}/serve`,
rangeChunkSize: 524288,
disableStream: true,
disableAutoFetch: true,
}).promise
pdfDoc.value = doc
totalPages.value = doc.numPages
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load PDF'
} finally {
loading.value = false
}
}
async function getPageDim(pageNum: number): Promise<PageDim> {
const doc = pdfDoc.value
if (!doc) return { width: 595, height: 842 }
const page = await doc.getPage(pageNum)
const vp = page.getViewport({ scale: 1 })
page.cleanup()
return { width: vp.width, height: vp.height }
}
function startRenderPage(pageNum: number, canvas: HTMLCanvasElement, scale: number): CancellableRender {
let renderTask: pdfjsLib.RenderTask | null = null
let cancelled = false
const promise = (async () => {
const doc = pdfDoc.value
if (!doc || cancelled) return
const page = await doc.getPage(pageNum)
if (cancelled) {
page.cleanup()
return
}
const dpr = window.devicePixelRatio || 1
const viewport = page.getViewport({ scale: scale * dpr })
const renderCanvas = document.createElement('canvas')
renderCanvas.width = viewport.width
renderCanvas.height = viewport.height
renderTask = page.render({ canvas: renderCanvas, viewport })
try {
await renderTask.promise
if (cancelled) return
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = viewport.width
canvas.height = viewport.height
canvas.style.width = `${Math.round(viewport.width / dpr)}px`
canvas.style.height = `${Math.round(viewport.height / dpr)}px`
ctx.drawImage(renderCanvas, 0, 0)
} catch {
// cancelled or interrupted — not an error
} finally {
page.cleanup()
}
})()
return {
promise,
cancel() {
cancelled = true
renderTask?.cancel()
},
}
}
async function getTextContent(pageNum: number) {
const doc = pdfDoc.value
if (!doc) return null
const page = await doc.getPage(pageNum)
const content = await page.getTextContent()
const viewport = page.getViewport({ scale: 1 })
page.cleanup()
return { content, viewport }
}
onUnmounted(() => {
pdfDoc.value?.destroy()
pdfDoc.value = null
})
return { pdfDoc, totalPages, loading, error, load, getPageDim, startRenderPage, getTextContent }
}
@@ -1,108 +0,0 @@
import { ref } from 'vue'
import type { Ref } from 'vue'
export interface PdfFindState {
query: string
matchCase: boolean
wholeWord: boolean
highlightAll: boolean
matchCount: number
currentIndex: number
}
const MATCH_CLASS = 'pdf-find-match'
const CURRENT_CLASS = 'pdf-find-match-current'
export function usePdfFind(textLayerMap: Ref<Map<number, HTMLElement>>) {
const query = ref('')
const matchCase = ref(false)
const wholeWord = ref(false)
const highlightAll = ref(true)
const matchCount = ref(0)
const currentIndex = ref(0)
let matches: HTMLElement[] = []
function normalize(s: string) {
return matchCase.value ? s : s.toLowerCase()
}
function isWordBoundary(text: string, start: number, len: number): boolean {
const before = start === 0 ? ' ' : (text[start - 1] ?? ' ')
const after = start + len >= text.length ? ' ' : (text[start + len] ?? ' ')
return /\W/.test(before) && /\W/.test(after)
}
function clearHighlights() {
for (const el of matches) {
el.classList.remove(MATCH_CLASS, CURRENT_CLASS)
}
matches = []
}
function search() {
clearHighlights()
const q = normalize(query.value.trim())
if (!q) {
matchCount.value = 0
currentIndex.value = 0
return
}
const found: HTMLElement[] = []
for (const [, layer] of textLayerMap.value) {
const spans = Array.from(layer.querySelectorAll('span')) as HTMLElement[]
for (const span of spans) {
const text = normalize(span.textContent ?? '')
if (!text.includes(q)) continue
if (wholeWord.value && !isWordBoundary(text, text.indexOf(q), q.length)) continue
found.push(span)
}
}
matches = found
matchCount.value = found.length
if (found.length === 0) {
currentIndex.value = 0
return
}
currentIndex.value = Math.min(currentIndex.value, found.length - 1)
for (const el of found) {
el.classList.add(MATCH_CLASS)
}
scrollToMatch(currentIndex.value)
}
function scrollToMatch(idx: number) {
for (const el of matches) el.classList.remove(CURRENT_CLASS)
const el = matches[idx]
if (!el) return
el.classList.add(CURRENT_CLASS)
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
function next() {
if (matchCount.value === 0) return
currentIndex.value = (currentIndex.value + 1) % matchCount.value
scrollToMatch(currentIndex.value)
}
function prev() {
if (matchCount.value === 0) return
currentIndex.value = (currentIndex.value - 1 + matchCount.value) % matchCount.value
scrollToMatch(currentIndex.value)
}
function clear() {
clearHighlights()
query.value = ''
matchCount.value = 0
currentIndex.value = 0
}
return { query, matchCase, wholeWord, highlightAll, matchCount, currentIndex, search, next, prev, clear }
}
@@ -1,122 +0,0 @@
import { computed, ref, watch } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { PageDim } from './usePdf'
export const PAGE_GAP = 16
export type ScrollMode = 'vertical' | 'horizontal' | 'wrapped' | 'page'
export function usePdfLayout(
scrollRef: Ref<HTMLElement | null>,
totalPages: Ref<number>,
effectiveDims: ComputedRef<PageDim[]>,
scale: ComputedRef<number>,
containerH: Ref<number>,
spread: Ref<'none' | 'odd' | 'even'>,
) {
const scrollMode = ref<ScrollMode>('vertical')
const currentPage = ref(1)
const pageInput = ref(1)
const pageRows = computed<number[][]>(() => {
const rows: number[][] = []
const total = totalPages.value
if (spread.value === 'none') {
for (let i = 1; i <= total; i++) rows.push([i])
return rows
}
let i = 0
if (spread.value === 'odd' && total >= 1) {
rows.push([1])
i = 2
}
while (i < total) {
const a = i + 1
const b = i + 2
rows.push(b <= total ? [a, b] : [a])
i += 2
}
return rows
})
const rowHeights = computed(() => {
if (scrollMode.value === 'page') return pageRows.value.map(() => containerH.value)
return pageRows.value.map((row) => Math.max(...row.map((n) => Math.round((effectiveDims.value[n - 1]?.height ?? 842) * scale.value))))
})
// Cumulative horizontal offset per page for horizontal scroll mode
const pageLeftOffsets = computed(() => {
const offsets: number[] = []
let x = PAGE_GAP
for (let i = 0; i < totalPages.value; i++) {
offsets.push(x)
x += Math.round((effectiveDims.value[i]?.width ?? 595) * scale.value) + PAGE_GAP
}
return offsets
})
watch(currentPage, (v) => {
pageInput.value = v
})
function goToPage(n: number, behavior: ScrollBehavior = 'smooth') {
const el = scrollRef.value
if (!el) return
const clamped = Math.max(1, Math.min(n, totalPages.value))
if (scrollMode.value === 'horizontal') {
const left = pageLeftOffsets.value[clamped - 1] ?? 0
el.scrollTo({ left, behavior })
return
}
const rowIdx = pageRows.value.findIndex((row) => row.includes(clamped))
if (rowIdx < 0) return
let top = 0
if (scrollMode.value === 'page') {
top = rowIdx * containerH.value
} else {
for (let i = 0; i < rowIdx; i++) top += (rowHeights.value[i] ?? 0) + PAGE_GAP
top += PAGE_GAP // initial top padding
}
el.scrollTo({ top, behavior })
}
function onScroll() {
const el = scrollRef.value
if (!el) return
if (scrollMode.value === 'horizontal') {
const scrollX = el.scrollLeft
const offsets = pageLeftOffsets.value
let page = 1
for (let i = 0; i < offsets.length; i++) {
if (scrollX >= (offsets[i] ?? 0) - PAGE_GAP / 2) page = i + 1
else break
}
currentPage.value = page
return
}
if (!rowHeights.value.length) return
let rowIdx = 0
if (scrollMode.value === 'page') {
rowIdx = Math.round(el.scrollTop / containerH.value)
} else {
let offset = el.scrollTop - PAGE_GAP
for (let i = 0; i < rowHeights.value.length; i++) {
const h = (rowHeights.value[i] ?? 0) + PAGE_GAP
if (offset < h) {
rowIdx = i
break
}
offset -= h
rowIdx = i + 1
}
}
const row = pageRows.value[rowIdx]
currentPage.value = row?.[0] ?? totalPages.value
}
return { scrollMode, currentPage, pageInput, pageRows, rowHeights, pageLeftOffsets, goToPage, onScroll }
}
@@ -1,64 +0,0 @@
import { ref } from 'vue'
import type * as pdfjsLib from 'pdfjs-dist'
import type { ShallowRef } from 'vue'
export interface OutlineItem {
title: string
dest: string | unknown[] | null
items: OutlineItem[]
pageNum: number | null
expanded: boolean
}
type PdfPageRef = Parameters<pdfjsLib.PDFDocumentProxy['getPageIndex']>[0]
type PdfOutline = NonNullable<Awaited<ReturnType<pdfjsLib.PDFDocumentProxy['getOutline']>>>
export function usePdfOutline(pdfDoc: ShallowRef<pdfjsLib.PDFDocumentProxy | null>) {
const outline = ref<OutlineItem[]>([])
const loading = ref(false)
async function resolvePageNum(doc: pdfjsLib.PDFDocumentProxy, dest: string | unknown[] | null): Promise<number | null> {
if (!dest) return null
try {
const resolved = typeof dest === 'string' ? await doc.getDestination(dest) : (dest as unknown[])
if (!resolved || !resolved[0]) return null
const pageIndex = await doc.getPageIndex(resolved[0] as PdfPageRef)
return pageIndex + 1
} catch {
return null
}
}
async function buildTree(doc: pdfjsLib.PDFDocumentProxy, raw: PdfOutline): Promise<OutlineItem[]> {
const items: OutlineItem[] = []
for (const node of raw) {
const pageNum = await resolvePageNum(doc, node.dest)
items.push({
title: node.title ?? '(untitled)',
dest: node.dest,
pageNum,
expanded: false,
items: node.items?.length ? await buildTree(doc, node.items) : [],
})
}
return items
}
async function load(): Promise<void> {
const doc = pdfDoc.value
if (!doc) return
loading.value = true
try {
const raw = await doc.getOutline()
outline.value = raw ? await buildTree(doc, raw) : []
} finally {
loading.value = false
}
}
function toggleExpand(item: OutlineItem) {
item.expanded = !item.expanded
}
return { outline, loading, load, toggleExpand }
}
@@ -1,158 +0,0 @@
import { nextTick, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { CancellableRender, PageDim } from './usePdf'
interface PdfTextItem {
str: string
transform: number[]
height?: number
}
interface PdfViewport {
width: number
height: number
clone(opts: { scale: number; rotation: number }): PdfViewport
}
interface PdfTextContent {
items: unknown[]
}
type StartRenderPageFn = (pageNum: number, canvas: HTMLCanvasElement, scale: number) => CancellableRender
type GetTextContentFn = (pageNum: number) => Promise<{ content: PdfTextContent; viewport: PdfViewport } | null>
type OnDimUpdateFn = (pageNum: number, dim: PageDim) => void
export function usePdfRenderer(
startRenderPageFn: StartRenderPageFn,
getTextContentFn: GetTextContentFn,
scale: ComputedRef<number>,
totalPages: Ref<number>,
onDimUpdate: OnDimUpdateFn,
) {
const canvasMap = ref(new Map<number, HTMLCanvasElement>())
const textLayerMap = ref(new Map<number, HTMLElement>())
const rendered = new Set<number>()
// Tracks in-flight renders so they can be cancelled before a new one starts on the same canvas.
const activeRenders = new Map<number, CancellableRender>()
let renderQueue: number[] = []
let isRendering = false
let ioEntries: IntersectionObserverEntry[] = []
let io: IntersectionObserver | null = null
function enqueue(pageNum: number) {
if (rendered.has(pageNum) || renderQueue.includes(pageNum)) return
renderQueue.push(pageNum)
flushQueue()
}
async function renderPage(pageNum: number) {
if (pageNum < 1 || pageNum > totalPages.value || rendered.has(pageNum)) return
renderQueue = [pageNum, ...renderQueue.filter((n) => n !== pageNum)]
await flushQueue()
}
async function flushQueue() {
if (isRendering) return
isRendering = true
while (renderQueue.length > 0) {
const pageNum = renderQueue.shift()!
const canvas = canvasMap.value.get(pageNum)
if (!canvas) continue
// Cancel any prior render on this canvas before starting a new one.
activeRenders.get(pageNum)?.cancel()
const renderScale = scale.value
const task = startRenderPageFn(pageNum, canvas, renderScale)
activeRenders.set(pageNum, task)
await task.promise
// Only proceed with post-render steps if this task wasn't superseded.
if (activeRenders.get(pageNum) !== task) continue
activeRenders.delete(pageNum)
const dpr = window.devicePixelRatio || 1
onDimUpdate(pageNum, { width: canvas.width / (renderScale * dpr), height: canvas.height / (renderScale * dpr) })
rendered.add(pageNum)
await buildTextLayer(pageNum)
}
isRendering = false
}
async function buildTextLayer(pageNum: number) {
const container = textLayerMap.value.get(pageNum)
if (!container) return
const result = await getTextContentFn(pageNum)
if (!result) return
const { content, viewport } = result
const scaledVp = viewport.clone({ scale: scale.value, rotation: 0 })
container.innerHTML = ''
container.style.width = `${scaledVp.width}px`
container.style.height = `${scaledVp.height}px`
const s = scale.value
for (const item of content.items) {
if (typeof item !== 'object' || item === null || !('str' in item)) continue
const { str, transform, height } = item as PdfTextItem
if (!str.trim()) continue
const [a, b, , , e, f] = transform as [number, number, number, number, number, number]
const span = document.createElement('span')
span.textContent = str
span.style.cssText =
`position:absolute;` +
`left:${e * s}px;` +
`top:${scaledVp.height - f * s - (height ?? 0) * s}px;` +
`font-size:${Math.abs(a * s) || Math.abs(b * s)}px;` +
`transform-origin:0% 0%;white-space:pre;color:transparent;cursor:text;`
container.appendChild(span)
}
}
function ioCallback(entries: IntersectionObserverEntry[]) {
for (const e of entries) {
if (!e.isIntersecting) continue
const nums = (e.target as HTMLElement).dataset.pages?.split(',').map(Number) ?? []
for (const n of nums) {
enqueue(n)
if (n > 1) enqueue(n - 1)
if (n < totalPages.value) enqueue(n + 1)
}
}
ioEntries = entries
}
// Clear render state and re-trigger visible pages — call after zoom, rotation, or resize.
function invalidate() {
// Cancel all in-flight renders so their canvases are free immediately.
for (const task of activeRenders.values()) task.cancel()
activeRenders.clear()
rendered.clear()
renderQueue = []
isRendering = false
nextTick(() => ioEntries.forEach((e) => ioCallback([e])))
}
function setupIO(scrollEl: HTMLElement) {
io?.disconnect()
io = new IntersectionObserver(ioCallback, { root: scrollEl, rootMargin: '300px', threshold: 0 })
nextTick(() => scrollEl.querySelectorAll('[data-pages]').forEach((el) => io!.observe(el)))
}
// Call when a new document is loaded to clear stale canvas/text-layer refs.
function reset() {
for (const task of activeRenders.values()) task.cancel()
activeRenders.clear()
canvasMap.value = new Map()
textLayerMap.value = new Map()
rendered.clear()
renderQueue = []
isRendering = false
}
function destroy() {
io?.disconnect()
for (const task of activeRenders.values()) task.cancel()
}
return { canvasMap, textLayerMap, invalidate, renderPage, setupIO, reset, destroy }
}
@@ -1,64 +0,0 @@
import { computed, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import type { PageDim } from './usePdf'
export type ZoomMode = 'fit-width' | 'fit-page' | 'custom'
export const ZOOM_PRESETS = [
{ label: '50%', value: '0.5' },
{ label: '75%', value: '0.75' },
{ label: '100%', value: '1' },
{ label: '125%', value: '1.25' },
{ label: '150%', value: '1.5' },
{ label: '200%', value: '2' },
] as const
// Horizontal gap between paired pages in spread mode
const SPREAD_GAP = 16
export function usePdfZoom(
containerW: Ref<number>,
containerH: Ref<number>,
effectiveDims: ComputedRef<PageDim[]>,
spread: Ref<'none' | 'odd' | 'even'>,
) {
const zoomMode = ref<ZoomMode>('fit-page')
const customScale = ref(1.0)
const scale = computed(() => {
const first = effectiveDims.value[0]
if (!first || !containerW.value) return 1
if (spread.value !== 'none') {
const sw = (containerW.value - 48) / (first.width * 2 + SPREAD_GAP)
if (zoomMode.value === 'fit-page') return Math.min(sw, (containerH.value - 48) / first.height)
if (zoomMode.value === 'fit-width') return sw
}
if (zoomMode.value === 'fit-width') return (containerW.value - 32) / first.width
if (zoomMode.value === 'fit-page') return Math.min((containerW.value - 32) / first.width, (containerH.value - 48) / first.height)
return customScale.value
})
const zoomPct = computed(() => Math.round(scale.value * 100))
const zoomLabel = computed(() => {
if (zoomMode.value === 'fit-width') return 'Fit Width'
if (zoomMode.value === 'fit-page') return 'Fit Page'
return `${zoomPct.value}%`
})
function adjustZoom(delta: number) {
customScale.value = Math.round(Math.max(0.25, Math.min(4, (zoomMode.value === 'custom' ? customScale.value : scale.value) + delta)) * 20) / 20
zoomMode.value = 'custom'
}
function applyZoomPreset(value: string) {
if (value === 'fit-width' || value === 'fit-page') {
zoomMode.value = value
} else {
customScale.value = parseFloat(value)
zoomMode.value = 'custom'
}
}
return { zoomMode, customScale, scale, zoomPct, zoomLabel, adjustZoom, applyZoomPreset }
}
+10341 -4906
View File
File diff suppressed because it is too large Load Diff