From 911f61e3dc4f9cb8ebbd860b58d9934915041718 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:44:02 +0000 Subject: [PATCH] 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 --- .github/dependabot.yml | 3 + .../__tests__/usePdfInteractionMode.spec.ts | 55 +++++ .../pdf-v4/__tests__/usePdfPagination.spec.ts | 190 +++++++++++++++++- .../pdf-v4/components/PdfReaderContent.vue | 11 +- .../composables/usePdfInteractionMode.ts | 21 ++ .../pdf-v4/composables/usePdfPagination.ts | 15 +- pnpm-lock.yaml | 18 +- server/package.json | 2 +- 8 files changed, 293 insertions(+), 22 deletions(-) create mode 100644 client/src/features/reader/pdf-v4/__tests__/usePdfInteractionMode.spec.ts create mode 100644 client/src/features/reader/pdf-v4/composables/usePdfInteractionMode.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fc57096f1..305ffaa16 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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" diff --git a/client/src/features/reader/pdf-v4/__tests__/usePdfInteractionMode.spec.ts b/client/src/features/reader/pdf-v4/__tests__/usePdfInteractionMode.spec.ts new file mode 100644 index 000000000..fee23ef4e --- /dev/null +++ b/client/src/features/reader/pdf-v4/__tests__/usePdfInteractionMode.spec.ts @@ -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() + }) +}) diff --git a/client/src/features/reader/pdf-v4/__tests__/usePdfPagination.spec.ts b/client/src/features/reader/pdf-v4/__tests__/usePdfPagination.spec.ts index 8ff9c86d6..138baeb64 100644 --- a/client/src/features/reader/pdf-v4/__tests__/usePdfPagination.spec.ts +++ b/client/src/features/reader/pdf-v4/__tests__/usePdfPagination.spec.ts @@ -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 + touchPageTurningEnabled: Ref + onActivity: ReturnType void>> + scrollToPage: ReturnType 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(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) + let pagination!: ReturnType + + 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, 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() + }) }) diff --git a/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue b/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue index 4f65a1a7f..8ad1d702f 100644 --- a/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue +++ b/client/src/features/reader/pdf-v4/components/PdfReaderContent.vue @@ -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" >