diff --git a/client/package.json b/client/package.json index afe979b47..27d8b4ea8 100644 --- a/client/package.json +++ b/client/package.json @@ -36,7 +36,26 @@ }, "dependencies": { "@bookorbit/types": "workspace:*", - "@embedpdf/vue-pdf-viewer": "^2.14.4", + "@embedpdf/core": "2.14.4", + "@embedpdf/engines": "2.14.4", + "@embedpdf/models": "2.14.4", + "@embedpdf/pdfium": "2.14.4", + "@embedpdf/plugin-annotation": "2.14.4", + "@embedpdf/plugin-bookmark": "2.14.4", + "@embedpdf/plugin-document-manager": "2.14.4", + "@embedpdf/plugin-history": "2.14.4", + "@embedpdf/plugin-interaction-manager": "2.14.4", + "@embedpdf/plugin-pan": "2.14.4", + "@embedpdf/plugin-render": "2.14.4", + "@embedpdf/plugin-rotate": "2.14.4", + "@embedpdf/plugin-scroll": "2.14.4", + "@embedpdf/plugin-search": "2.14.4", + "@embedpdf/plugin-selection": "2.14.4", + "@embedpdf/plugin-spread": "2.14.4", + "@embedpdf/plugin-thumbnail": "2.14.4", + "@embedpdf/plugin-tiling": "2.14.4", + "@embedpdf/plugin-viewport": "2.14.4", + "@embedpdf/plugin-zoom": "2.14.4", "@fontsource-variable/fraunces": "^5.2.9", "@fontsource-variable/inter": "^5.2.8", "@lucide/vue": "1.24.0", diff --git a/client/src/assets/main.css b/client/src/assets/main.css index f17ba4145..4872462e5 100644 --- a/client/src/assets/main.css +++ b/client/src/assets/main.css @@ -353,16 +353,6 @@ } @layer components { - .pdf-find-match { - background: rgba(255, 220, 0, 0.38); - border-radius: 1px; - } - .pdf-find-match-current { - background: rgba(255, 140, 0, 0.55); - outline: 1px solid rgba(255, 140, 0, 0.85); - border-radius: 1px; - } - .viewer-btn { @apply flex items-center justify-center w-8 h-8 rounded text-foreground/60 hover:text-foreground hover:bg-muted transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed; } diff --git a/client/src/features/reader/ReaderView.vue b/client/src/features/reader/ReaderView.vue index efdb55d67..f2ccf0764 100644 --- a/client/src/features/reader/ReaderView.vue +++ b/client/src/features/reader/ReaderView.vue @@ -1,5 +1,5 @@ diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfDocumentViewport.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfDocumentViewport.spec.ts new file mode 100644 index 000000000..b88fdcc6d --- /dev/null +++ b/client/src/features/reader/pdf-v4/__tests__/PdfDocumentViewport.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import PdfDocumentViewport from '../components/PdfDocumentViewport.vue' + +const passthroughStub = (testId: string) => + defineComponent({ + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h('div', { ...attrs, 'data-testid': testId }, slots.default?.()) + }, + }) + +const layerStub = (testId: string) => + defineComponent({ + inheritAttrs: false, + setup(_, { attrs }) { + return () => h('div', { ...attrs, 'data-testid': testId }) + }, + }) + +describe('PdfDocumentViewport', () => { + it('keeps rotation outside the sized pointer layer so page backdrops fill at every zoom', () => { + const wrapper = mount(PdfDocumentViewport, { + props: { documentId: 'doc-1' }, + global: { + stubs: { + GlobalPointerProvider: passthroughStub('global-pointer'), + Viewport: passthroughStub('viewport'), + ZoomGestureWrapper: passthroughStub('zoom-wrapper'), + Scroller: defineComponent({ + setup(_, { slots }) { + return () => h('div', { 'data-testid': 'scroller' }, slots.default?.({ page: { pageIndex: 3 } })) + }, + }), + Rotate: passthroughStub('rotate'), + PagePointerProvider: passthroughStub('page-pointer'), + RenderLayer: layerStub('render'), + TilingLayer: layerStub('tiling'), + SearchLayer: layerStub('search'), + SelectionLayer: layerStub('selection'), + AnnotationLayer: layerStub('annotation'), + }, + }, + }) + + const rotate = wrapper.get('[data-testid="rotate"]') + const pointer = rotate.get('[data-testid="page-pointer"]') + + expect(pointer.get('[data-testid="render"]').attributes('scale')).toBe('0.5') + expect(pointer.find('[data-testid="tiling"]').exists()).toBe(true) + expect(pointer.get('[data-testid="search"]').attributes('highlight-color')).toContain('var(--primary)') + }) +}) diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfReaderSettingsPanel.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfReaderSettingsPanel.spec.ts new file mode 100644 index 000000000..904f1a609 --- /dev/null +++ b/client/src/features/reader/pdf-v4/__tests__/PdfReaderSettingsPanel.spec.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import PdfReaderSettingsPanel from '../components/PdfReaderSettingsPanel.vue' + +function mountPanel() { + return mount(PdfReaderSettingsPanel, { + props: { + scrollMode: 'vertical', + spread: 'none', + rotation: 0, + zoomMode: 'fit-page', + customScale: 1, + }, + }) +} + +describe('PdfReaderSettingsPanel', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('debounces zoom previews and persists only the committed slider value', async () => { + vi.useFakeTimers() + const wrapper = mountPanel() + const slider = wrapper.get('input[type="range"]') + const element = slider.element as HTMLInputElement + + element.value = '1.5' + await slider.trigger('input') + element.value = '1.75' + await slider.trigger('input') + + await vi.advanceTimersByTimeAsync(99) + expect(wrapper.emitted('previewZoom')).toBeUndefined() + + await vi.advanceTimersByTimeAsync(1) + expect(wrapper.emitted('previewZoom')).toEqual([[1.75]]) + expect(wrapper.emitted('setZoom')).toBeUndefined() + + element.value = '2' + await slider.trigger('input') + await slider.trigger('change') + await vi.runAllTimersAsync() + + expect(wrapper.emitted('previewZoom')).toEqual([[1.75]]) + expect(wrapper.emitted('setZoom')).toEqual([['custom', 2]]) + }) + + it('exposes page, horizontal, automatic zoom, and auto spread choices', async () => { + const wrapper = mountPanel() + const buttons = wrapper.findAll('button') + const clickLabel = async (label: string) => buttons.find((button) => button.text() === label)!.trigger('click') + + await clickLabel('Page') + await clickLabel('Horizontal scroll') + await clickLabel('Automatic') + await clickLabel('Auto') + + expect(wrapper.emitted('setScrollMode')).toEqual([['page'], ['horizontal']]) + expect(wrapper.emitted('setZoom')).toEqual([['automatic']]) + expect(wrapper.emitted('setSpread')).toEqual([['auto']]) + }) +}) diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfReaderSidebar.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfReaderSidebar.spec.ts new file mode 100644 index 000000000..34f611cb0 --- /dev/null +++ b/client/src/features/reader/pdf-v4/__tests__/PdfReaderSidebar.spec.ts @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { MatchFlag, type SearchResult } from '@embedpdf/models' + +const mocks = vi.hoisted(() => ({ + searchState: { + __v_isRef: true, + value: { + flags: [] as MatchFlag[], + results: [] as SearchResult[], + total: 0, + activeResultIndex: -1, + query: '', + loading: false, + active: false, + }, + }, + searchScope: { + searchAllPages: vi.fn<(query: string) => { onProgress: (callback: (progress: { page: number }) => void) => void } | undefined>(), + stopSearch: vi.fn<() => void>(), + setFlags: vi.fn<(flags: MatchFlag[]) => void>(), + goToResult: vi.fn<(index: number) => void>(), + }, + scrollScope: { + scrollToPage: vi.fn<(options: Record) => void>(), + }, +})) + +vi.mock('@embedpdf/plugin-search/vue', async () => { + const { defineComponent, h } = await import('vue') + return { + SearchLayer: defineComponent({ + setup: + (_, { slots }) => + () => + h('div', slots.default?.()), + }), + useSearch: () => ({ state: mocks.searchState, provides: { value: mocks.searchScope } }), + } +}) + +vi.mock('@embedpdf/plugin-scroll/vue', () => ({ + useScroll: () => ({ + state: { __v_isRef: true, value: { currentPage: 1, totalPages: 10 } }, + provides: { value: mocks.scrollScope }, + }), +})) + +vi.mock('@embedpdf/plugin-bookmark/vue', () => ({ + useBookmarkCapability: () => ({ provides: { value: null } }), +})) + +vi.mock('@embedpdf/plugin-annotation/vue', () => ({ + useAnnotationCapability: () => ({ provides: { value: null } }), +})) + +vi.mock('@embedpdf/plugin-thumbnail/vue', async () => { + const { defineComponent, h } = await import('vue') + return { + ThumbImg: defineComponent({ setup: () => () => h('img') }), + ThumbnailsPane: defineComponent({ + setup: + (_, { slots }) => + () => + h('div', slots.default?.()), + }), + } +}) + +vi.mock('vue-virtual-scroller', async () => { + const { defineComponent, h } = await import('vue') + return { + RecycleScroller: defineComponent({ + inheritAttrs: false, + props: { items: { type: Array, required: true } }, + setup(props, { attrs, slots }) { + return () => + h( + 'div', + { ...attrs, 'data-testid': 'search-results' }, + props.items.map((item, index) => slots.default?.({ item, index })), + ) + }, + }), + } +}) + +import PdfReaderSidebar from '../components/PdfReaderSidebar.vue' + +function makeResult(rects: SearchResult['rects'] = []): SearchResult { + return { + pageIndex: 4, + charIndex: 10, + charCount: 4, + rects, + context: { + before: 'before', + match: 'term', + after: 'after', + truncatedLeft: false, + truncatedRight: false, + }, + } +} + +describe('PdfReaderSidebar', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mocks.searchScope.searchAllPages.mockReset() + mocks.searchState.value = { + flags: [], + results: [], + total: 0, + activeResultIndex: -1, + query: '', + loading: false, + active: false, + } + }) + + it('debounces full-document searches and cancels superseded work', async () => { + const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } }) + const input = wrapper.get('input[type="search"]') + mocks.searchScope.stopSearch.mockClear() + + await input.setValue('dis') + await input.setValue('distance') + + expect(mocks.searchScope.stopSearch).toHaveBeenCalledTimes(2) + expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(249) + expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(mocks.searchScope.searchAllPages).toHaveBeenCalledTimes(1) + expect(mocks.searchScope.searchAllPages).toHaveBeenCalledWith('distance') + }) + + it('does not search for a single character', async () => { + const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } }) + mocks.searchScope.stopSearch.mockClear() + + await wrapper.get('input[type="search"]').setValue('a') + await vi.runAllTimersAsync() + + expect(mocks.searchScope.stopSearch).toHaveBeenCalledOnce() + expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('Enter at least 2 characters') + }) + + it('lets the plugin restart an active search once when flags change', async () => { + const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } }) + + await wrapper.get('input[type="checkbox"]').setValue(true) + + expect(mocks.searchScope.setFlags).toHaveBeenCalledWith([MatchFlag.MatchCase]) + expect(mocks.searchScope.searchAllPages).not.toHaveBeenCalled() + }) + + it('keeps virtual results bounded and navigates results without rectangles safely', async () => { + mocks.searchState.value = { + ...mocks.searchState.value, + results: [makeResult()], + total: 1, + active: true, + } + const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } }) + + expect(wrapper.get('aside').classes()).toContain('overflow-hidden') + expect(wrapper.get('[data-testid="search-results"]').classes()).toContain('overflow-x-hidden') + + await wrapper.get('[data-testid="search-results"] button').trigger('click') + + expect(mocks.searchScope.goToResult).toHaveBeenCalledWith(0) + expect(mocks.scrollScope.scrollToPage).toHaveBeenCalledWith({ pageNumber: 5 }) + }) + + it('reports full-document search progress', async () => { + mocks.searchState.value = { + ...mocks.searchState.value, + query: 'distance', + loading: true, + } + mocks.searchScope.searchAllPages.mockReturnValue({ + onProgress: (callback: (progress: { page: number }) => void) => callback({ page: 4 }), + }) + + const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } }) + await vi.advanceTimersByTimeAsync(250) + + expect(wrapper.text()).toContain('Searching page 5 of 10') + }) + + it('uses Enter and Shift+Enter to navigate search results', async () => { + mocks.searchState.value = { + ...mocks.searchState.value, + results: [makeResult(), makeResult()], + total: 2, + activeResultIndex: 0, + active: true, + } + const wrapper = mount(PdfReaderSidebar, { props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true } }) + const input = wrapper.get('input[type="search"]') + + await input.trigger('keydown', { key: 'Enter' }) + expect(mocks.searchScope.goToResult).toHaveBeenLastCalledWith(1) + + mocks.searchState.value.activeResultIndex = 1 + await input.trigger('keydown', { key: 'Enter', shiftKey: true }) + expect(mocks.searchScope.goToResult).toHaveBeenLastCalledWith(0) + }) + + it('clears the query, cancels search work, and restores input focus', async () => { + const wrapper = mount(PdfReaderSidebar, { + props: { documentId: 'doc-1', activeTab: 'search', headerVisible: true }, + attachTo: document.body, + }) + const input = wrapper.get('input[type="search"]') + await input.setValue('distance') + mocks.searchScope.stopSearch.mockClear() + + await wrapper.get('button[aria-label="Clear search"]').trigger('click') + + expect((input.element as HTMLInputElement).value).toBe('') + expect(mocks.searchScope.stopSearch).toHaveBeenCalled() + expect(document.activeElement).toBe(input.element) + wrapper.unmount() + }) +}) diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfReaderToolbar.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfReaderToolbar.spec.ts new file mode 100644 index 000000000..2e6b09baf --- /dev/null +++ b/client/src/features/reader/pdf-v4/__tests__/PdfReaderToolbar.spec.ts @@ -0,0 +1,84 @@ +import { defineComponent, h } from 'vue' +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import PdfReaderToolbar from '../components/PdfReaderToolbar.vue' + +const passthrough = defineComponent({ + setup: + (_, { slots }) => + () => + h('div', slots.default?.()), +}) + +function mountToolbar(overrides: Partial['$props']> = {}) { + return mount(PdfReaderToolbar, { + props: { + currentPageStart: 12, + currentPageEnd: 13, + totalPages: 240, + zoomPercent: 100, + sidebarOpen: false, + searchOpen: false, + settingsOpen: false, + panActive: true, + fullscreen: false, + fullscreenSupported: true, + headerPinned: false, + ...overrides, + }, + global: { + stubs: { + Tooltip: passthrough, + TooltipTrigger: passthrough, + TooltipContent: passthrough, + DropdownMenu: passthrough, + DropdownMenuTrigger: passthrough, + DropdownMenuContent: passthrough, + }, + }, + }) +} + +describe('PdfReaderToolbar', () => { + it('shows the current spread and commits a clamped page number', async () => { + const wrapper = mountToolbar() + const input = wrapper.get('input[aria-label="Current page"]') + + expect(wrapper.text()).toContain('-13') + expect(wrapper.text()).toContain('/ 240') + + ;(input.element as HTMLInputElement).value = '999' + await input.trigger('change') + + expect(wrapper.emitted('goToPage')).toEqual([[240]]) + expect((input.element as HTMLInputElement).value).toBe('240') + }) + + it('keeps search available outside fullscreen and exposes responsive tools', async () => { + const wrapper = mountToolbar() + + expect(wrapper.findAll('button[aria-label="Search document"]')).toHaveLength(1) + expect(wrapper.find('button[aria-label="More PDF tools"]').exists()).toBe(true) + + const buttons = wrapper.findAll('button') + await buttons.find((button) => button.text().trim() === 'Search')!.trigger('click') + await buttons.find((button) => button.text().trim() === 'Select text')!.trigger('click') + await buttons.find((button) => button.text().trim() === 'Pan')!.trigger('click') + + expect(wrapper.emitted('toggleSearch')).toHaveLength(1) + expect(wrapper.emitted('selectTool')).toHaveLength(1) + expect(wrapper.emitted('togglePan')).toHaveLength(1) + }) + + it('shows the header pin only while fullscreen', async () => { + const windowed = mountToolbar() + expect(windowed.find('button[aria-label="Pin reader header"]').exists()).toBe(false) + + const fullscreen = mountToolbar({ fullscreen: true }) + const pinButtons = fullscreen.findAll('button[aria-label="Pin reader header"]') + expect(pinButtons).toHaveLength(1) + + await pinButtons[0]!.trigger('click') + expect(fullscreen.emitted('toggleHeaderPin')).toHaveLength(1) + }) +}) diff --git a/client/src/features/reader/pdf-v4/__tests__/PdfV4ReaderView.spec.ts b/client/src/features/reader/pdf-v4/__tests__/PdfV4ReaderView.spec.ts index 69162b56c..7ac613a99 100644 --- a/client/src/features/reader/pdf-v4/__tests__/PdfV4ReaderView.spec.ts +++ b/client/src/features/reader/pdf-v4/__tests__/PdfV4ReaderView.spec.ts @@ -1,13 +1,48 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' -import { defineComponent, h, nextTick } from 'vue' -import type { PDFViewerConfig } from '@embedpdf/vue-pdf-viewer' +import { defineComponent, h, ref } from 'vue' +import { ScrollStrategy } from '@embedpdf/plugin-scroll' +import { SpreadMode } from '@embedpdf/plugin-spread' +import { ZoomMode } from '@embedpdf/plugin-zoom' +import type { PdfReaderSettings } from '@bookorbit/types' + +const mockRouterBack = vi.fn<() => void>() +const mockRouterReplace = vi.fn<() => Promise>().mockResolvedValue(undefined) +const mockRoute = { + params: { bookId: '42', fileId: '101' }, + query: {} as Record, +} + +vi.mock('vue-router', () => ({ + useRoute: () => mockRoute, + useRouter: () => ({ back: mockRouterBack, replace: mockRouterReplace }), +})) + +const mockApi = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>()) +vi.mock('@/lib/api', () => ({ api: mockApi })) + +const mockSettingsLoad = vi.fn<() => Promise>().mockResolvedValue(undefined) +const mockUpdateBookSettings = vi.fn<(patch: Partial) => void>() +const mockEffective = ref({ + scrollMode: 'page' as const, + spread: 'none' as const, + zoomMode: 'fit-page' as const, + customScale: 1, + rotation: 0, +}) + +vi.mock('../../shared/composables/useReaderSettings', () => ({ + useReaderSettings: () => ({ + load: mockSettingsLoad, + effective: mockEffective, + updateBookSettings: mockUpdateBookSettings, + }), +})) const mockProgressLoad = vi.fn<() => Promise>().mockResolvedValue(undefined) -const mockProgressSave = vi.fn<() => void>() -const mockProgressPageNumber = { value: 5 } -const mockProgressPercentage = { value: 25 } +const mockProgressSave = vi.fn<() => Promise>().mockResolvedValue(undefined) +const mockProgressPageNumber = ref(5) +const mockProgressPercentage = ref(25) vi.mock('../../shared/composables/useReaderProgress', () => ({ useReaderProgress: () => ({ @@ -18,342 +53,247 @@ vi.mock('../../shared/composables/useReaderProgress', () => ({ }), })) -const mockSettingsLoad = vi.fn<() => Promise>().mockResolvedValue(undefined) -const mockUpdateBookSettings = vi.fn<() => void>() -const mockEffective = { - value: { - scrollMode: 'vertical' as string, - spread: 'none' as string, - zoomMode: 'fit-page' as string, - customScale: 1.0, - rotation: 0 as number, - }, -} - -vi.mock('../../shared/composables/useReaderSettings', () => ({ - useReaderSettings: () => ({ - load: mockSettingsLoad, - updateBookSettings: mockUpdateBookSettings, - effective: mockEffective, - }), -})) - const mockOnActivity = vi.fn<() => void>() vi.mock('../../shared/composables/useReadingSession', () => ({ useReadingSession: () => ({ onActivity: mockOnActivity, + elapsedMinutes: ref(0), }), })) -const requestFullscreenMock = vi.fn<() => Promise>().mockResolvedValue(undefined) -const exitFullscreenMock = vi.fn<() => Promise>().mockResolvedValue(undefined) - -vi.mock('@/lib/api', () => ({ - getAccessToken: () => 'test-token-abc', +const mockUsePdfiumEngine = vi.fn<() => unknown>(() => ({ + engine: ref({ id: 'engine' }), + isLoading: ref(false), + error: ref(null), +})) +vi.mock('@embedpdf/engines/vue', () => ({ + usePdfiumEngine: mockUsePdfiumEngine, })) -let capturedConfig: PDFViewerConfig | null = null -let readyCallback: ((registry: unknown) => void) | null = null -let initCallback: ((container: unknown) => void) | null = null +const mockCreatePluginRegistration = vi.fn<(pluginPackage: unknown, config?: unknown) => { pluginPackage: unknown; config?: unknown }>( + (pluginPackage, config) => ({ pluginPackage, config }), +) +const mockOpenDocumentBuffer = vi.hoisted(() => vi.fn<(options: unknown) => unknown>()) +const mockCloseDocument = vi.hoisted(() => vi.fn<(documentId: string) => unknown>()) +const mockGetDocumentState = vi.hoisted(() => vi.fn<(documentId: string) => unknown>()) +const mockDocumentManager = { + openDocumentBuffer: mockOpenDocumentBuffer, + closeDocument: mockCloseDocument, + getDocumentState: mockGetDocumentState, +} +const mockRegistry = { + pluginsReady: vi.fn<() => Promise>().mockResolvedValue(undefined), + isDestroyed: vi.fn<() => boolean>().mockReturnValue(false), + getPlugin: vi.fn<() => { provides: () => typeof mockDocumentManager }>(() => ({ provides: () => mockDocumentManager })), +} +vi.mock('@embedpdf/core', async (importOriginal) => { + const original = await importOriginal() + return { ...original, createPluginRegistration: mockCreatePluginRegistration } +}) -vi.mock('@embedpdf/vue-pdf-viewer', () => ({ - PDFViewer: defineComponent({ - props: ['config'], - emits: ['ready', 'init'], - setup(props, { emit }) { - capturedConfig = props.config - readyCallback = (registry: unknown) => emit('ready', registry) - initCallback = (container: unknown) => emit('init', container) - return () => h('div', { class: 'mock-pdf-viewer' }) +vi.mock('@embedpdf/core/vue', () => ({ + EmbedPDF: defineComponent({ + props: ['engine', 'plugins', 'onInitialized'], + setup(props, { slots }) { + void props.onInitialized?.(mockRegistry) + return () => + h( + 'div', + { class: 'mock-embed-pdf' }, + slots.default?.({ pluginsReady: true, activeDocumentId: 'document-1', activeDocument: { status: 'loaded' } }), + ) }, }), - ScrollPlugin: { id: 'scroll' }, - ScrollStrategy: { Vertical: 'vertical', Horizontal: 'horizontal' }, - SpreadMode: { None: 'none', Odd: 'odd', Even: 'even' }, - ZoomMode: { Automatic: 'automatic', FitPage: 'fit-page', FitWidth: 'fit-width' }, })) -// Must import AFTER mocks are set up const { default: PdfV4ReaderView } = await import('../PdfV4ReaderView.vue') describe('PdfV4ReaderView', () => { beforeEach(() => { vi.clearAllMocks() - capturedConfig = null - readyCallback = null - initCallback = null - document.documentElement.className = '' - localStorage.clear() - setActivePinia(createPinia()) - requestFullscreenMock.mockClear() - exitFullscreenMock.mockClear() - Object.defineProperty(document.documentElement, 'requestFullscreen', { - configurable: true, - value: requestFullscreenMock, - }) - Object.defineProperty(document, 'exitFullscreen', { - configurable: true, - value: exitFullscreenMock, - }) + mockSettingsLoad.mockResolvedValue(undefined) + mockApi.mockReset() + mockApi.mockImplementation(async () => new Response(new Uint8Array([37, 80, 68, 70]), { status: 200 })) + mockOpenDocumentBuffer.mockReset() + mockOpenDocumentBuffer.mockImplementation(() => ({ + toPromise: async () => ({ + documentId: 'document-1', + task: { toPromise: async () => ({ pageCount: 12 }) }, + }), + })) + mockCloseDocument.mockReset() + mockCloseDocument.mockImplementation(() => ({ toPromise: async () => undefined })) + mockGetDocumentState.mockReset() + mockRegistry.pluginsReady.mockResolvedValue(undefined) + mockRegistry.isDestroyed.mockReturnValue(false) + mockRegistry.getPlugin.mockReturnValue({ provides: () => mockDocumentManager }) + mockRoute.query = {} + mockProgressPageNumber.value = 5 + mockProgressPercentage.value = 25 + mockEffective.value = { + scrollMode: 'page', + spread: 'none', + zoomMode: 'fit-page', + customScale: 1, + rotation: 0, + } }) - async function mountComponent() { - const pinia = createPinia() + async function mountReader(peekMode = false) { const wrapper = mount(PdfV4ReaderView, { - props: { bookId: 42, fileId: 101 }, + props: { bookId: 42, fileId: 101, peekMode }, global: { - plugins: [pinia], stubs: { - Tooltip: { template: '
' }, - TooltipTrigger: { template: '
' }, - TooltipContent: { template: '
' }, + PdfReaderContent: { + name: 'PdfReaderContent', + props: ['documentId', 'initialPage', 'settings', 'peekMode'], + emits: ['back', 'pageChange', 'retry', 'settingsChange', 'startReading'], + template: '
', + }, }, }, }) await flushPromises() - await nextTick() return wrapper } - describe('initialization', () => { - it('loads book settings on mount', async () => { - await mountComponent() - expect(mockSettingsLoad).toHaveBeenCalled() + it('loads settings and progress before rendering document content', async () => { + const wrapper = await mountReader() + + expect(mockSettingsLoad).toHaveBeenCalledOnce() + expect(mockProgressLoad).toHaveBeenCalledOnce() + expect(wrapper.find('.mock-reader-content').exists()).toBe(true) + }) + + it('opens the verified document buffer after every plugin is initialized', async () => { + await mountReader() + + const documentConfig = mockCreatePluginRegistration.mock.calls[0]?.[1] as { + maxDocuments: number + } + expect(documentConfig.maxDocuments).toBe(1) + expect(documentConfig).not.toHaveProperty('initialDocuments') + expect(mockRegistry.pluginsReady).toHaveBeenCalledOnce() + expect(mockOpenDocumentBuffer).toHaveBeenCalledWith({ + name: 'PDF', + buffer: expect.any(ArrayBuffer), + rotation: 0, }) + expect(mockApi).toHaveBeenCalledWith('/api/v1/books/files/101/serve', { signal: expect.any(AbortSignal) }) + }) - it('renders viewer only after config is ready', async () => { - const wrapper = await mountComponent() - expect(wrapper.find('.mock-pdf-viewer').exists()).toBe(true) - }) + it('retries a zero-page engine result once before showing an error', async () => { + mockOpenDocumentBuffer.mockImplementation(() => ({ + toPromise: async () => ({ + documentId: 'document-zero', + task: { toPromise: async () => ({ pageCount: 0 }) }, + }), + })) - it('requests fullscreen from the floating fullscreen control', async () => { - const wrapper = await mountComponent() - await wrapper.get('button[aria-label="Enter fullscreen"]').trigger('click') - await flushPromises() + const wrapper = await mountReader() - expect(requestFullscreenMock).toHaveBeenCalled() + expect(mockOpenDocumentBuffer).toHaveBeenCalledTimes(2) + expect(mockCloseDocument).toHaveBeenCalledTimes(2) + expect(wrapper.text()).toContain('The PDF engine opened this document without any pages.') + }) + + it('shows a recoverable error when the PDF response is unsuccessful', async () => { + mockApi.mockResolvedValueOnce(new Response(null, { status: 503 })) + + const wrapper = await mountReader() + + expect(wrapper.text()).toContain('The PDF request failed with status 503.') + expect(wrapper.text()).toContain('Retry') + }) + + it('rejects an empty PDF response instead of mounting a zero-page document', async () => { + mockApi.mockResolvedValueOnce(new Response(null, { status: 200 })) + + const wrapper = await mountReader() + + expect(wrapper.text()).toContain('The PDF request returned an empty document.') + expect(wrapper.find('.mock-embed-pdf').exists()).toBe(false) + }) + + it('refetches the PDF when document content requests recovery', async () => { + const wrapper = await mountReader() + mockApi.mockClear() + + wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('retry') + await flushPromises() + + expect(mockApi).toHaveBeenCalledOnce() + expect(wrapper.find('.mock-reader-content').exists()).toBe(true) + }) + + it('runs PDFium in a worker with an absolute WASM URL', async () => { + await mountReader() + + expect(mockUsePdfiumEngine).toHaveBeenCalledWith({ + wasmUrl: expect.stringMatching(/^https?:\/\//), + worker: true, }) }) - describe('config construction', () => { - it('sets tabBar to never', async () => { - await mountComponent() - expect(capturedConfig?.tabBar).toBe('never') - }) + it('shows a recoverable error when reader state fails to load', async () => { + mockSettingsLoad.mockRejectedValueOnce(new Error('Settings unavailable')) - it('disables annotation, signature, form, print, export, insert, and redaction categories', async () => { - await mountComponent() - expect(capturedConfig?.disabledCategories).toEqual( - expect.arrayContaining(['annotation', 'signature', 'form', 'document-print', 'export', 'insert', 'redaction']), - ) - }) + const wrapper = await mountReader() - it('includes auth header in document request options', async () => { - await mountComponent() - const docs = capturedConfig?.documentManager?.initialDocuments - expect(docs).toHaveLength(1) - const doc = docs?.[0] as Record | undefined - expect((doc?.requestOptions as Record)?.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 | undefined - expect(doc?.url).toBe('/api/v1/books/files/101/serve') - }) - - it('maps scroll mode from settings', async () => { - mockEffective.value.scrollMode = 'horizontal' - await mountComponent() - expect(capturedConfig?.scroll?.defaultStrategy).toBe('horizontal') - mockEffective.value.scrollMode = 'vertical' - }) - - it('maps spread mode from settings', async () => { - mockEffective.value.spread = 'odd' - await mountComponent() - expect(capturedConfig?.spread?.defaultSpreadMode).toBe('odd') - mockEffective.value.spread = 'none' - }) - - it('maps fit-page zoom from settings', async () => { - mockEffective.value.zoomMode = 'fit-page' - await mountComponent() - expect(capturedConfig?.zoom?.defaultZoomLevel).toBe('fit-page') - }) - - it('maps custom zoom scale from settings', async () => { - mockEffective.value.zoomMode = 'custom' - mockEffective.value.customScale = 1.5 - await mountComponent() - expect(capturedConfig?.zoom?.defaultZoomLevel).toBe(1.5) - mockEffective.value.zoomMode = 'fit-page' - mockEffective.value.customScale = 1.0 - }) + expect(wrapper.text()).toContain('Unable to open this PDF') + expect(wrapper.text()).toContain('Settings unavailable') + expect(wrapper.text()).toContain('Retry') + expect(wrapper.find('.mock-embed-pdf').exists()).toBe(false) }) - describe('theme configuration', () => { - it('sets light preference when not in dark mode', async () => { - localStorage.setItem('theme', JSON.stringify('light')) - await mountComponent() - expect(capturedConfig?.theme?.preference).toBe('light') - }) + it('maps persisted layout settings into headless plugin configuration', async () => { + mockEffective.value = { + scrollMode: 'horizontal', + spread: 'odd', + zoomMode: 'fit-width', + customScale: 1.5, + rotation: 0, + } + await mountReader() - it('sets dark preference when in dark mode', async () => { - document.documentElement.classList.add('dark') - await mountComponent() - expect(capturedConfig?.theme?.preference).toBe('dark') - }) - - it('includes accent color overrides from theme store', async () => { - await mountComponent() - expect(capturedConfig?.theme?.light?.accent?.primary).toBeDefined() - expect(capturedConfig?.theme?.dark?.accent?.primary).toBeDefined() - expect(capturedConfig?.theme?.light?.accent?.primary).toBe(capturedConfig?.theme?.dark?.accent?.primary) - }) - - it('syncs theme on class attribute change via setTheme', async () => { - const mockSetTheme = vi.fn<() => void>() - const mockContainer = { setTheme: mockSetTheme } - - await mountComponent() - initCallback?.(mockContainer) - - const mockRegistry = { - getPlugin: () => ({ - provides: () => ({ - onLayoutReady: () => () => {}, - onPageChange: () => () => {}, - onStateChange: () => () => {}, - scrollToPage: vi.fn<() => void>(), - }), - }), - } - readyCallback?.(mockRegistry) - await flushPromises() - - document.documentElement.classList.add('dark') - await flushPromises() - await new Promise((r) => setTimeout(r, 50)) - - expect(mockSetTheme).toHaveBeenCalledWith(expect.objectContaining({ preference: 'dark' })) - }) + expect(mockCreatePluginRegistration.mock.calls[2]?.[1]).toMatchObject({ defaultStrategy: ScrollStrategy.Horizontal }) + expect(mockCreatePluginRegistration.mock.calls[4]?.[1]).toMatchObject({ defaultZoomLevel: ZoomMode.FitWidth }) + expect(mockCreatePluginRegistration.mock.calls[6]?.[1]).toMatchObject({ defaultSpreadMode: SpreadMode.Odd }) }) - describe('progress tracking', () => { - it('loads progress when viewer becomes ready', async () => { - await mountComponent() - const mockRegistry = { - getPlugin: () => ({ - provides: () => ({ - onLayoutReady: vi.fn<() => void>(), - onPageChange: vi.fn<() => void>(), - onStateChange: vi.fn<() => void>(), - }), - }), - } - readyCallback?.(mockRegistry) - await flushPromises() - expect(mockProgressLoad).toHaveBeenCalled() - }) + it('uses a deep-linked page before saved progress', async () => { + mockRoute.query = { page: '12' } + const wrapper = await mountReader() - it('scrolls to saved page on layout ready', async () => { - const mockScrollToPage = vi.fn<() => void>() - const layoutReadyHandlers: ((event: { isInitial: boolean; totalPages: number }) => void)[] = [] - const mockRegistry = { - getPlugin: () => ({ - provides: () => ({ - onLayoutReady: (handler: (e: { isInitial: boolean; totalPages: number }) => void) => { - layoutReadyHandlers.push(handler) - return () => {} - }, - onPageChange: () => () => {}, - onStateChange: () => () => {}, - scrollToPage: mockScrollToPage, - }), - }), - } - - await mountComponent() - readyCallback?.(mockRegistry) - await flushPromises() - - layoutReadyHandlers[0]?.({ isInitial: true, totalPages: 20 }) - expect(mockScrollToPage).toHaveBeenCalledWith({ pageNumber: 5 }) - }) - - it('does not scroll if saved page is 1', async () => { - mockProgressPageNumber.value = 1 - const mockScrollToPage = vi.fn<() => void>() - const layoutReadyHandlers: ((event: { isInitial: boolean; totalPages: number }) => void)[] = [] - const mockRegistry = { - getPlugin: () => ({ - provides: () => ({ - onLayoutReady: (handler: (e: { isInitial: boolean; totalPages: number }) => void) => { - layoutReadyHandlers.push(handler) - return () => {} - }, - onPageChange: () => () => {}, - onStateChange: () => () => {}, - scrollToPage: mockScrollToPage, - }), - }), - } - - await mountComponent() - readyCallback?.(mockRegistry) - await flushPromises() - - layoutReadyHandlers[0]?.({ isInitial: true, totalPages: 20 }) - expect(mockScrollToPage).not.toHaveBeenCalled() - mockProgressPageNumber.value = 5 - }) - - it('calls onActivity on page change', async () => { - const pageChangeHandlers: ((event: { pageNumber: number; totalPages: number }) => void)[] = [] - const mockRegistry = { - getPlugin: () => ({ - provides: () => ({ - onLayoutReady: () => () => {}, - onPageChange: (handler: (e: { pageNumber: number; totalPages: number }) => void) => { - pageChangeHandlers.push(handler) - return () => {} - }, - onStateChange: () => () => {}, - scrollToPage: vi.fn<() => void>(), - }), - }), - } - - await mountComponent() - readyCallback?.(mockRegistry) - await flushPromises() - - pageChangeHandlers[0]?.({ pageNumber: 3, totalPages: 10 }) - expect(mockOnActivity).toHaveBeenCalled() - }) + expect(wrapper.getComponent({ name: 'PdfReaderContent' }).props('initialPage')).toBe(12) }) - describe('cleanup', () => { - it('disconnects mutation observer on unmount', async () => { - const wrapper = await mountComponent() - const mockRegistry = { - getPlugin: () => ({ - provides: () => ({ - onLayoutReady: () => () => {}, - onPageChange: () => () => {}, - onStateChange: () => () => {}, - scrollToPage: vi.fn<() => void>(), - }), - }), - } - readyCallback?.(mockRegistry) - await flushPromises() + it('debounces page progress saves', async () => { + vi.useFakeTimers() + const wrapper = await mountReader() + wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('pageChange', 8, 20) + await vi.advanceTimersByTimeAsync(2000) - expect(() => wrapper.unmount()).not.toThrow() - }) + expect(mockProgressPageNumber.value).toBe(8) + expect(mockProgressPercentage.value).toBe(40) + expect(mockProgressSave).toHaveBeenCalledOnce() + expect(mockOnActivity).toHaveBeenCalledOnce() + vi.useRealTimers() + }) + + it('keeps peek mode out of tracked progress and starts reading on request', async () => { + const wrapper = await mountReader(true) + wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('startReading') + await flushPromises() + + expect(mockRouterReplace).toHaveBeenCalledWith({ name: 'reader', params: mockRoute.params, query: {} }) + expect(mockProgressSave).toHaveBeenCalledOnce() + expect(mockOnActivity).toHaveBeenCalledOnce() + }) + + it('routes back from the BookOrbit reader shell', async () => { + const wrapper = await mountReader() + wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('back') + expect(mockRouterBack).toHaveBeenCalledOnce() }) }) diff --git a/client/src/features/reader/pdf-v4/__tests__/embedpdf-lifecycle.spec.ts b/client/src/features/reader/pdf-v4/__tests__/embedpdf-lifecycle.spec.ts new file mode 100644 index 000000000..5284d9e99 --- /dev/null +++ b/client/src/features/reader/pdf-v4/__tests__/embedpdf-lifecycle.spec.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { EmbedPDF } from '@embedpdf/core/vue' +import type { PdfEngine } from '@embedpdf/models' + +describe('patched EmbedPDF lifecycle', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('registers teardown before asynchronous plugin initialization completes', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const engine = {} as PdfEngine + const Host = defineComponent({ + setup() { + return () => h(EmbedPDF, { engine, plugins: [] }, () => h('div', 'reader')) + }, + }) + + const wrapper = mount(Host) + await flushPromises() + wrapper.unmount() + + expect(warn.mock.calls.flat().join(' ')).not.toContain('onBeforeUnmount is called when there is no active component instance') + }) +}) diff --git a/client/src/features/reader/pdf-v4/__tests__/embedpdf-worker.spec.ts b/client/src/features/reader/pdf-v4/__tests__/embedpdf-worker.spec.ts new file mode 100644 index 000000000..91c3056f0 --- /dev/null +++ b/client/src/features/reader/pdf-v4/__tests__/embedpdf-worker.spec.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PdfErrorCode } from '@embedpdf/models' +import { createPdfiumEngine } from '@embedpdf/engines/pdfium-worker-engine' + +class FailingWorker { + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + private readonly listeners = new Map>() + + addEventListener(type: string, listener: EventListenerOrEventListenerObject) { + const listeners = this.listeners.get(type) ?? new Set() + listeners.add(listener) + this.listeners.set(type, listeners) + } + + removeEventListener(type: string, listener: EventListenerOrEventListenerObject) { + this.listeners.get(type)?.delete(listener) + } + + postMessage(message: { type?: string }) { + if (message.type !== 'wasmInit') return + queueMicrotask(() => { + const event = new MessageEvent('message', { data: { type: 'wasmError', error: 'WASM unavailable' } }) + for (const listener of this.listeners.get('message') ?? []) { + if (typeof listener === 'function') listener(event) + else listener.handleEvent(event) + } + }) + } + + terminate() {} +} + +describe('patched PDFium worker initialization', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('rejects queued document work instead of hanging on a WASM error', async () => { + vi.stubGlobal('Worker', FailingWorker) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:pdf-worker') + const engine = createPdfiumEngine('https://bookorbit.test/pdfium.wasm', { fontFallback: null }) + + const task = engine.openDocumentBuffer({ id: 'doc-1', content: new ArrayBuffer(0) }) + + await expect(task.toPromise()).rejects.toMatchObject({ + reason: { + code: PdfErrorCode.Initialization, + message: 'Worker initialization failed', + }, + }) + }) +}) diff --git a/client/src/features/reader/pdf-v4/__tests__/pdf-pagination.spec.ts b/client/src/features/reader/pdf-v4/__tests__/pdf-pagination.spec.ts new file mode 100644 index 000000000..2d3462683 --- /dev/null +++ b/client/src/features/reader/pdf-v4/__tests__/pdf-pagination.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { Rotation, type PdfPageObjectWithRotatedSize } from '@embedpdf/models' +import type { PageVisibilityMetrics } from '@embedpdf/plugin-scroll' +import { canTurnFromVisiblePage, findAdjacentPage, findPageRange, shouldUseResponsiveSpread } from '../pdf-pagination' + +function page(index: number, width = 600, height = 800): PdfPageObjectWithRotatedSize { + return { + index, + size: { width, height }, + rotatedSize: { width, height }, + rotation: Rotation.Degree0, + objectNumber: index + 1, + } +} + +function visibility(pageNumber: number, pageX: number, pageY: number, visibleWidth: number, visibleHeight: number): PageVisibilityMetrics { + return { + pageNumber, + viewportX: 0, + viewportY: 0, + visiblePercentage: 100, + original: { pageX, pageY, visibleWidth, visibleHeight, scale: 1 }, + scaled: { pageX, pageY, visibleWidth, visibleHeight, scale: 1 }, + } +} + +describe('PDF pagination helpers', () => { + const spreads = [[page(0)], [page(1), page(2)], [page(3), page(4)]] + + it('finds the visible logical spread for any page in it', () => { + expect(findPageRange(spreads, 3, 5)).toEqual({ start: 2, end: 3 }) + expect(findPageRange(spreads, 5, 5)).toEqual({ start: 4, end: 5 }) + }) + + it('moves by logical spreads and stops at document boundaries', () => { + expect(findAdjacentPage(spreads, 1, 1, 5)).toBe(2) + expect(findAdjacentPage(spreads, 2, 1, 5)).toBe(4) + expect(findAdjacentPage(spreads, 4, 1, 5)).toBe(4) + expect(findAdjacentPage(spreads, 1, -1, 5)).toBe(1) + }) + + it('requires an enlarged page to reach the relevant pan boundary before turning', () => { + const range = { start: 1, end: 1 } + expect(canTurnFromVisiblePage(1, range, [visibility(1, 0, 100, 600, 600)], spreads)).toBe(false) + expect(canTurnFromVisiblePage(1, range, [visibility(1, 0, 200, 600, 600)], spreads)).toBe(true) + expect(canTurnFromVisiblePage(1, range, [visibility(1, 100, 0, 500, 800)], spreads, 'horizontal')).toBe(true) + expect(canTurnFromVisiblePage(-1, range, [visibility(1, 20, 0, 500, 800)], spreads, 'horizontal')).toBe(false) + }) + + it('uses responsive spreads only for sufficiently wide landscape viewports', () => { + expect(shouldUseResponsiveSpread(1200, 800)).toBe(true) + expect(shouldUseResponsiveSpread(800, 1200)).toBe(false) + expect(shouldUseResponsiveSpread(850, 700)).toBe(false) + }) +}) diff --git a/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts b/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts index a632f1e87..ad9dba8d2 100644 --- a/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts +++ b/client/src/features/reader/pdf-v4/__tests__/pdf-viewer-utils.spec.ts @@ -1,26 +1,71 @@ import { describe, expect, it } from 'vitest' -import { lookupAccentHex } from '../pdf-viewer-utils' +import { Rotation } from '@embedpdf/models' +import { ScrollStrategy } from '@embedpdf/plugin-scroll' +import { SpreadMode } from '@embedpdf/plugin-spread' +import { ZoomMode } from '@embedpdf/plugin-zoom' +import type { PdfReaderSettings } from '@bookorbit/types' +import { flattenPdfBookmarks, fromRotation, safeExternalPdfUrl, toRotation, toScrollStrategy, toSpreadMode, toZoomLevel } from '../pdf-viewer-utils' -describe('lookupAccentHex', () => { - const options = [ - { id: 'rose', label: 'Rose', color: '#e11d48' }, - { id: 'blue', label: 'Blue', color: '#2563eb' }, - { id: 'green', label: 'Green', color: '#16a34a' }, - ] +const settings: PdfReaderSettings = { + scrollMode: 'vertical', + spread: 'none', + zoomMode: 'fit-page', + customScale: 1, + rotation: 0, +} - it('returns color for matching accent id', () => { - expect(lookupAccentHex('rose', options)).toBe('#e11d48') +describe('PDF viewer settings mapping', () => { + it('maps page mode to the vertical strategy used by paginated navigation', () => { + expect(toScrollStrategy('page')).toBe(ScrollStrategy.Vertical) + expect(toScrollStrategy('horizontal')).toBe(ScrollStrategy.Horizontal) }) - it('returns color for another accent id', () => { - expect(lookupAccentHex('green', options)).toBe('#16a34a') + it('maps spread modes', () => { + expect(toSpreadMode('none')).toBe(SpreadMode.None) + expect(toSpreadMode('odd')).toBe(SpreadMode.Odd) + expect(toSpreadMode('even')).toBe(SpreadMode.Even) + expect(toSpreadMode('auto')).toBe(SpreadMode.None) }) - it('returns fallback blue for unknown accent id', () => { - expect(lookupAccentHex('unknown', options)).toBe('#3b82f6') + it('maps fit and custom zoom levels', () => { + expect(toZoomLevel(settings)).toBe(ZoomMode.FitPage) + expect(toZoomLevel({ ...settings, zoomMode: 'fit-width' })).toBe(ZoomMode.FitWidth) + expect(toZoomLevel({ ...settings, zoomMode: 'automatic' })).toBe(ZoomMode.Automatic) + expect(toZoomLevel({ ...settings, zoomMode: 'custom', customScale: 1.75 })).toBe(1.75) }) - it('returns fallback blue for empty options', () => { - expect(lookupAccentHex('rose', [])).toBe('#3b82f6') + it('round trips persisted rotation values', () => { + expect(toRotation(0)).toBe(Rotation.Degree0) + expect(toRotation(90)).toBe(Rotation.Degree90) + expect(toRotation(180)).toBe(Rotation.Degree180) + expect(toRotation(270)).toBe(Rotation.Degree270) + expect(fromRotation(Rotation.Degree270)).toBe(270) + }) +}) + +describe('flattenPdfBookmarks', () => { + it('preserves hierarchy depth in document order', () => { + const result = flattenPdfBookmarks([ + { + title: 'Part one', + children: [{ title: 'Chapter one' }, { title: 'Chapter two', children: [{ title: 'Section' }] }], + }, + ]) + + expect(result.map(({ bookmark, depth }) => [bookmark.title, depth])).toEqual([ + ['Part one', 0], + ['Chapter one', 1], + ['Chapter two', 1], + ['Section', 2], + ]) + }) +}) + +describe('safeExternalPdfUrl', () => { + it('allows only HTTP and HTTPS links', () => { + expect(safeExternalPdfUrl('https://example.com/reference')?.hostname).toBe('example.com') + expect(safeExternalPdfUrl('http://example.com')?.protocol).toBe('http:') + expect(safeExternalPdfUrl('javascript:alert(1)')).toBeNull() + expect(safeExternalPdfUrl('mailto:test@example.com')).toBeNull() }) }) diff --git a/client/src/features/reader/pdf-v4/__tests__/usePdfFullscreenChrome.spec.ts b/client/src/features/reader/pdf-v4/__tests__/usePdfFullscreenChrome.spec.ts new file mode 100644 index 000000000..c7f6fd0ef --- /dev/null +++ b/client/src/features/reader/pdf-v4/__tests__/usePdfFullscreenChrome.spec.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, ref } from 'vue' +import { usePdfFullscreenChrome } from '../composables/usePdfFullscreenChrome' + +describe('usePdfFullscreenChrome', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('auto-hides only in fullscreen and keeps a session pin', async () => { + vi.useFakeTimers() + const fullscreen = ref(false) + const openUi = ref(false) + let chrome!: ReturnType + const wrapper = mount( + defineComponent({ + setup() { + chrome = usePdfFullscreenChrome(fullscreen, openUi) + return () => h('div') + }, + }), + ) + + await vi.advanceTimersByTimeAsync(3000) + expect(chrome.visible.value).toBe(true) + + fullscreen.value = true + await vi.runAllTicks() + await vi.advanceTimersByTimeAsync(2500) + expect(chrome.visible.value).toBe(false) + + chrome.reveal() + chrome.togglePinned() + await vi.advanceTimersByTimeAsync(3000) + expect(chrome.visible.value).toBe(true) + + fullscreen.value = false + await vi.runAllTicks() + expect(chrome.pinned.value).toBe(true) + + fullscreen.value = true + await vi.runAllTicks() + await vi.advanceTimersByTimeAsync(3000) + expect(chrome.visible.value).toBe(true) + + chrome.togglePinned() + await vi.advanceTimersByTimeAsync(2500) + expect(chrome.visible.value).toBe(false) + wrapper.unmount() + }) +}) diff --git a/client/src/features/reader/pdf-v4/__tests__/usePdfPagination.spec.ts b/client/src/features/reader/pdf-v4/__tests__/usePdfPagination.spec.ts new file mode 100644 index 000000000..8ff9c86d6 --- /dev/null +++ b/client/src/features/reader/pdf-v4/__tests__/usePdfPagination.spec.ts @@ -0,0 +1,39 @@ +import { defineComponent, h, ref } from 'vue' +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import type { ScrollScope } from '@embedpdf/plugin-scroll' +import { usePdfPagination } from '../composables/usePdfPagination' + +describe('usePdfPagination', () => { + it('stays stable while EmbedPDF unloads the document scope', () => { + const scroll = ref({ + getSpreadPagesWithRotatedSize() { + throw new Error('Document doc-1 not loaded') + }, + scrollToPage() { + throw new Error('Document doc-1 not loaded') + }, + } as unknown as Readonly) + let pagination!: ReturnType + + const wrapper = mount( + defineComponent({ + setup() { + pagination = usePdfPagination({ + mode: ref('page'), + scrollState: ref({ currentPage: 20, totalPages: 240 }), + scroll, + onActivity: () => {}, + }) + return () => h('div') + }, + }), + ) + + expect(pagination.pageRange.value).toEqual({ start: 20, end: 20 }) + expect(() => pagination.goToPage(21)).not.toThrow() + expect(() => pagination.nextPage()).not.toThrow() + + wrapper.unmount() + }) +}) diff --git a/client/src/features/reader/pdf-v4/components/PdfDocumentViewport.vue b/client/src/features/reader/pdf-v4/components/PdfDocumentViewport.vue new file mode 100644 index 000000000..3ce7b5afb --- /dev/null +++ b/client/src/features/reader/pdf-v4/components/PdfDocumentViewport.vue @@ -0,0 +1,45 @@ + + + diff --git a/client/src/features/reader/pdf-v4/components/PdfPasswordPrompt.vue b/client/src/features/reader/pdf-v4/components/PdfPasswordPrompt.vue new file mode 100644 index 000000000..4be3b8fd1 --- /dev/null +++ b/client/src/features/reader/pdf-v4/components/PdfPasswordPrompt.vue @@ -0,0 +1,80 @@ + + + diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue b/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue new file mode 100644 index 000000000..9df9c1386 --- /dev/null +++ b/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue @@ -0,0 +1,496 @@ + + + diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderSettingsPanel.vue b/client/src/features/reader/pdf-v4/components/PdfReaderSettingsPanel.vue new file mode 100644 index 000000000..099f3c563 --- /dev/null +++ b/client/src/features/reader/pdf-v4/components/PdfReaderSettingsPanel.vue @@ -0,0 +1,239 @@ + + + diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderSidebar.vue b/client/src/features/reader/pdf-v4/components/PdfReaderSidebar.vue new file mode 100644 index 000000000..984a5e92e --- /dev/null +++ b/client/src/features/reader/pdf-v4/components/PdfReaderSidebar.vue @@ -0,0 +1,377 @@ + + + diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderToolbar.vue b/client/src/features/reader/pdf-v4/components/PdfReaderToolbar.vue new file mode 100644 index 000000000..ffa16383f --- /dev/null +++ b/client/src/features/reader/pdf-v4/components/PdfReaderToolbar.vue @@ -0,0 +1,346 @@ + + + diff --git a/client/src/features/reader/pdf-v4/composables/usePdfFullscreenChrome.ts b/client/src/features/reader/pdf-v4/composables/usePdfFullscreenChrome.ts new file mode 100644 index 000000000..43b19785f --- /dev/null +++ b/client/src/features/reader/pdf-v4/composables/usePdfFullscreenChrome.ts @@ -0,0 +1,45 @@ +import { computed, onUnmounted, ref, watch, type Ref } from 'vue' + +const AUTO_HIDE_DELAY_MS = 2500 + +export function usePdfFullscreenChrome(isFullscreen: Readonly>, hasOpenUi: Readonly>) { + const pinned = ref(false) + const visible = ref(true) + let hideTimer: ReturnType | null = null + + const canAutoHide = computed(() => isFullscreen.value && !pinned.value && !hasOpenUi.value) + + function clearHideTimer() { + if (!hideTimer) return + clearTimeout(hideTimer) + hideTimer = null + } + + function scheduleHide() { + clearHideTimer() + if (!canAutoHide.value) return + hideTimer = setTimeout(() => { + visible.value = false + hideTimer = null + }, AUTO_HIDE_DELAY_MS) + } + + function reveal() { + visible.value = true + scheduleHide() + } + + function togglePinned() { + pinned.value = !pinned.value + reveal() + } + + watch([isFullscreen, hasOpenUi, pinned], () => { + visible.value = true + scheduleHide() + }) + + onUnmounted(clearHideTimer) + + return { pinned, visible, reveal, togglePinned } +} diff --git a/client/src/features/reader/pdf-v4/composables/usePdfPagination.ts b/client/src/features/reader/pdf-v4/composables/usePdfPagination.ts new file mode 100644 index 000000000..3bc404c29 --- /dev/null +++ b/client/src/features/reader/pdf-v4/composables/usePdfPagination.ts @@ -0,0 +1,150 @@ +import { computed, onUnmounted, type Ref } from 'vue' +import type { ScrollScope } from '@embedpdf/plugin-scroll' +import type { PdfReaderSettings } from '@bookorbit/types' +import { canTurnFromVisiblePage, findAdjacentPage, findPageRange } from '../pdf-pagination' + +type ReadonlyRef = Readonly> + +export interface UsePdfPaginationOptions { + mode: ReadonlyRef + scrollState: ReadonlyRef<{ currentPage: number; totalPages: number }> + scroll: ReadonlyRef | null> + onActivity: () => void +} + +export function usePdfPagination({ mode, scrollState, scroll, onActivity }: UsePdfPaginationOptions) { + let wheelDelta = 0 + let wheelUnlockTimer: ReturnType | null = null + let touchStart: { x: number; y: number } | null = null + + function getScrollScope() { + try { + return scroll.value + } catch { + return null + } + } + + function getSpreads(scope = getScrollScope()) { + try { + return scope?.getSpreadPagesWithRotatedSize() ?? [] + } catch { + return [] + } + } + + function getPageRange(pageNumber: number, totalPages: number) { + return findPageRange(getSpreads(), pageNumber, totalPages) + } + + const pageRange = computed(() => getPageRange(scrollState.value.currentPage, scrollState.value.totalPages)) + const behavior = computed(() => (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth')) + + function goToPage(page: number) { + const clamped = Math.min(Math.max(page, 1), scrollState.value.totalPages || 1) + try { + getScrollScope()?.scrollToPage({ pageNumber: clamped, behavior: behavior.value }) + } catch { + return + } + } + + function turnPage(direction: -1 | 1) { + const scope = getScrollScope() + if (!scope) return + try { + if (scope.getPageChangeState().isChanging) return + const spreads = getSpreads(scope) + if (spreads.length === 0) return + const target = findAdjacentPage(spreads, pageRange.value.start, direction, scrollState.value.totalPages) + if (target === pageRange.value.start) return + scope.scrollToPage({ pageNumber: target, behavior: behavior.value }) + } catch { + return + } + } + + function previousPage() { + onActivity() + if (mode.value === 'page') turnPage(-1) + else { + try { + getScrollScope()?.scrollToPreviousPage(behavior.value) + } catch { + return + } + } + } + + function nextPage() { + onActivity() + if (mode.value === 'page') turnPage(1) + else { + try { + getScrollScope()?.scrollToNextPage(behavior.value) + } catch { + return + } + } + } + + function canTurnPage(direction: -1 | 1, axis: 'horizontal' | 'vertical' = 'vertical') { + const scope = getScrollScope() + if (!scope) return false + const spreads = getSpreads(scope) + if (spreads.length === 0) return false + try { + return canTurnFromVisiblePage(direction, pageRange.value, scope.getMetrics().pageVisibilityMetrics, spreads, axis) + } catch { + return false + } + } + + function handleWheel(event: WheelEvent) { + onActivity() + if (mode.value !== 'page' || event.ctrlKey || event.metaKey) return + const axis = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? 'horizontal' : 'vertical' + const delta = axis === 'horizontal' ? event.deltaX : event.deltaY + const direction: -1 | 1 = delta < 0 ? -1 : 1 + if (!canTurnPage(direction, axis)) return + event.preventDefault() + wheelDelta += delta + if (Math.abs(wheelDelta) < 80 || wheelUnlockTimer) return + turnPage(direction) + wheelDelta = 0 + wheelUnlockTimer = setTimeout(() => { + wheelUnlockTimer = null + }, 350) + } + + function handleTouchStart(event: TouchEvent) { + onActivity() + if (event.touches.length !== 1) { + touchStart = null + return + } + touchStart = { x: event.touches[0]!.clientX, y: event.touches[0]!.clientY } + } + + function handleTouchEnd(event: TouchEvent) { + if (mode.value !== 'page' || !touchStart || event.changedTouches.length !== 1) { + touchStart = null + return + } + const touch = event.changedTouches[0]! + const deltaX = touch.clientX - touchStart.x + const deltaY = touch.clientY - touchStart.y + touchStart = null + if (Math.abs(deltaX) < 60 || Math.abs(deltaX) < Math.abs(deltaY) * 1.2) return + const direction: -1 | 1 = deltaX > 0 ? -1 : 1 + if (!canTurnPage(direction, 'horizontal')) return + event.preventDefault() + turnPage(direction) + } + + onUnmounted(() => { + if (wheelUnlockTimer) clearTimeout(wheelUnlockTimer) + }) + + return { pageRange, getPageRange, goToPage, previousPage, nextPage, handleWheel, handleTouchStart, handleTouchEnd } +} diff --git a/client/src/features/reader/pdf-v4/composables/usePdfResponsiveSpread.ts b/client/src/features/reader/pdf-v4/composables/usePdfResponsiveSpread.ts new file mode 100644 index 000000000..c4c8557a7 --- /dev/null +++ b/client/src/features/reader/pdf-v4/composables/usePdfResponsiveSpread.ts @@ -0,0 +1,45 @@ +import { onMounted, onUnmounted, watch, type Ref } from 'vue' +import { SpreadMode, type SpreadScope } from '@embedpdf/plugin-spread' +import type { PdfReaderSettings } from '@bookorbit/types' +import { shouldUseResponsiveSpread } from '../pdf-pagination' + +type ReadonlyRef = Readonly> + +export function usePdfResponsiveSpread( + surface: ReadonlyRef, + preference: ReadonlyRef, + spread: ReadonlyRef | null>, +) { + let resizeObserver: ResizeObserver | null = null + + function getSpreadScope() { + try { + return spread.value + } catch { + return null + } + } + + function apply() { + if (preference.value !== 'auto' || !surface.value) return + const { width, height } = surface.value.getBoundingClientRect() + try { + getSpreadScope()?.setSpreadMode(shouldUseResponsiveSpread(width, height) ? SpreadMode.Odd : SpreadMode.None) + } catch { + return + } + } + + onMounted(() => { + if (!surface.value) return + resizeObserver = new ResizeObserver(apply) + resizeObserver.observe(surface.value) + apply() + }) + + watch([surface, preference, getSpreadScope], apply, { flush: 'post' }) + + onUnmounted(() => resizeObserver?.disconnect()) + + return { apply } +} diff --git a/client/src/features/reader/pdf-v4/pdf-pagination.ts b/client/src/features/reader/pdf-v4/pdf-pagination.ts new file mode 100644 index 000000000..7606a9fbf --- /dev/null +++ b/client/src/features/reader/pdf-v4/pdf-pagination.ts @@ -0,0 +1,58 @@ +import type { PdfPageObjectWithRotatedSize } from '@embedpdf/models' +import type { PageVisibilityMetrics } from '@embedpdf/plugin-scroll' + +export interface PdfPageRange { + start: number + end: number +} + +export function findPageRange(spreads: PdfPageObjectWithRotatedSize[][], pageNumber: number, totalPages: number): PdfPageRange { + const spread = spreads.find((pages) => pages.some((page) => page.index + 1 === pageNumber)) + if (!spread?.length) { + const page = Math.min(Math.max(pageNumber, 1), Math.max(totalPages, 1)) + return { start: page, end: page } + } + + return { + start: Math.min(...spread.map((page) => page.index + 1)), + end: Math.max(...spread.map((page) => page.index + 1)), + } +} + +export function findAdjacentPage(spreads: PdfPageObjectWithRotatedSize[][], pageNumber: number, direction: -1 | 1, totalPages: number): number { + const currentIndex = spreads.findIndex((pages) => pages.some((page) => page.index + 1 === pageNumber)) + if (currentIndex < 0) return Math.min(Math.max(pageNumber + direction, 1), Math.max(totalPages, 1)) + const target = spreads[currentIndex + direction] + if (!target?.length) return Math.min(...spreads[currentIndex]!.map((page) => page.index + 1)) + return Math.min(...target.map((page) => page.index + 1)) +} + +export function canTurnFromVisiblePage( + direction: -1 | 1, + pageRange: PdfPageRange, + visibility: PageVisibilityMetrics[], + spreads: PdfPageObjectWithRotatedSize[][], + axis: 'horizontal' | 'vertical' = 'vertical', +): boolean { + const pageNumbers = new Set(Array.from({ length: pageRange.end - pageRange.start + 1 }, (_, index) => pageRange.start + index)) + const relevantMetrics = visibility.filter((metric) => pageNumbers.has(metric.pageNumber)) + if (relevantMetrics.length === 0) return true + + const pages = spreads.flat().filter((page) => pageNumbers.has(page.index + 1)) + if (pages.length === 0) return true + + return relevantMetrics.every((metric) => { + const page = pages.find((candidate) => candidate.index + 1 === metric.pageNumber) + if (!page) return true + if (axis === 'horizontal') { + if (direction < 0) return metric.original.pageX <= 1 + return metric.original.pageX + metric.original.visibleWidth >= page.rotatedSize.width - 1 + } + if (direction < 0) return metric.original.pageY <= 1 + return metric.original.pageY + metric.original.visibleHeight >= page.rotatedSize.height - 1 + }) +} + +export function shouldUseResponsiveSpread(width: number, height: number): boolean { + return width >= 900 && width / Math.max(height, 1) >= 1.1 +} diff --git a/client/src/features/reader/pdf-v4/pdf-viewer-utils.ts b/client/src/features/reader/pdf-v4/pdf-viewer-utils.ts index 773b60e94..70da9b93d 100644 --- a/client/src/features/reader/pdf-v4/pdf-viewer-utils.ts +++ b/client/src/features/reader/pdf-v4/pdf-viewer-utils.ts @@ -1,7 +1,54 @@ -export function lookupAccentHex(accentId: string, options: readonly { id: string; color: string }[]): string { - return options.find((o) => o.id === accentId)?.color ?? '#3b82f6' +import { Rotation, type PdfBookmarkObject } from '@embedpdf/models' +import { ScrollStrategy } from '@embedpdf/plugin-scroll' +import { SpreadMode } from '@embedpdf/plugin-spread' +import { ZoomMode, type ZoomLevel } from '@embedpdf/plugin-zoom' +import type { PdfReaderSettings } from '@bookorbit/types' + +export interface FlatPdfBookmark { + bookmark: PdfBookmarkObject + depth: number } -export function getIsDark(): boolean { - return document.documentElement.classList.contains('dark') +export function toScrollStrategy(mode: PdfReaderSettings['scrollMode']): ScrollStrategy { + return mode === 'horizontal' ? ScrollStrategy.Horizontal : ScrollStrategy.Vertical +} + +export function toSpreadMode(mode: PdfReaderSettings['spread']): SpreadMode { + if (mode === 'odd') return SpreadMode.Odd + if (mode === 'even') return SpreadMode.Even + return SpreadMode.None +} + +export function toZoomLevel(settings: PdfReaderSettings): ZoomLevel { + if (settings.zoomMode === 'fit-width') return ZoomMode.FitWidth + if (settings.zoomMode === 'automatic') return ZoomMode.Automatic + if (settings.zoomMode === 'custom') return settings.customScale + return ZoomMode.FitPage +} + +export function toRotation(rotation: PdfReaderSettings['rotation']): Rotation { + if (rotation === 90) return Rotation.Degree90 + if (rotation === 180) return Rotation.Degree180 + if (rotation === 270) return Rotation.Degree270 + return Rotation.Degree0 +} + +export function fromRotation(rotation: Rotation): PdfReaderSettings['rotation'] { + if (rotation === Rotation.Degree90) return 90 + if (rotation === Rotation.Degree180) return 180 + if (rotation === Rotation.Degree270) return 270 + return 0 +} + +export function flattenPdfBookmarks(bookmarks: PdfBookmarkObject[], depth = 0): FlatPdfBookmark[] { + return bookmarks.flatMap((bookmark) => [{ bookmark, depth }, ...flattenPdfBookmarks(bookmark.children ?? [], depth + 1)]) +} + +export function safeExternalPdfUrl(uri: string): URL | null { + try { + const url = new URL(uri, window.location.href) + return url.protocol === 'http:' || url.protocol === 'https:' ? url : null + } catch { + return null + } } diff --git a/client/src/features/reader/shared/composables/useReaderSettings.test.ts b/client/src/features/reader/shared/composables/useReaderSettings.test.ts index 16ed5aa28..f19e87729 100644 --- a/client/src/features/reader/shared/composables/useReaderSettings.test.ts +++ b/client/src/features/reader/shared/composables/useReaderSettings.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ref } from 'vue' -import { CBX_READER_DEFAULTS, READER_GROUP_DEFAULTS } from '@bookorbit/types' +import { CBX_READER_DEFAULTS, READER_GROUP_DEFAULTS, type PdfReaderSettings } from '@bookorbit/types' const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise>()) vi.mock('@/lib/api', () => ({ api: apiMock })) @@ -108,6 +108,47 @@ describe('useReaderSettings - load() with invalid localStorage value', () => { }) }) +describe('useReaderSettings - PDF validation', () => { + it('keeps valid PDF settings and removes values that can break reader layout', async () => { + localStorage.setItem( + `reader:book:${BOOK_FILE_ID}`, + JSON.stringify({ + scrollMode: 'horizontal', + spread: 'odd', + zoomMode: 'custom', + customScale: 40, + rotation: 45, + }), + ) + + const settings = useReaderSettings(BOOK_FILE_ID, 'pdf') + await settings.load() + + expect(settings.bookDelta.value).toEqual({ + scrollMode: 'horizontal', + spread: 'odd', + zoomMode: 'custom', + }) + const effective = settings.effective.value as PdfReaderSettings + expect(effective.customScale).toBe(1) + expect(effective.rotation).toBe(0) + }) + + it('migrates wrapped mode and retains new read-only layout choices', async () => { + localStorage.setItem(`reader:book:${BOOK_FILE_ID}`, JSON.stringify({ scrollMode: 'wrapped', spread: 'auto', zoomMode: 'automatic' })) + + const settings = useReaderSettings(BOOK_FILE_ID, 'pdf') + await settings.load() + + expect(settings.bookDelta.value).toEqual({ scrollMode: 'vertical', spread: 'auto', zoomMode: 'automatic' }) + expect(JSON.parse(localStorage.getItem(`reader:book:${BOOK_FILE_ID}`) ?? '{}')).toEqual({ + scrollMode: 'vertical', + spread: 'auto', + zoomMode: 'automatic', + }) + }) +}) + describe('useReaderSettings - load() with valid cbx localStorage delta', () => { it('filters out invalid fitMode and keeps valid fields', async () => { const raw = { fitMode: 'stretch', viewMode: 'single' } diff --git a/client/src/features/reader/shared/composables/useReaderSettings.ts b/client/src/features/reader/shared/composables/useReaderSettings.ts index f78fd17fc..380b03034 100644 --- a/client/src/features/reader/shared/composables/useReaderSettings.ts +++ b/client/src/features/reader/shared/composables/useReaderSettings.ts @@ -5,8 +5,10 @@ import { useAuth } from '@/features/auth/composables/useAuth' import { CBX_READER_DEFAULTS, EPUB_READER_DEFAULTS, + PDF_READER_DEFAULTS, type CbxReaderSettings, type EpubReaderSettings, + type PdfReaderSettings, type ReaderFormatGroup, type ReaderSettings, READER_GROUP_DEFAULTS, @@ -138,8 +140,35 @@ function sanitizeCbxPartialSettings(settings: unknown): Partial | null { + if (!isRecord(settings)) return null + + const out: Partial = {} + + if (settings.scrollMode === 'vertical' || settings.scrollMode === 'horizontal' || settings.scrollMode === 'page') { + out.scrollMode = settings.scrollMode + } else if (settings.scrollMode === 'wrapped') { + out.scrollMode = 'vertical' + } + if (settings.spread === 'none' || settings.spread === 'odd' || settings.spread === 'even' || settings.spread === 'auto') { + out.spread = settings.spread + } + if (settings.zoomMode === 'fit-width' || settings.zoomMode === 'fit-page' || settings.zoomMode === 'automatic' || settings.zoomMode === 'custom') { + out.zoomMode = settings.zoomMode + } + if (isNumberInRange(settings.customScale, 0.25, 4)) { + out.customScale = settings.customScale + } + if (settings.rotation === 0 || settings.rotation === 90 || settings.rotation === 180 || settings.rotation === 270) { + out.rotation = settings.rotation + } + + return out +} + function sanitizeBookDelta(group: ReaderFormatGroup, raw: unknown): Partial | null { if (group === 'epub') return sanitizeEpubPartialSettings(raw) as Partial | null + if (group === 'pdf') return sanitizePdfPartialSettings(raw) as Partial | null if (group !== 'cbx') return isRecord(raw) ? (raw as Partial) : null const sanitized = sanitizeCbxPartialSettings(raw) return sanitized as Partial | null @@ -154,6 +183,14 @@ function sanitizeDefaultSettings(group: ReaderFormatGroup, raw: unknown): Reader ...sanitized, } as ReaderSettings } + if (group === 'pdf') { + const sanitized = sanitizePdfPartialSettings(raw) + if (!sanitized) return null + return { + ...PDF_READER_DEFAULTS, + ...sanitized, + } as ReaderSettings + } if (group !== 'cbx') return isRecord(raw) ? (raw as unknown as ReaderSettings) : null const sanitized = sanitizeCbxPartialSettings(raw) diff --git a/client/src/features/settings/PdfSettings.vue b/client/src/features/settings/PdfSettings.vue index c77038268..be7c17661 100644 --- a/client/src/features/settings/PdfSettings.vue +++ b/client/src/features/settings/PdfSettings.vue @@ -18,6 +18,47 @@ const { effective, load, update, reset } = useReaderDefaultSettings effective.value.zoomMode === 'custom') + +function resetSettings() { + reset() +} + +function selectPageLayout() { + update({ scrollMode: 'page' }) +} + +function selectVerticalLayout() { + update({ scrollMode: 'vertical' }) +} + +function selectHorizontalLayout() { + update({ scrollMode: 'horizontal' }) +} + +function selectSingleSpread() { + update({ spread: 'none' }) +} + +function selectOddSpread() { + update({ spread: 'odd' }) +} + +function selectEvenSpread() { + update({ spread: 'even' }) +} + +function selectAutoSpread() { + update({ spread: 'auto' }) +} + +function selectZoomMode(event: Event) { + const mode = (event.currentTarget as HTMLButtonElement).dataset.zoomMode as PdfReaderSettings['zoomMode'] + update({ zoomMode: mode }) +} + +function handleCustomScale(event: Event) { + update({ customScale: Number((event.target as HTMLInputElement).value) }) +}