From 7a605a357ad46195644f5cb695eb26ad724e69e4 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 12 Mar 2026 23:07:55 +0800 Subject: [PATCH] feat(pdf): support pitch to zoom and scroll mode for PDFs, closes #3461 (#3520) --- ...aryUtils.test.ts => library-utils.test.ts} | 0 .../{ttsTime.test.ts => tts-time.test.ts} | 0 .../src/app/reader/components/ViewMenu.tsx | 2 +- .../reader/components/annotator/Annotator.tsx | 1 - .../src/app/reader/hooks/useBookShortcuts.ts | 14 +++- .../src/app/reader/hooks/useIframeEvents.ts | 65 +++++++++++++++++-- .../src/app/reader/hooks/usePagination.ts | 5 +- .../app/reader/utils/iframeEventHandlers.ts | 22 ++++--- apps/readest-app/src/types/view.ts | 2 + packages/foliate-js | 2 +- 10 files changed, 93 insertions(+), 20 deletions(-) rename apps/readest-app/src/__tests__/utils/{libraryUtils.test.ts => library-utils.test.ts} (100%) rename apps/readest-app/src/__tests__/utils/{ttsTime.test.ts => tts-time.test.ts} (100%) diff --git a/apps/readest-app/src/__tests__/utils/libraryUtils.test.ts b/apps/readest-app/src/__tests__/utils/library-utils.test.ts similarity index 100% rename from apps/readest-app/src/__tests__/utils/libraryUtils.test.ts rename to apps/readest-app/src/__tests__/utils/library-utils.test.ts diff --git a/apps/readest-app/src/__tests__/utils/ttsTime.test.ts b/apps/readest-app/src/__tests__/utils/tts-time.test.ts similarity index 100% rename from apps/readest-app/src/__tests__/utils/ttsTime.test.ts rename to apps/readest-app/src/__tests__/utils/tts-time.test.ts diff --git a/apps/readest-app/src/app/reader/components/ViewMenu.tsx b/apps/readest-app/src/app/reader/components/ViewMenu.tsx index 6c85111d..5f661183 100644 --- a/apps/readest-app/src/app/reader/components/ViewMenu.tsx +++ b/apps/readest-app/src/app/reader/components/ViewMenu.tsx @@ -113,6 +113,7 @@ const ViewMenu: React.FC = ({ bookKey, setIsDropdownOpen }) => { }, [isScrolledMode]); useEffect(() => { + if (zoomLevel === viewSettings.zoomLevel) return; saveViewSettings(envConfig, bookKey, 'zoomLevel', zoomLevel, true, true); if (bookData.bookDoc?.rendition?.layout === 'pre-paginated') { getView(bookKey)?.renderer.setAttribute('scale-factor', zoomLevel); @@ -273,7 +274,6 @@ const ViewMenu: React.FC = ({ bookKey, setIsDropdownOpen }) => { shortcut='Shift+J' Icon={isScrolledMode ? MdCheck : undefined} onClick={toggleScrolledMode} - disabled={bookData.isFixedLayout} /> diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index c031b474..e65b5c49 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -908,7 +908,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { tooltipText: _(label), Icon, onClick: handleSearch, - disabled: bookData.book?.format === 'PDF', }; case 'dictionary': return { tooltipText: _(label), Icon, onClick: handleDictionary }; diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts index 2bd3ad2b..21fab87b 100644 --- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -179,9 +179,10 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = const viewSettings = getViewSettings(sideBarBookKey)!; viewSettings!.zoomLevel = zoomLevel; setViewSettings(sideBarBookKey, viewSettings!); - view?.renderer.setStyles?.(getStyles(viewSettings!)); - if (bookData?.bookDoc?.rendition?.layout === 'pre-paginated') { + if (bookData?.isFixedLayout) { view?.renderer.setAttribute('scale-factor', zoomLevel); + } else { + view?.renderer.setStyles?.(getStyles(viewSettings!)); } }; @@ -246,14 +247,23 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = return true; }; + const handlePinchZoom = (event: CustomEvent) => { + const zoomLevel = event.detail?.zoomLevel; + if (zoomLevel != null) { + applyZoomLevel(zoomLevel); + } + }; + useEffect(() => { eventDispatcher.on('zoom-in', handleZoomIn); eventDispatcher.on('zoom-out', handleZoomOut); eventDispatcher.on('reset-zoom', resetZoom); + eventDispatcher.on('pinch-zoom', handlePinchZoom); return () => { eventDispatcher.off('zoom-in', handleZoomIn); eventDispatcher.off('zoom-out', handleZoomOut); eventDispatcher.off('reset-zoom', resetZoom); + eventDispatcher.off('pinch-zoom', handlePinchZoom); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sideBarBookKey]); diff --git a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts index 37b76579..c28af508 100644 --- a/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts +++ b/apps/readest-app/src/app/reader/hooks/useIframeEvents.ts @@ -4,6 +4,7 @@ import { useBookDataStore } from '@/store/bookDataStore'; import { debounce } from '@/utils/debounce'; import { ScrollSource } from './usePagination'; import { eventDispatcher } from '@/utils/event'; +import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL } from '@/services/constants'; export const useMouseEvent = ( bookKey: string, @@ -97,23 +98,61 @@ export const useTouchEvent = ( handleContinuousScroll: (source: ScrollSource, delta: number, threshold: number) => void, ) => { const { getBookData } = useBookDataStore(); - const { hoveredBookKey, setHoveredBookKey, getViewSettings } = useReaderStore(); + const { hoveredBookKey, setHoveredBookKey, getViewSettings, getView } = useReaderStore(); const touchStartRef = useRef(null); const touchEndRef = useRef(null); const touchStartTimeRef = useRef(null); const touchEndTimeRef = useRef(null); + const isPinchingRef = useRef(false); + const initialPinchDistRef = useRef(0); + const initialZoomRef = useRef(100); + const lastPinchRatioRef = useRef(1); + + const getTouchDistance = (t0: IframeTouch, t1: IframeTouch) => { + // Use screenX/screenY instead of clientX/clientY because pinchZoom + // applies a CSS transform to the iframe's parent, which changes the + // iframe's coordinate space and causes clientX/clientY to oscillate + const dx = t1.screenX - t0.screenX; + const dy = t1.screenY - t0.screenY; + return Math.sqrt(dx * dx + dy * dy); + }; const onTouchStart = (e: IframeTouchEvent | React.TouchEvent) => { - const touch = e.targetTouches[0]; - if (!touch) return; - touchStartRef.current = touch; + const t0 = e.targetTouches[0] as IframeTouch | undefined; + const t1 = e.targetTouches[1] as IframeTouch | undefined; + if (t0 && t1) { + const bookData = getBookData(bookKey); + if (bookData?.isFixedLayout) { + isPinchingRef.current = true; + initialPinchDistRef.current = getTouchDistance(t0, t1); + initialZoomRef.current = getViewSettings(bookKey)?.zoomLevel ?? 100; + lastPinchRatioRef.current = 1; + touchStartRef.current = null; + touchEndRef.current = null; + return; + } + } + if (!t0) return; + touchStartRef.current = t0; touchStartTimeRef.current = 'timeStamp' in e ? e.timeStamp : Date.now(); }; const onTouchMove = (e: IframeTouchEvent | React.TouchEvent) => { + const t0 = e.targetTouches[0] as IframeTouch | undefined; + const t1 = e.targetTouches[1] as IframeTouch | undefined; + if (isPinchingRef.current && t0 && t1) { + const currentDist = getTouchDistance(t0, t1); + if (initialPinchDistRef.current > 0) { + const ratio = currentDist / initialPinchDistRef.current; + lastPinchRatioRef.current = ratio; + const renderer = getView(bookKey)?.renderer; + renderer?.pinchZoom?.(ratio); + } + return; + } if (!touchStartRef.current) return; - const touch = e.targetTouches[0]; + const touch = t0; if (touch) { touchEndRef.current = touch; touchEndTimeRef.current = 'timeStamp' in e ? e.timeStamp : Date.now(); @@ -135,6 +174,22 @@ export const useTouchEvent = ( }; const onTouchEnd = (e: IframeTouchEvent | React.TouchEvent) => { + if (isPinchingRef.current) { + const t0 = e.targetTouches[0] as IframeTouch | undefined; + const t1 = e.targetTouches[1] as IframeTouch | undefined; + if (t0 && t1) return; // still pinching with 2+ fingers + isPinchingRef.current = false; + const renderer = getView(bookKey)?.renderer; + if (renderer && initialPinchDistRef.current > 0) { + renderer.pinchEnd?.(); + const newZoom = Math.round(initialZoomRef.current * lastPinchRatioRef.current); + const clampedZoom = Math.max(MIN_ZOOM_LEVEL, Math.min(MAX_ZOOM_LEVEL, newZoom)); + eventDispatcher.dispatch('pinch-zoom', { zoomLevel: clampedZoom }); + } + touchStartRef.current = null; + touchEndRef.current = null; + return; + } if (!touchStartRef.current) return; const touch = e.targetTouches[0]; diff --git a/apps/readest-app/src/app/reader/hooks/usePagination.ts b/apps/readest-app/src/app/reader/hooks/usePagination.ts index df93f769..5eea24d6 100644 --- a/apps/readest-app/src/app/reader/hooks/usePagination.ts +++ b/apps/readest-app/src/app/reader/hooks/usePagination.ts @@ -213,6 +213,7 @@ export const usePagination = ( } else if ( msg.type === 'touch-swipe' && bookData.isFixedLayout && + !viewSettings?.scrolled && !isPanningView(viewRef.current, viewSettings) ) { const { deltaX, deltaY, deltaT } = msg.detail; @@ -245,8 +246,8 @@ export const usePagination = ( const renderer = viewRef.current?.renderer; const viewSettings = getViewSettings(bookKey)!; const bookData = getBookData(bookKey)!; - // Currently continuous scroll is not supported in pre-paginated layout - if (bookData.bookDoc?.rendition?.layout === 'pre-paginated') return; + // Continuous scroll is not supported in pre-paginated layout unless scrolled mode is active + if (bookData.bookDoc?.rendition?.layout === 'pre-paginated' && !viewSettings.scrolled) return; if (renderer && viewSettings.scrolled && viewSettings.continuousScroll) { const doScroll = () => { diff --git a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts index 0be62a3d..11698603 100644 --- a/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts +++ b/apps/readest-app/src/app/reader/utils/iframeEventHandlers.ts @@ -234,15 +234,21 @@ export const handleClick = ( }; const handleTouchEv = (bookKey: string, event: TouchEvent, type: string) => { - const touch = event.targetTouches[0]; + // Use event.touches (all active touches) instead of event.targetTouches + // so that multi-finger gestures work even when fingers land on different + // elements within the iframe (e.g. canvas vs textLayer spans in PDF) + const touchList = type === 'iframe-touchend' ? event.targetTouches : event.touches; const touches = []; - if (touch) { - touches.push({ - clientX: touch.clientX, - clientY: touch.clientY, - screenX: touch.screenX, - screenY: touch.screenY, - }); + for (let i = 0; i < touchList.length; i++) { + const touch = touchList[i]; + if (touch) { + touches.push({ + clientX: touch.clientX, + clientY: touch.clientY, + screenX: touch.screenX, + screenY: touch.screenY, + }); + } } window.postMessage( { diff --git a/apps/readest-app/src/types/view.ts b/apps/readest-app/src/types/view.ts index 489eda3d..14f5ff24 100644 --- a/apps/readest-app/src/types/view.ts +++ b/apps/readest-app/src/types/view.ts @@ -94,6 +94,8 @@ export interface FoliateView extends HTMLElement { options: { isVertical: boolean; color: string; radius: number }, ) => void; hideLoupe?: () => void; + pinchZoom?: (ratio: number) => void; + pinchEnd?: () => void; }; } diff --git a/packages/foliate-js b/packages/foliate-js index 345b246c..81e37d2a 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 345b246cba9bb822d44e7cff69f6dea6255a2c25 +Subproject commit 81e37d2a84ed36694df521883b51953ef76a7d6e