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" >