feat(pdf): support pitch to zoom and scroll mode for PDFs, closes #3461 (#3520)

This commit is contained in:
Huang Xin
2026-03-12 23:07:55 +08:00
committed by GitHub
parent af649edd0d
commit 7a605a357a
10 changed files with 93 additions and 20 deletions
@@ -113,6 +113,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ 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<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
shortcut='Shift+J'
Icon={isScrolledMode ? MdCheck : undefined}
onClick={toggleScrolledMode}
disabled={bookData.isFixedLayout}
/>
<hr aria-hidden='true' className='border-base-300 my-1' />
@@ -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 };
@@ -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]);
@@ -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<IframeTouch | null>(null);
const touchEndRef = useRef<IframeTouch | null>(null);
const touchStartTimeRef = useRef<number | null>(null);
const touchEndTimeRef = useRef<number | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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];
@@ -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 = () => {
@@ -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(
{
+2
View File
@@ -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;
};
}