mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(reader): serve PDF and comic files correctly to every client
Fix the server and web gaps found while porting the PDF and comic readers and offline downloads to Android, checked against the iOS app: - KOReader: answer a pull for a PDF or comic with the page number when a BookOrbit client wrote last, instead of a percentage with no page - comics: key the zip offset, RAR and CB7 caches by file id and modification time, so pages are not served from stale offsets after a metadata write-back rewrites the archive - comics: answer an unreadable archive with 422 rather than a page count of 0, and extract CB7 with its folders so same-named pages in different folders no longer overwrite each other - book files: send an ETag and Last-Modified on serve and download and honour If-None-Match and If-Range, clamp a range that runs past the end, answer suffix ranges with 206, and honour Range on download, which advertised it but sent the whole file - book files: leave raw file routes uncompressed, so FB2 and unknown formats keep their Content-Length - progress: reject a fractional or negative page number with 400 rather than failing with 500 at the database - types: declare the comic page-count response shared by the web and the apps - web: open a percentage-only PDF position at its estimated page, and flush pending PDF and comic progress before the reading session ends
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, onUnmounted, ref } from 'vue'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({
|
||||
scrollToIndex: vi.fn<(index: number, options: { align: string }) => void>(),
|
||||
updateBookSettings: vi.fn<(patch: unknown) => void>(),
|
||||
resetBookSettings: vi.fn<() => void>(),
|
||||
sessionEndSnapshots: [] as Array<{ percentage: number; pageNumber?: number | null }>,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
|
||||
@@ -127,8 +128,12 @@ vi.mock('../shared/composables/useReaderProgress', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
// Like the real composable, the session ends from an unmounted hook registered before the view's own.
|
||||
vi.mock('../shared/composables/useReadingSession', () => ({
|
||||
useReadingSession: () => ({ onActivity: vi.fn<() => void>(), elapsedMinutes: ref(0) }),
|
||||
useReadingSession: (_fileId: number, getProgress: () => { percentage: number; pageNumber?: number | null }) => {
|
||||
onUnmounted(() => mocks.sessionEndSnapshots.push(getProgress()))
|
||||
return { onActivity: vi.fn<() => void>(), elapsedMinutes: ref(0) }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../shared/composables/useVisibility', () => ({
|
||||
@@ -157,6 +162,7 @@ describe('CbzReaderView', () => {
|
||||
mocks.savedAutoAdvance = false
|
||||
mocks.seriesId = 42
|
||||
mocks.nextBook = null
|
||||
mocks.sessionEndSnapshots.length = 0
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
@@ -403,6 +409,23 @@ describe('CbzReaderView', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('flushes a debounced page turn before the reading session ends', async () => {
|
||||
mocks.savedMode = 'paginated'
|
||||
mocks.pageCount = 6
|
||||
mocks.savedPageNumber = 2
|
||||
|
||||
const wrapper = shallowMount(CbzReaderView, { props: { bookId: 11, fileId: 22 } })
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||
await nextTick()
|
||||
wrapper.unmount()
|
||||
|
||||
expect(mocks.sessionEndSnapshots).toEqual([{ percentage: 50, pageNumber: 3 }])
|
||||
expect(mocks.progressSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('places the next book after the last page in continuous modes', async () => {
|
||||
mocks.savedMode = 'long-strip'
|
||||
mocks.pageCount = 3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
@@ -779,11 +779,16 @@ onBeforeRouteLeave(async () => {
|
||||
await flushPendingProgress()
|
||||
})
|
||||
|
||||
// Before unmount, because useReadingSession ends the session from its own unmounted hook, which
|
||||
// runs first and would otherwise miss a page turn still inside the save debounce.
|
||||
onBeforeUnmount(() => {
|
||||
void flushPendingProgress()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
if (stripScrollFrame !== null) cancelAnimationFrame(stripScrollFrame)
|
||||
void flushPendingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref } from 'vue'
|
||||
import type { BookDetail } from '@bookorbit/types'
|
||||
import type { BookDetail, ComicPageCountResponse } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/** The series a book is read as part of: its first membership, matching how the book pages order them. */
|
||||
@@ -27,7 +27,7 @@ export function useCbz(fileId: number, bookId: number) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const [pagesData, bookData] = await Promise.all([pagesRes.json(), bookRes.ok ? bookRes.json() : null])
|
||||
const [pagesData, bookData] = await Promise.all([pagesRes.json() as Promise<ComicPageCountResponse>, bookRes.ok ? bookRes.json() : null])
|
||||
pageCount.value = pagesData.pageCount
|
||||
bookTitle.value = bookData?.title ?? ''
|
||||
seriesId.value = primarySeriesId(bookData)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, shallowRef } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, onUnmounted, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { EmbedPDF } from '@embedpdf/core/vue'
|
||||
@@ -70,7 +70,8 @@ const plugins = shallowRef<PluginBatchRegistrations>([])
|
||||
const documentBuffer = shallowRef<ArrayBuffer | null>(null)
|
||||
const readerReady = shallowRef(false)
|
||||
const documentError = shallowRef<Error | null>(null)
|
||||
const initialPage = shallowRef(1)
|
||||
const initialPage = shallowRef<number | null>(null)
|
||||
const initialPercentage = shallowRef(0)
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let settingsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let documentAbortController: AbortController | null = null
|
||||
@@ -127,14 +128,18 @@ function buildPlugins(settings: PdfReaderSettings): PluginBatchRegistrations {
|
||||
]
|
||||
}
|
||||
|
||||
function setPosition(pageNumber: number, totalPages: number) {
|
||||
progress.pageNumber.value = pageNumber
|
||||
progress.percentage.value = totalPages > 0 ? (pageNumber / totalPages) * 100 : progress.percentage.value
|
||||
}
|
||||
|
||||
function applyPendingProgress() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
if (!pendingPageUpdate) return false
|
||||
const { pageNumber, totalPages } = pendingPageUpdate
|
||||
pendingPageUpdate = null
|
||||
progress.pageNumber.value = pageNumber
|
||||
progress.percentage.value = totalPages > 0 ? (pageNumber / totalPages) * 100 : progress.percentage.value
|
||||
setPosition(pageNumber, totalPages)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -142,7 +147,14 @@ function flushProgress() {
|
||||
if (applyPendingProgress()) void progress.save()
|
||||
}
|
||||
|
||||
function handlePageChange(pageNumber: number, totalPages: number) {
|
||||
function handlePageChange(pageNumber: number, totalPages: number, restored = false) {
|
||||
// Landing on the opening page is not a move: writing it back would replace another client's
|
||||
// percentage-only position with a snapped page, so it is only remembered for an explicit save.
|
||||
if (restored) {
|
||||
setPosition(pageNumber, totalPages)
|
||||
onActivity()
|
||||
return
|
||||
}
|
||||
onActivity()
|
||||
pendingPageUpdate = { pageNumber, totalPages }
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
@@ -196,7 +208,9 @@ async function loadReader() {
|
||||
if (buffer.byteLength === 0) throw new Error(t('reader.pdf.emptyDocument'))
|
||||
if (sequence !== loadSequence || abortController.signal.aborted) return
|
||||
const settings = bookSettings.effective.value as PdfReaderSettings
|
||||
initialPage.value = parseDeepLinkPage() ?? progress.pageNumber.value ?? 1
|
||||
const savedPage = progress.pageNumber.value
|
||||
initialPage.value = parseDeepLinkPage() ?? (savedPage !== null && savedPage > 0 ? savedPage : null)
|
||||
initialPercentage.value = progress.percentage.value
|
||||
documentBuffer.value = buffer
|
||||
plugins.value = buildPlugins(settings)
|
||||
readerReady.value = true
|
||||
@@ -259,10 +273,13 @@ function handleRetry() {
|
||||
|
||||
onMounted(loadReader)
|
||||
|
||||
// Before unmount, because useReadingSession ends the session from its own unmounted hook, which
|
||||
// runs first and would otherwise miss a page turn still inside the save debounce.
|
||||
onBeforeUnmount(flushProgress)
|
||||
|
||||
onUnmounted(() => {
|
||||
loadSequence += 1
|
||||
documentAbortController?.abort()
|
||||
flushProgress()
|
||||
flushSettings()
|
||||
})
|
||||
</script>
|
||||
@@ -308,6 +325,7 @@ onUnmounted(() => {
|
||||
:book-id="props.bookId"
|
||||
:file-id="props.fileId"
|
||||
:initial-page="initialPage"
|
||||
:initial-percentage="initialPercentage"
|
||||
:settings="effectiveSettings"
|
||||
:peek-mode="props.peekMode"
|
||||
@back="handleBack"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import type { PdfReaderSettings } from '@bookorbit/types'
|
||||
|
||||
type LayoutReadyEvent = { documentId: string; isInitial: boolean; pageNumber: number; totalPages: number }
|
||||
type PageChangeEvent = { documentId: string; pageNumber: number; totalPages: number }
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
layoutReadyListener: null as ((event: LayoutReadyEvent) => void) | null,
|
||||
pageChangeListener: null as ((event: PageChangeEvent) => void) | null,
|
||||
totalPages: 10,
|
||||
scrollToPage: vi.fn<(options: { pageNumber: number }) => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('@embedpdf/plugin-scroll/vue', async () => {
|
||||
const { ScrollStrategy } = await import('@embedpdf/plugin-scroll')
|
||||
return {
|
||||
ScrollStrategy,
|
||||
useScroll: () => ({
|
||||
state: ref({ currentPage: 1, totalPages: mocks.totalPages }),
|
||||
provides: ref({
|
||||
// EmbedPDF reports the target page synchronously when a programmatic page change starts.
|
||||
scrollToPage: (options: { pageNumber: number }) => {
|
||||
mocks.scrollToPage(options)
|
||||
mocks.pageChangeListener?.({ documentId: 'document-1', pageNumber: options.pageNumber, totalPages: mocks.totalPages })
|
||||
},
|
||||
getSpreadPagesWithRotatedSize: () => [],
|
||||
getPageChangeState: () => ({ isChanging: false }),
|
||||
}),
|
||||
}),
|
||||
useScrollCapability: () => ({
|
||||
provides: ref({
|
||||
onLayoutReady: (listener: (event: LayoutReadyEvent) => void) => {
|
||||
mocks.layoutReadyListener = listener
|
||||
return () => {}
|
||||
},
|
||||
onPageChange: (listener: (event: PageChangeEvent) => void) => {
|
||||
mocks.pageChangeListener = listener
|
||||
return () => {}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@embedpdf/plugin-zoom/vue', async () => {
|
||||
const { ZoomMode } = await import('@embedpdf/plugin-zoom')
|
||||
return { ZoomMode, useZoom: () => ({ state: ref({ currentZoomLevel: 1, zoomLevel: ZoomMode.FitPage }), provides: ref(null) }) }
|
||||
})
|
||||
|
||||
vi.mock('@embedpdf/plugin-spread/vue', async () => {
|
||||
const { SpreadMode } = await import('@embedpdf/plugin-spread')
|
||||
return { SpreadMode, useSpread: () => ({ provides: ref(null) }) }
|
||||
})
|
||||
|
||||
vi.mock('@embedpdf/plugin-rotate/vue', () => ({ useRotate: () => ({ rotation: ref(0), provides: ref(null) }) }))
|
||||
vi.mock('@embedpdf/plugin-selection/vue', () => ({ useSelectionCapability: () => ({ provides: ref(null) }) }))
|
||||
vi.mock('@embedpdf/plugin-annotation/vue', () => ({ useAnnotationCapability: () => ({ provides: ref(null) }) }))
|
||||
vi.mock('@embedpdf/plugin-document-manager/vue', () => ({
|
||||
DocumentContent: defineComponent({ name: 'DocumentContent', props: ['documentId'], setup: () => () => h('div') }),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/usePdfInteractionMode', () => ({
|
||||
usePdfInteractionMode: () => ({ isPanning: ref(false), togglePan: vi.fn<() => void>(), activateTextSelection: vi.fn<() => void>() }),
|
||||
}))
|
||||
vi.mock('../composables/usePdfResponsiveSpread', () => ({ usePdfResponsiveSpread: () => ({ apply: vi.fn<() => void>() }) }))
|
||||
vi.mock('../../shared/composables/useFullscreen', () => ({
|
||||
useFullscreen: () => ({ isFullscreen: ref(false), isFullscreenSupported: ref(false), toggleFullscreen: vi.fn<() => void>() }),
|
||||
}))
|
||||
vi.mock('../composables/usePdfHighlights', () => ({
|
||||
usePdfHighlights: () => ({
|
||||
renderAll: vi.fn<() => void>(),
|
||||
annotations: ref([]),
|
||||
loadError: ref(null),
|
||||
loading: ref(false),
|
||||
loadingMore: ref(false),
|
||||
hasMore: ref(false),
|
||||
popupVisible: ref(false),
|
||||
popupPosition: ref({ x: 0, y: 0 }),
|
||||
popupShowBelow: ref(false),
|
||||
selectedText: ref(''),
|
||||
overlappingAnnotationId: ref(null),
|
||||
isSaving: ref(false),
|
||||
showNoteDialog: ref(false),
|
||||
noteText: ref(''),
|
||||
}),
|
||||
}))
|
||||
|
||||
const { default: PdfReaderContent } = await import('../components/PdfReaderContent.vue')
|
||||
|
||||
const settings: PdfReaderSettings = { scrollMode: 'page', spread: 'none', zoomMode: 'fit-page', customScale: 1, rotation: 0 }
|
||||
|
||||
function mountContent(initialPage: number | null, initialPercentage?: number) {
|
||||
return shallowMount(PdfReaderContent, {
|
||||
props: { documentId: 'document-1', bookId: 42, fileId: 101, initialPage, initialPercentage, settings },
|
||||
})
|
||||
}
|
||||
|
||||
function layoutReady() {
|
||||
mocks.layoutReadyListener?.({ documentId: 'document-1', isInitial: true, pageNumber: 1, totalPages: mocks.totalPages })
|
||||
}
|
||||
|
||||
describe('PdfReaderContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.layoutReadyListener = null
|
||||
mocks.pageChangeListener = null
|
||||
mocks.totalPages = 10
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ initialPage: null, percentage: 37.5, expected: 4 },
|
||||
{ initialPage: null, percentage: 100, expected: 10 },
|
||||
{ initialPage: null, percentage: 0.4, expected: 1 },
|
||||
{ initialPage: null, percentage: 0, expected: 1 },
|
||||
{ initialPage: 3, percentage: 90, expected: 3 },
|
||||
{ initialPage: 25, percentage: 10, expected: 10 },
|
||||
])('opens $initialPage / $percentage% of 10 pages at page $expected', ({ initialPage, percentage, expected }) => {
|
||||
mountContent(initialPage, percentage)
|
||||
layoutReady()
|
||||
|
||||
// Page 1 is where the document already is, so the reader only scrolls for a later page.
|
||||
expect(mocks.scrollToPage.mock.calls[0]?.[0].pageNumber ?? 1).toBe(expected)
|
||||
expect(mocks.scrollToPage.mock.calls.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('marks the opening page as restored and later turns as moves', () => {
|
||||
const wrapper = mountContent(null, 37.5)
|
||||
layoutReady()
|
||||
mocks.pageChangeListener?.({ documentId: 'document-1', pageNumber: 5, totalPages: 10 })
|
||||
|
||||
expect(wrapper.emitted('pageChange')).toEqual([
|
||||
[4, 10, true],
|
||||
[5, 10, false],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { defineComponent, h, onUnmounted, ref } from 'vue'
|
||||
import { ScrollStrategy } from '@embedpdf/plugin-scroll'
|
||||
import { SpreadMode } from '@embedpdf/plugin-spread'
|
||||
import { ZoomMode } from '@embedpdf/plugin-zoom'
|
||||
@@ -54,11 +54,17 @@ vi.mock('../../shared/composables/useReaderProgress', () => ({
|
||||
}))
|
||||
|
||||
const mockOnActivity = vi.fn<() => void>()
|
||||
type SessionSnapshot = { percentage: number; pageNumber?: number | null }
|
||||
const sessionEndSnapshots: SessionSnapshot[] = []
|
||||
// Like the real composable, the session ends from an unmounted hook registered before the view's own.
|
||||
vi.mock('../../shared/composables/useReadingSession', () => ({
|
||||
useReadingSession: () => ({
|
||||
onActivity: mockOnActivity,
|
||||
elapsedMinutes: ref(0),
|
||||
}),
|
||||
useReadingSession: (_fileId: number, getProgress: () => SessionSnapshot) => {
|
||||
onUnmounted(() => sessionEndSnapshots.push(getProgress()))
|
||||
return {
|
||||
onActivity: mockOnActivity,
|
||||
elapsedMinutes: ref(0),
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const mockUsePdfiumEngine = vi.fn<() => unknown>(() => ({
|
||||
@@ -129,6 +135,7 @@ describe('PdfV4ReaderView', () => {
|
||||
mockRegistry.isDestroyed.mockReturnValue(false)
|
||||
mockRegistry.getPlugin.mockReturnValue({ provides: () => mockDocumentManager })
|
||||
mockRoute.query = {}
|
||||
sessionEndSnapshots.length = 0
|
||||
mockProgressPageNumber.value = 5
|
||||
mockProgressPercentage.value = 25
|
||||
mockEffective.value = {
|
||||
@@ -147,7 +154,7 @@ describe('PdfV4ReaderView', () => {
|
||||
stubs: {
|
||||
PdfReaderContent: {
|
||||
name: 'PdfReaderContent',
|
||||
props: ['documentId', 'initialPage', 'settings', 'peekMode'],
|
||||
props: ['documentId', 'initialPage', 'initialPercentage', 'settings', 'peekMode'],
|
||||
emits: ['back', 'pageChange', 'retry', 'settingsChange', 'startReading'],
|
||||
template: '<div class="mock-reader-content" />',
|
||||
},
|
||||
@@ -280,6 +287,57 @@ describe('PdfV4ReaderView', () => {
|
||||
expect(wrapper.getComponent({ name: 'PdfReaderContent' }).props('initialPage')).toBe(12)
|
||||
})
|
||||
|
||||
it('opens at a saved page number before its percentage', async () => {
|
||||
const wrapper = await mountReader()
|
||||
|
||||
expect(wrapper.getComponent({ name: 'PdfReaderContent' }).props('initialPage')).toBe(5)
|
||||
})
|
||||
|
||||
it('passes a percentage-only position through for the page estimate', async () => {
|
||||
mockProgressPageNumber.value = null
|
||||
mockProgressPercentage.value = 37.5
|
||||
const wrapper = await mountReader()
|
||||
|
||||
const content = wrapper.getComponent({ name: 'PdfReaderContent' })
|
||||
expect(content.props('initialPage')).toBeNull()
|
||||
expect(content.props('initialPercentage')).toBe(37.5)
|
||||
})
|
||||
|
||||
it('treats a zero page number as no page so the percentage still places the reader', async () => {
|
||||
mockProgressPageNumber.value = 0
|
||||
mockProgressPercentage.value = 60
|
||||
const wrapper = await mountReader()
|
||||
|
||||
expect(wrapper.getComponent({ name: 'PdfReaderContent' }).props('initialPage')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not write progress for the page the reader opened on', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockProgressPageNumber.value = null
|
||||
mockProgressPercentage.value = 37.5
|
||||
const wrapper = await mountReader()
|
||||
wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('pageChange', 4, 10, true)
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
wrapper.unmount()
|
||||
|
||||
expect(mockProgressSave).not.toHaveBeenCalled()
|
||||
expect(mockProgressPageNumber.value).toBe(4)
|
||||
expect(mockProgressPercentage.value).toBe(40)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('flushes a debounced page turn before the reading session ends', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = await mountReader()
|
||||
wrapper.getComponent({ name: 'PdfReaderContent' }).vm.$emit('pageChange', 8, 20)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
wrapper.unmount()
|
||||
|
||||
expect(sessionEndSnapshots).toEqual([{ percentage: 40, pageNumber: 8 }])
|
||||
expect(mockProgressSave).toHaveBeenCalledOnce()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('debounces page progress saves', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = await mountReader()
|
||||
|
||||
@@ -32,7 +32,8 @@ const props = defineProps<{
|
||||
documentId: string
|
||||
bookId: number
|
||||
fileId: number
|
||||
initialPage: number
|
||||
initialPage: number | null
|
||||
initialPercentage?: number
|
||||
settings: PdfReaderSettings
|
||||
peekMode?: boolean
|
||||
}>()
|
||||
@@ -41,7 +42,7 @@ const { t } = useI18n()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
pageChange: [pageNumber: number, totalPages: number]
|
||||
pageChange: [pageNumber: number, totalPages: number, restored: boolean]
|
||||
retry: []
|
||||
settingsChange: [patch: Partial<PdfReaderSettings>]
|
||||
startReading: []
|
||||
@@ -61,6 +62,7 @@ const sidebar = usePdfSidebarLayout()
|
||||
const settingsOpen = ref(false)
|
||||
const pendingExternalUrl = ref<URL | null>(null)
|
||||
const restoredInitialPage = ref(false)
|
||||
let restoringPage: number | null = null
|
||||
const viewerSurface = ref<HTMLElement | null>(null)
|
||||
const selectionPopup = ref<{ getElement: () => HTMLElement | null } | null>(null)
|
||||
const currentScrollMode = ref<PdfReaderSettings['scrollMode']>(props.settings.scrollMode)
|
||||
@@ -250,6 +252,17 @@ function handleRetryDocument() {
|
||||
emit('retry')
|
||||
}
|
||||
|
||||
// A percentage without a page number was written by a client that tracks no pages, so the page
|
||||
// is estimated from it the way the iOS reader does.
|
||||
function resolveInitialPage(totalPages: number) {
|
||||
const total = Math.max(totalPages, 1)
|
||||
const percentage = props.initialPercentage ?? 0
|
||||
let page = 1
|
||||
if (props.initialPage !== null) page = props.initialPage
|
||||
else if (Number.isFinite(percentage) && percentage > 0) page = Math.round((percentage / 100) * total)
|
||||
return Math.min(Math.max(page, 1), total)
|
||||
}
|
||||
|
||||
function hasDocumentPages(documentState: DocumentState) {
|
||||
return (documentState.document?.pageCount ?? 0) > 0
|
||||
}
|
||||
@@ -326,14 +339,19 @@ watch(
|
||||
if (event.documentId !== props.documentId || !event.isInitial) return
|
||||
highlights.renderAll()
|
||||
if (restoredInitialPage.value) return
|
||||
const page = Math.min(Math.max(props.initialPage, 1), event.totalPages || 1)
|
||||
const page = resolveInitialPage(event.totalPages)
|
||||
restoredInitialPage.value = true
|
||||
if (page > 1) scroll.value?.scrollToPage({ pageNumber: page })
|
||||
if (page > 1) {
|
||||
restoringPage = page
|
||||
scroll.value?.scrollToPage({ pageNumber: page })
|
||||
}
|
||||
})
|
||||
const unsubscribePage = capability.onPageChange((event) => {
|
||||
if (event.documentId !== props.documentId || !restoredInitialPage.value) return
|
||||
const restored = event.pageNumber === restoringPage
|
||||
restoringPage = null
|
||||
const range = getPageRange(event.pageNumber, event.totalPages)
|
||||
emit('pageChange', range.end, event.totalPages)
|
||||
emit('pageChange', range.end, event.totalPages, restored)
|
||||
})
|
||||
onCleanup(() => {
|
||||
unsubscribeLayout()
|
||||
|
||||
Reference in New Issue
Block a user