feat(pdf): support panning on PDFs (#2981)

This also closes #2978 and closes #2875.
This commit is contained in:
Huang Xin
2026-01-17 09:11:33 +01:00
committed by GitHub
parent 2d7d6b08a9
commit b28ac99a9e
10 changed files with 88 additions and 52 deletions
@@ -115,15 +115,17 @@ const ProgressInfoView: React.FC<PageInfoProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progressInfoMode]);
const isMobile = appService?.isMobile || window.innerWidth < 640;
return (
<div
role='presentation'
className={clsx(
'progressinfo absolute flex items-center justify-between font-sans',
'pointer-events-auto bottom-0',
'progressinfo absolute bottom-0 flex items-center justify-between font-sans',
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-extralight',
isVertical ? 'writing-vertical-rl' : 'w-full',
isScrolled && !isVertical && 'bg-base-100',
isMobile ? 'pointer-events-auto' : 'pointer-events-none',
)}
onClick={() => cycleProgressInfoModes()}
aria-label={[
@@ -38,7 +38,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ 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<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
const openFontLayoutMenu = () => {
setIsDropdownOpen?.(false);
setSettingsDialogBookKey(bookKey);
setSettingsDialogOpen(true);
};
@@ -258,7 +259,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
shortcut='Shift+J'
Icon={isScrolledMode ? MdCheck : undefined}
onClick={toggleScrolledMode}
disabled={bookData.bookDoc?.rendition?.layout === 'pre-paginated'}
disabled={bookData.isFixedLayout}
/>
<hr aria-hidden='true' className='border-base-300 my-1' />
@@ -67,7 +67,7 @@ const DesktopFooterBar: React.FC<FooterBarChildProps> = ({
return (
<div
className='absolute hidden h-full w-full items-center gap-x-4 px-4 sm:flex'
className='absolute hidden h-8 w-full items-center gap-x-4 px-4 sm:flex'
style={{ bottom: isMobile ? `${gridInsets.bottom * 0.33}px` : '0px' }}
>
<Button
@@ -66,11 +66,11 @@ const FooterBar: React.FC<FooterBarProps> = ({
);
const handleGoPrevPage = useCallback(() => {
viewPagination(view, viewSettings, 'left');
viewPagination(view, viewSettings, 'left', 'page');
}, [view, viewSettings]);
const handleGoNextPage = useCallback(() => {
viewPagination(view, viewSettings, 'right');
viewPagination(view, viewSettings, 'right', 'page');
}, [view, viewSettings]);
const handleGoPrevSection = useCallback(() => {
@@ -211,7 +211,7 @@ const FooterBar: React.FC<FooterBarProps> = ({
appService?.hasRoundedWindow && 'rounded-window-bottom-right',
!isSideBarVisible && appService?.hasRoundedWindow && 'rounded-window-bottom-left',
isHoveredAnim && 'hover-bar-anim',
needHorizontalScroll && 'sm:!bottom-3 sm:!h-7',
needHorizontalScroll && 'sm:!bottom-3 sm:!h-10 sm:justify-end',
isVisible
? 'pointer-events-auto translate-y-0 opacity-100'
: 'pointer-events-none translate-y-full opacity-0 sm:translate-y-0',
@@ -245,6 +245,9 @@ const FooterBar: React.FC<FooterBarProps> = ({
<MobileFooterBar {...commonProps} />
<DesktopFooterBar {...commonProps} />
</div>
{isVisible && needHorizontalScroll && (
<div className='bg-base-100 pointer-events-none absolute bottom-0 left-0 hidden h-3 w-full sm:block' />
)}
<TTSControl bookKey={bookKey} gridInsets={gridInsets} />
</>
@@ -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,
@@ -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);
@@ -209,6 +209,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
type='checkbox'
className='toggle'
checked={isScrolledMode}
disabled={bookData?.isFixedLayout}
onChange={() => setScrolledMode(!isScrolledMode)}
/>
</div>
@@ -218,6 +219,7 @@ const ControlPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRes
type='checkbox'
className='toggle'
checked={isContinuousScroll}
disabled={bookData?.isFixedLayout}
onChange={() => setIsContinuousScroll(!isContinuousScroll)}
/>
</div>
+6 -6
View File
@@ -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'],
+3
View File
@@ -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;