From b28ac99a9e08a150c914bac160c75438af35dec1 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 17 Jan 2026 09:11:33 +0100 Subject: [PATCH] feat(pdf): support panning on PDFs (#2981) This also closes #2978 and closes #2875. --- .../app/reader/components/ProgressInfo.tsx | 6 ++- .../src/app/reader/components/ViewMenu.tsx | 5 +- .../components/footerbar/DesktopFooterBar.tsx | 2 +- .../reader/components/footerbar/FooterBar.tsx | 9 ++-- .../src/app/reader/hooks/useBookShortcuts.ts | 48 +++++++---------- .../src/app/reader/hooks/usePagination.ts | 51 ++++++++++++++++--- .../src/components/settings/ControlPanel.tsx | 2 + apps/readest-app/src/helpers/shortcuts.ts | 12 ++--- apps/readest-app/src/types/view.ts | 3 ++ packages/foliate-js | 2 +- 10 files changed, 88 insertions(+), 52 deletions(-) diff --git a/apps/readest-app/src/app/reader/components/ProgressInfo.tsx b/apps/readest-app/src/app/reader/components/ProgressInfo.tsx index 4b2a98a6..b6f1606b 100644 --- a/apps/readest-app/src/app/reader/components/ProgressInfo.tsx +++ b/apps/readest-app/src/app/reader/components/ProgressInfo.tsx @@ -115,15 +115,17 @@ const ProgressInfoView: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [progressInfoMode]); + const isMobile = appService?.isMobile || window.innerWidth < 640; + return (
cycleProgressInfoModes()} aria-label={[ diff --git a/apps/readest-app/src/app/reader/components/ViewMenu.tsx b/apps/readest-app/src/app/reader/components/ViewMenu.tsx index 448036ef..698c8d62 100644 --- a/apps/readest-app/src/app/reader/components/ViewMenu.tsx +++ b/apps/readest-app/src/app/reader/components/ViewMenu.tsx @@ -38,7 +38,7 @@ const ViewMenu: React.FC = ({ bookKey, setIsDropdownOpen }) => { const { user } = useAuth(); const { envConfig, appService } = useEnv(); const { getConfig, getBookData } = useBookDataStore(); - const { setSettingsDialogOpen } = useSettingsStore(); + const { setSettingsDialogOpen, setSettingsDialogBookKey } = useSettingsStore(); const { getView, getViewSettings, getViewState, setViewSettings } = useReaderStore(); const config = getConfig(bookKey)!; const bookData = getBookData(bookKey)!; @@ -62,6 +62,7 @@ const ViewMenu: React.FC = ({ bookKey, setIsDropdownOpen }) => { const openFontLayoutMenu = () => { setIsDropdownOpen?.(false); + setSettingsDialogBookKey(bookKey); setSettingsDialogOpen(true); }; @@ -258,7 +259,7 @@ const ViewMenu: React.FC = ({ bookKey, setIsDropdownOpen }) => { shortcut='Shift+J' Icon={isScrolledMode ? MdCheck : undefined} onClick={toggleScrolledMode} - disabled={bookData.bookDoc?.rendition?.layout === 'pre-paginated'} + disabled={bookData.isFixedLayout} /> diff --git a/apps/readest-app/src/app/reader/components/footerbar/DesktopFooterBar.tsx b/apps/readest-app/src/app/reader/components/footerbar/DesktopFooterBar.tsx index 15c5836e..65d49cad 100644 --- a/apps/readest-app/src/app/reader/components/footerbar/DesktopFooterBar.tsx +++ b/apps/readest-app/src/app/reader/components/footerbar/DesktopFooterBar.tsx @@ -67,7 +67,7 @@ const DesktopFooterBar: React.FC = ({ return (
+ {isVisible && needHorizontalScroll && ( +
+ )} diff --git a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts index e64753b6..e4630ac1 100644 --- a/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts +++ b/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts @@ -46,12 +46,26 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = const goLeft = () => { const viewSettings = getViewSettings(sideBarBookKey ?? ''); - viewPagination(getView(sideBarBookKey), viewSettings, 'left'); + viewPagination(getView(sideBarBookKey), viewSettings, 'left', 'pan', distance); }; const goRight = () => { const viewSettings = getViewSettings(sideBarBookKey ?? ''); - viewPagination(getView(sideBarBookKey), viewSettings, 'right'); + viewPagination(getView(sideBarBookKey), viewSettings, 'right', 'pan', distance); + }; + + const goUp = (event?: KeyboardEvent | MessageEvent) => { + const view = getView(sideBarBookKey); + const viewSettings = getViewSettings(sideBarBookKey ?? ''); + if (view?.renderer.scrolled && event instanceof MessageEvent) return; + viewPagination(view, viewSettings, 'up', 'pan', distance); + }; + + const goDown = (event?: KeyboardEvent | MessageEvent) => { + const view = getView(sideBarBookKey); + const viewSettings = getViewSettings(sideBarBookKey ?? ''); + if (view?.renderer.scrolled && event instanceof MessageEvent) return; + viewPagination(view, viewSettings, 'down', 'pan', distance); }; const goPrevSection = () => { @@ -82,32 +96,6 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = getView(sideBarBookKey)?.next(distance); }; - const goPrevArrowUp = (event?: KeyboardEvent | MessageEvent) => { - const view = getView(sideBarBookKey); - if ( - view?.renderer.scrolled && - event instanceof MessageEvent && - event.data.type === 'iframe-keydown' - ) { - // already handled in the iframe for better smoothness - return; - } - view?.prev(distance); - }; - - const goNextArrowDown = (event?: KeyboardEvent | MessageEvent) => { - const view = getView(sideBarBookKey); - if ( - view?.renderer.scrolled && - event instanceof MessageEvent && - event.data.type === 'iframe-keydown' - ) { - // already handled in the iframe for better smoothness - return; - } - view?.next(distance); - }; - const goBack = () => { getView(sideBarBookKey)?.history.back(); }; @@ -251,10 +239,10 @@ const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) = onQuitApp: quitApp, onGoLeft: goLeft, onGoRight: goRight, + onGoUp: goUp, + onGoDown: goDown, onGoPrev: goPrev, onGoNext: goNext, - onGoPrevArrowUp: goPrevArrowUp, - onGoNextArrowDown: goNextArrowDown, onGoHalfPageDown: goHalfPageDown, onGoHalfPageUp: goHalfPageUp, onGoPrevSection: goPrevSection, diff --git a/apps/readest-app/src/app/reader/hooks/usePagination.ts b/apps/readest-app/src/app/reader/hooks/usePagination.ts index ae1215c7..eae8592b 100644 --- a/apps/readest-app/src/app/reader/hooks/usePagination.ts +++ b/apps/readest-app/src/app/reader/hooks/usePagination.ts @@ -12,7 +12,7 @@ import { tauriGetWindowLogicalPosition } from '@/utils/window'; export type ScrollSource = 'touch' | 'mouse'; type PaginationSide = 'left' | 'right' | 'up' | 'down'; -type PaginationMode = 'page' | 'section'; +type PaginationMode = 'pan' | 'page' | 'section'; const swapLeftRight = (side: PaginationSide) => { if (side === 'left') return 'right'; @@ -20,11 +20,36 @@ const swapLeftRight = (side: PaginationSide) => { return side; }; +const isPanningView = (view: FoliateView | null, viewSettings: ViewSettings | null | undefined) => { + if (!view || !viewSettings) return false; + return ( + view.book.rendition?.layout === 'pre-paginated' && + (viewSettings.zoomLevel > 100 || viewSettings.zoomMode !== 'fit-page') + ); +}; + +const hasHorizontalPanning = ( + view: FoliateView | null, + viewSettings: ViewSettings | null | undefined, +) => { + if (!view || !viewSettings) return false; + return isPanningView(view, viewSettings) && view.isOverflowX(); +}; + +const hasVerticalPanning = ( + view: FoliateView | null, + viewSettings: ViewSettings | null | undefined, +) => { + if (!view || !viewSettings) return false; + return isPanningView(view, viewSettings) && view.isOverflowY(); +}; + export const viewPagination = ( view: FoliateView | null, viewSettings: ViewSettings | null | undefined, side: PaginationSide, mode: PaginationMode = 'page', + panDistance: number = 50, ) => { if (!view || !viewSettings) return; const renderer = view.renderer; @@ -38,25 +63,37 @@ export const viewPagination = ( const scrollingOverlap = viewSettings.scrollingOverlap; const distance = size - scrollingOverlap - (showHeader ? 44 : 0) - (showFooter ? 44 : 0); switch (mode) { - case 'page': - return side === 'left' || side === 'up' ? view.prev(distance) : view.next(distance); case 'section': if (side === 'left' || side === 'up') { return view.renderer.prevSection?.(); } else { return view.renderer.nextSection?.(); } + case 'pan': + case 'page': + default: + return side === 'left' || side === 'up' ? view.prev(distance) : view.next(distance); + } + } else if (mode === 'pan' && isPanningView(view, viewSettings)) { + if (hasHorizontalPanning(view, viewSettings) && (side === 'left' || side === 'right')) { + return view.pan(side === 'left' ? -panDistance : panDistance, 0); + } else if (hasVerticalPanning(view, viewSettings) && (side === 'up' || side === 'down')) { + return view.pan(0, side === 'up' ? -panDistance : panDistance); + } else { + return side === 'left' || side === 'up' ? view.prev() : view.next(); } } else { switch (mode) { - case 'page': - return side === 'left' || side === 'up' ? view.prev() : view.next(); case 'section': if (side === 'left' || side === 'up') { return view.renderer.prevSection?.(); } else { return view.renderer.nextSection?.(); } + case 'pan': + case 'page': + default: + return side === 'left' || side === 'up' ? view.prev() : view.next(); } } }; @@ -142,7 +179,7 @@ export const usePagination = ( } else if ( msg.data.type === 'iframe-wheel' && !viewSettings.scrolled && - (!bookData.isFixedLayout || viewSettings.zoomLevel <= 100) + !isPanningView(viewRef.current, viewSettings) ) { // The wheel event is handled by the iframe itself in scrolled mode. const { deltaY } = msg.data; @@ -172,7 +209,7 @@ export const usePagination = ( } else if ( msg.type === 'touch-swipe' && bookData.isFixedLayout && - viewSettings!.zoomLevel <= 100 + !isPanningView(viewRef.current, viewSettings) ) { const { deltaX, deltaY, deltaT } = msg.detail; const vx = Math.abs(deltaX / deltaT); diff --git a/apps/readest-app/src/components/settings/ControlPanel.tsx b/apps/readest-app/src/components/settings/ControlPanel.tsx index 15a514ad..1e70069f 100644 --- a/apps/readest-app/src/components/settings/ControlPanel.tsx +++ b/apps/readest-app/src/components/settings/ControlPanel.tsx @@ -209,6 +209,7 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes type='checkbox' className='toggle' checked={isScrolledMode} + disabled={bookData?.isFixedLayout} onChange={() => setScrolledMode(!isScrolledMode)} />
@@ -218,6 +219,7 @@ const ControlPanel: React.FC = ({ bookKey, onRegisterRes type='checkbox' className='toggle' checked={isContinuousScroll} + disabled={bookData?.isFixedLayout} onChange={() => setIsContinuousScroll(!isContinuousScroll)} />
diff --git a/apps/readest-app/src/helpers/shortcuts.ts b/apps/readest-app/src/helpers/shortcuts.ts index 16698777..0881c7c0 100644 --- a/apps/readest-app/src/helpers/shortcuts.ts +++ b/apps/readest-app/src/helpers/shortcuts.ts @@ -23,12 +23,12 @@ const DEFAULT_SHORTCUTS = { onToggleFullscreen: ['F11'], onCloseWindow: ['ctrl+w', 'cmd+w'], onQuitApp: ['ctrl+q', 'cmd+q'], - onGoLeft: ['ArrowLeft', 'PageUp', 'h', 'shift+ '], - onGoRight: ['ArrowRight', 'PageDown', 'l', ' '], - onGoNext: ['j'], - onGoPrev: ['k'], - onGoNextArrowDown: ['ArrowDown'], - onGoPrevArrowUp: ['ArrowUp'], + onGoLeft: ['ArrowLeft', 'h', 'shift+ '], + onGoRight: ['ArrowRight', 'l', ' '], + onGoUp: ['ArrowUp', 'k'], + onGoDown: ['ArrowDown', 'j'], + onGoNext: ['shift+j', 'shift+ArrowRight', 'shift+ArrowDown', 'PageDown'], + onGoPrev: ['shift+k', 'shift+ArrowLeft', 'shift+ArrowUp', 'PageUp'], onGoLeftSection: ['opt+ArrowLeft', 'alt+ArrowLeft'], onGoRightSection: ['opt+ArrowRight', 'alt+ArrowRight'], onGoPrevSection: ['opt+ArrowUp', 'alt+ArrowUp'], diff --git a/apps/readest-app/src/types/view.ts b/apps/readest-app/src/types/view.ts index acf62301..cf9c0370 100644 --- a/apps/readest-app/src/types/view.ts +++ b/apps/readest-app/src/types/view.ts @@ -14,6 +14,9 @@ export interface FoliateView extends HTMLElement { goToFraction: (fraction: number) => void; prev: (distance?: number) => void; next: (distance?: number) => void; + pan: (dx: number, dy: number) => void; + isOverflowX: () => boolean; + isOverflowY: () => boolean; goLeft: () => void; goRight: () => void; getCFI: (index: number, range: Range) => string; diff --git a/packages/foliate-js b/packages/foliate-js index 47be9d81..133ae252 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 47be9d814bf7919b7137cab79afa2b96697afbc6 +Subproject commit 133ae252b92bf09bc5900672cd6f3049c758a2b5