fix(reader): preserve touch text selection in PDF reader

Route touch selection through EmbedPDF pointer mode while keeping swipe pagination limited to pan mode. Keep NestJS testing aligned with the application major and prevent incompatible standalone major updates.

Fixes #1388
This commit is contained in:
neon
2026-09-13 18:44:02 +00:00
parent 2f22abe03e
commit 911f61e3dc
8 changed files with 293 additions and 22 deletions
+3
View File
@@ -18,6 +18,9 @@ updates:
# archiver v8 is ESM-only with a completely different API; pin to v7 until we migrate
- dependency-name: "archiver"
update-types: ["version-update:semver-major"]
# @nestjs/testing must stay on the same major as @nestjs/common and @nestjs/core
- dependency-name: "@nestjs/testing"
update-types: ["version-update:semver-major"]
groups:
production-dependencies:
dependency-type: "production"
@@ -0,0 +1,55 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockUsePan = vi.hoisted(() => vi.fn<() => unknown>())
const mockUseInteractionManager = vi.hoisted(() => vi.fn<() => unknown>())
vi.mock('@embedpdf/plugin-pan/vue', () => ({ usePan: mockUsePan }))
vi.mock('@embedpdf/plugin-interaction-manager/vue', () => ({ useInteractionManager: mockUseInteractionManager }))
const { usePdfInteractionMode } = await import('../composables/usePdfInteractionMode')
describe('usePdfInteractionMode', () => {
const togglePan = vi.fn<() => void>()
const disablePan = vi.fn<() => void>()
const activate = vi.fn<(mode: string) => void>()
const isPanning = ref(true)
beforeEach(() => {
vi.clearAllMocks()
mockUsePan.mockReturnValue({
isPanning,
provides: ref({ togglePan, disablePan }),
})
mockUseInteractionManager.mockReturnValue({
provides: ref({ activate }),
})
})
it('activates pointer mode directly when selecting text on a touch device', () => {
const mode = usePdfInteractionMode(ref('document-1'))
mode.activateTextSelection()
expect(activate).toHaveBeenCalledExactlyOnceWith('pointerMode')
expect(disablePan).not.toHaveBeenCalled()
expect(mode.isPanning).toBe(isPanning)
})
it('toggles pan through the document-scoped pan capability', () => {
const mode = usePdfInteractionMode(ref('document-1'))
mode.togglePan()
expect(togglePan).toHaveBeenCalledOnce()
})
it('keeps tool actions safe while the document capabilities are unavailable', () => {
mockUsePan.mockReturnValue({ isPanning, provides: ref(null) })
mockUseInteractionManager.mockReturnValue({ provides: ref(null) })
const mode = usePdfInteractionMode(ref('document-1'))
expect(() => mode.togglePan()).not.toThrow()
expect(() => mode.activateTextSelection()).not.toThrow()
})
})
@@ -1,9 +1,87 @@
import { defineComponent, h, ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, ref, type Ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { Rotation, type PdfPageObjectWithRotatedSize } from '@embedpdf/models'
import type { ScrollScope } from '@embedpdf/plugin-scroll'
import type { PdfReaderSettings } from '@bookorbit/types'
import { usePdfPagination } from '../composables/usePdfPagination'
interface PaginationHarness {
wrapper: VueWrapper
pagination: ReturnType<typeof usePdfPagination>
touchPageTurningEnabled: Ref<boolean>
onActivity: ReturnType<typeof vi.fn<() => void>>
scrollToPage: ReturnType<typeof vi.fn<(options: { pageNumber: number; behavior: 'instant' | 'smooth' }) => void>>
}
function page(index: number): PdfPageObjectWithRotatedSize {
return {
index,
size: { width: 600, height: 800 },
rotatedSize: { width: 600, height: 800 },
rotation: Rotation.Degree0,
objectNumber: index + 1,
}
}
function touchEvent(type: 'touchstart' | 'touchend', touches: Array<{ clientX: number; clientY: number }>, cancelable = true): TouchEvent {
const event = new Event(type, { bubbles: true, cancelable }) as TouchEvent
Object.defineProperties(event, {
touches: { value: type === 'touchstart' ? touches : [] },
changedTouches: { value: touches },
})
return event
}
function mountPagination({
scrollMode = 'page',
touchTurning = true,
currentPage = 2,
totalPages = 3,
}: {
scrollMode?: PdfReaderSettings['scrollMode']
touchTurning?: boolean
currentPage?: number
totalPages?: number
} = {}): PaginationHarness {
const mode = ref<PdfReaderSettings['scrollMode']>(scrollMode)
const touchPageTurningEnabled = ref(touchTurning)
const onActivity = vi.fn<() => void>()
const scrollToPage = vi.fn<(options: { pageNumber: number; behavior: 'instant' | 'smooth' }) => void>()
const scroll = ref({
getSpreadPagesWithRotatedSize: () => [[page(0)], [page(1)], [page(2)]],
getPageChangeState: () => ({ isChanging: false }),
getMetrics: () => ({ pageVisibilityMetrics: [] }),
scrollToPage,
} as unknown as Readonly<ScrollScope>)
let pagination!: ReturnType<typeof usePdfPagination>
const wrapper = mount(
defineComponent({
setup() {
pagination = usePdfPagination({
mode,
scrollState: ref({ currentPage, totalPages }),
scroll,
touchPageTurningEnabled,
onActivity,
})
return () => h('div')
},
}),
)
return { wrapper, pagination, touchPageTurningEnabled, onActivity, scrollToPage }
}
function swipe(pagination: ReturnType<typeof usePdfPagination>, from: [number, number], to: [number, number]) {
const start = touchEvent('touchstart', [{ clientX: from[0], clientY: from[1] }])
const end = touchEvent('touchend', [{ clientX: to[0], clientY: to[1] }])
pagination.handleTouchStart(start)
pagination.handleTouchEnd(end)
return { start, end }
}
describe('usePdfPagination', () => {
it('stays stable while EmbedPDF unloads the document scope', () => {
const scroll = ref({
@@ -23,6 +101,7 @@ describe('usePdfPagination', () => {
mode: ref('page'),
scrollState: ref({ currentPage: 20, totalPages: 240 }),
scroll,
touchPageTurningEnabled: ref(true),
onActivity: () => {},
})
return () => h('div')
@@ -36,4 +115,109 @@ describe('usePdfPagination', () => {
wrapper.unmount()
})
it.each([
{ name: 'previous', from: [100, 100], to: [180, 100], pageNumber: 1 },
{ name: 'next', from: [180, 100], to: [100, 100], pageNumber: 3 },
] as const)('turns to the $name page for a horizontal pan swipe', ({ from, to, pageNumber }) => {
const harness = mountPagination()
const { end } = swipe(harness.pagination, [...from], [...to])
expect(end.defaultPrevented).toBe(true)
expect(harness.scrollToPage).toHaveBeenCalledExactlyOnceWith({ pageNumber, behavior: 'smooth' })
expect(harness.onActivity).toHaveBeenCalledOnce()
harness.wrapper.unmount()
})
it('turns the page without preventing a non-cancelable touchend', () => {
const harness = mountPagination()
const start = touchEvent('touchstart', [{ clientX: 180, clientY: 100 }])
const end = touchEvent('touchend', [{ clientX: 100, clientY: 100 }], false)
harness.pagination.handleTouchStart(start)
harness.pagination.handleTouchEnd(end)
expect(end.defaultPrevented).toBe(false)
expect(harness.scrollToPage).toHaveBeenCalledExactlyOnceWith({ pageNumber: 3, behavior: 'smooth' })
harness.wrapper.unmount()
})
it('leaves touch drags available to text selection when the pan tool is inactive', () => {
const harness = mountPagination({ touchTurning: false })
const first = swipe(harness.pagination, [180, 100], [100, 100])
expect(first.end.defaultPrevented).toBe(false)
expect(harness.scrollToPage).not.toHaveBeenCalled()
expect(harness.onActivity).toHaveBeenCalledOnce()
harness.touchPageTurningEnabled.value = true
const staleEnd = touchEvent('touchend', [{ clientX: 100, clientY: 100 }])
harness.pagination.handleTouchEnd(staleEnd)
expect(staleEnd.defaultPrevented).toBe(false)
expect(harness.scrollToPage).not.toHaveBeenCalled()
harness.wrapper.unmount()
})
it('does not finish a page turn after switching from pan to text selection mid-gesture', () => {
const harness = mountPagination()
harness.pagination.handleTouchStart(touchEvent('touchstart', [{ clientX: 180, clientY: 100 }]))
harness.touchPageTurningEnabled.value = false
const end = touchEvent('touchend', [{ clientX: 100, clientY: 100 }])
harness.pagination.handleTouchEnd(end)
expect(end.defaultPrevented).toBe(false)
expect(harness.scrollToPage).not.toHaveBeenCalled()
harness.wrapper.unmount()
})
it.each([
{ name: 'short', from: [100, 100], to: [159, 100] },
{ name: 'mostly vertical', from: [100, 100], to: [180, 180] },
] as const)('ignores $name touch gestures', ({ from, to }) => {
const harness = mountPagination()
const { end } = swipe(harness.pagination, [...from], [...to])
expect(end.defaultPrevented).toBe(false)
expect(harness.scrollToPage).not.toHaveBeenCalled()
harness.wrapper.unmount()
})
it('ignores touch page turns outside paginated mode', () => {
const harness = mountPagination({ scrollMode: 'vertical' })
const { end } = swipe(harness.pagination, [180, 100], [100, 100])
expect(end.defaultPrevented).toBe(false)
expect(harness.scrollToPage).not.toHaveBeenCalled()
harness.wrapper.unmount()
})
it('clears a pending swipe after touch cancellation or a multi-touch start', () => {
const harness = mountPagination()
harness.pagination.handleTouchStart(touchEvent('touchstart', [{ clientX: 180, clientY: 100 }]))
harness.pagination.handleTouchCancel()
const canceledEnd = touchEvent('touchend', [{ clientX: 100, clientY: 100 }])
harness.pagination.handleTouchEnd(canceledEnd)
harness.pagination.handleTouchStart(
touchEvent('touchstart', [
{ clientX: 180, clientY: 100 },
{ clientX: 200, clientY: 100 },
]),
)
const multiTouchEnd = touchEvent('touchend', [{ clientX: 100, clientY: 100 }])
harness.pagination.handleTouchEnd(multiTouchEnd)
expect(canceledEnd.defaultPrevented).toBe(false)
expect(multiTouchEnd.defaultPrevented).toBe(false)
expect(harness.scrollToPage).not.toHaveBeenCalled()
harness.wrapper.unmount()
})
})
@@ -6,7 +6,6 @@ import type { DocumentState } from '@embedpdf/core'
import { PdfErrorCode } from '@embedpdf/models'
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/vue'
import { DocumentContent } from '@embedpdf/plugin-document-manager/vue'
import { usePan } from '@embedpdf/plugin-pan/vue'
import { useRotate } from '@embedpdf/plugin-rotate/vue'
import { ScrollStrategy, useScroll, useScrollCapability } from '@embedpdf/plugin-scroll/vue'
import { useSelectionCapability } from '@embedpdf/plugin-selection/vue'
@@ -22,6 +21,7 @@ import PdfReaderToolbar from './PdfReaderToolbar.vue'
import { fromRotation, safeExternalPdfUrl } from '../pdf-viewer-utils'
import { usePdfFullscreenChrome } from '../composables/usePdfFullscreenChrome'
import { usePdfHighlights } from '../composables/usePdfHighlights'
import { usePdfInteractionMode } from '../composables/usePdfInteractionMode'
import { usePdfPagination } from '../composables/usePdfPagination'
import { usePdfResponsiveSpread } from '../composables/usePdfResponsiveSpread'
import { usePdfSidebarLayout, type PdfSidebarTab } from '../composables/usePdfSidebarLayout'
@@ -52,7 +52,7 @@ const { provides: scrollCapability } = useScrollCapability()
const { state: zoomState, provides: zoom } = useZoom(() => props.documentId)
const { provides: spread } = useSpread(() => props.documentId)
const { rotation, provides: rotate } = useRotate(() => props.documentId)
const { isPanning, provides: pan } = usePan(() => props.documentId)
const { isPanning, togglePan, activateTextSelection } = usePdfInteractionMode(() => props.documentId)
const { provides: selectionCapability } = useSelectionCapability()
const { provides: annotationCapability } = useAnnotationCapability()
const { isFullscreen, isFullscreenSupported, toggleFullscreen } = useFullscreen()
@@ -91,10 +91,12 @@ const {
handleWheel: handleViewportWheel,
handleTouchStart,
handleTouchEnd,
handleTouchCancel,
} = usePdfPagination({
mode: currentScrollMode,
scrollState,
scroll,
touchPageTurningEnabled: isPanning,
onActivity: revealHeader,
})
const { apply: applyResponsiveSpread } = usePdfResponsiveSpread(viewerSurface, currentSpreadPreference, spread)
@@ -135,11 +137,11 @@ function handleSidebarWidth(width: number) {
}
function handleTogglePan() {
pan.value?.togglePan()
togglePan()
}
function handleSelectTool() {
pan.value?.disablePan()
activateTextSelection()
}
function handleSettingsOpen(open: boolean) {
@@ -475,6 +477,7 @@ onUnmounted(() => {
@wheel="handleViewportWheel"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
>
<DocumentContent :document-id="props.documentId">
<template #default="{ documentState, isLoading, isError, isLoaded }">
@@ -0,0 +1,21 @@
import type { MaybeRefOrGetter } from 'vue'
import { useInteractionManager } from '@embedpdf/plugin-interaction-manager/vue'
import { usePan } from '@embedpdf/plugin-pan/vue'
const TEXT_SELECTION_MODE = 'pointerMode'
export function usePdfInteractionMode(documentId: MaybeRefOrGetter<string>) {
const { isPanning, provides: pan } = usePan(documentId)
const { provides: interactionManager } = useInteractionManager(documentId)
function togglePan() {
pan.value?.togglePan()
}
function activateTextSelection() {
// EmbedPDF makes pan the default on touch devices, so disablePan() would reactivate pan.
interactionManager.value?.activate(TEXT_SELECTION_MODE)
}
return { isPanning, togglePan, activateTextSelection }
}
@@ -9,10 +9,11 @@ export interface UsePdfPaginationOptions {
mode: ReadonlyRef<PdfReaderSettings['scrollMode']>
scrollState: ReadonlyRef<{ currentPage: number; totalPages: number }>
scroll: ReadonlyRef<Readonly<ScrollScope> | null>
touchPageTurningEnabled: ReadonlyRef<boolean>
onActivity: () => void
}
export function usePdfPagination({ mode, scrollState, scroll, onActivity }: UsePdfPaginationOptions) {
export function usePdfPagination({ mode, scrollState, scroll, touchPageTurningEnabled, onActivity }: UsePdfPaginationOptions) {
let wheelDelta = 0
let wheelUnlockTimer: ReturnType<typeof setTimeout> | null = null
let touchStart: { x: number; y: number } | null = null
@@ -119,7 +120,7 @@ export function usePdfPagination({ mode, scrollState, scroll, onActivity }: UseP
function handleTouchStart(event: TouchEvent) {
onActivity()
if (event.touches.length !== 1) {
if (mode.value !== 'page' || !touchPageTurningEnabled.value || event.touches.length !== 1) {
touchStart = null
return
}
@@ -127,7 +128,7 @@ export function usePdfPagination({ mode, scrollState, scroll, onActivity }: UseP
}
function handleTouchEnd(event: TouchEvent) {
if (mode.value !== 'page' || !touchStart || event.changedTouches.length !== 1) {
if (mode.value !== 'page' || !touchPageTurningEnabled.value || !touchStart || event.changedTouches.length !== 1) {
touchStart = null
return
}
@@ -138,13 +139,17 @@ export function usePdfPagination({ mode, scrollState, scroll, onActivity }: UseP
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()
if (event.cancelable) event.preventDefault()
turnPage(direction)
}
function handleTouchCancel() {
touchStart = null
}
onUnmounted(() => {
if (wheelUnlockTimer) clearTimeout(wheelUnlockTimer)
})
return { pageRange, getPageRange, goToPage, previousPage, nextPage, handleWheel, handleTouchStart, handleTouchEnd }
return { pageRange, getPageRange, goToPage, previousPage, nextPage, handleWheel, handleTouchStart, handleTouchEnd, handleTouchCancel }
}
+9 -9
View File
@@ -516,8 +516,8 @@ importers:
specifier: ^11.1.0
version: 11.1.0(chokidar@5.0.0)(prettier@3.9.6)(typescript@6.0.3)
'@nestjs/testing':
specifier: ^12.0.1
version: 12.0.1(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.3)
specifier: ^11.2.3
version: 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.3)
'@swc/cli':
specifier: ^0.8.1
version: 0.8.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(chokidar@5.0.0)(supports-color@10.2.2)
@@ -3090,13 +3090,13 @@ packages:
typeorm:
optional: true
'@nestjs/testing@12.0.1':
resolution: {integrity: sha512-prf3fsUaoKS6t04XcQt5aSTp68Po1GxUcbEBPv+3iqAef07hKQEO15r0jmmwA+rWZfONYT1UcV3YIylR++/tCg==}
'@nestjs/testing@11.2.3':
resolution: {integrity: sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==}
peerDependencies:
'@nestjs/common': ^12.0.0
'@nestjs/core': ^12.0.0
'@nestjs/microservices': ^12.0.0
'@nestjs/platform-express': ^12.0.0
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/microservices': ^11.0.0
'@nestjs/platform-express': ^11.0.0
peerDependenciesMeta:
'@nestjs/microservices':
optional: true
@@ -11851,7 +11851,7 @@ snapshots:
reflect-metadata: 0.2.2
rxjs: 7.8.2
'@nestjs/testing@12.0.1(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.3)':
'@nestjs/testing@11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.2.3)':
dependencies:
'@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2)
'@nestjs/core': 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/websockets@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+1 -1
View File
@@ -114,7 +114,7 @@
"@ffprobe-installer/ffprobe": "^2.1.2",
"@nestjs/cli": "^11.0.24",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^12.0.1",
"@nestjs/testing": "^11.2.3",
"@swc/cli": "^0.8.1",
"@swc/core": "^1.16.1",
"@types/bcrypt": "^6.0.0",