mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Generated
+10341
-4906
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user