forked from akai/readest
324bb8a366
Add a bindable "Refresh Page" action to Settings > Behavior > Page Turner that triggers a deep e-ink full refresh (GC16) to clear screen ghosting, gated to e-ink mode on Android. It reuses the existing hardware page-turner key-binding machinery: a new 'refresh' slot in HardwarePageTurnerSettings, shown only when isAndroidApp and the e-ink view setting is on. Pressing the bound key calls a new native bridge command instead of paginating. The native side is device-agnostic: EinkRefreshController probes each vendor mechanism via reflection and stops at the first that works, covering Onyx BOOX (Qualcomm View.refreshScreen), Tolino/Nook (NTX postInvalidateDelayed) and Boyue-style Rockchip (requestEpdMode) without bundling any vendor SDK. A success:false result is a soft no-op on non-e-ink hardware. iOS gets a stub. Verified on an Onyx BOOX Leaf5: the Onyx path fires and performs a visible full GC16 refresh. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
529 lines
20 KiB
TypeScript
529 lines
20 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { FoliateView } from '@/types/view';
|
|
import { ViewSettings } from '@/types/book';
|
|
import { useReaderStore } from '@/store/readerStore';
|
|
import { useBookDataStore } from '@/store/bookDataStore';
|
|
import { useDeviceControlStore } from '@/store/deviceStore';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useSidebarStore } from '@/store/sidebarStore';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { resolvePageTurn, normalizeDomKeyEvent, KeyCandidate } from '@/utils/keybinding';
|
|
import { refreshEinkScreen } from '@/utils/bridge';
|
|
import { isTauriAppPlatform } from '@/services/environment';
|
|
import { tauriGetWindowLogicalPosition } from '@/utils/window';
|
|
import { getReadingRulerMoveDirection } from '../utils/readingRuler';
|
|
import { useTouchInterceptor } from './useTouchInterceptor';
|
|
|
|
export type ScrollSource = 'touch' | 'mouse';
|
|
|
|
type PaginationSide = 'left' | 'right' | 'up' | 'down';
|
|
type PaginationMode = 'pan' | 'page' | 'section';
|
|
|
|
const swapLeftRight = (side: PaginationSide) => {
|
|
if (side === 'left') return 'right';
|
|
if (side === 'right') return 'left';
|
|
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();
|
|
};
|
|
|
|
// In scrolled mode, snap the page-scroll distance to whole lines so the new view
|
|
// tiles cleanly: forward, the new view's top aligns to the first line that wasn't
|
|
// fully visible (so no already-shown line repeats and lines aren't cut at the top);
|
|
// backward, the new view's bottom aligns to the last such line. `distance` is a
|
|
// positive scroll amount. Falls back to `distance` if the geometry is unavailable.
|
|
const snapScrolledDistanceToLines = (
|
|
view: FoliateView,
|
|
distance: number,
|
|
forward: boolean,
|
|
): number => {
|
|
try {
|
|
const visible = view.renderer.getContents().find((c) => {
|
|
const f = c.doc?.defaultView?.frameElement?.getBoundingClientRect();
|
|
return !!f && f.bottom > 1 && f.top < window.innerHeight;
|
|
});
|
|
const frameEl = visible?.doc?.defaultView?.frameElement as HTMLElement | undefined;
|
|
if (!visible || !frameEl) return distance;
|
|
let container: Element | null = frameEl;
|
|
while (container && container.id !== 'container') container = container.parentElement;
|
|
if (!(container instanceof HTMLElement)) return distance;
|
|
|
|
const cRect = container.getBoundingClientRect();
|
|
const frameRect = frameEl.getBoundingClientRect();
|
|
const size = cRect.height;
|
|
const scrollTop = container.scrollTop;
|
|
|
|
const range = visible.doc.createRange();
|
|
range.selectNodeContents(visible.doc.body);
|
|
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 2 && r.height > 2);
|
|
if (rects.length < 2) return distance;
|
|
const heights = rects.map((r) => r.height).sort((a, b) => a - b);
|
|
const maxLineHeight = (heights[Math.floor(heights.length / 2)] ?? 0) * 1.8;
|
|
|
|
// Line boxes in content (scroll) coordinates, sorted top-to-bottom (skip
|
|
// block/container boxes that are much taller than a line).
|
|
const toContent = (localY: number) => localY + frameRect.top - cRect.top + scrollTop;
|
|
const lines = rects
|
|
.filter((r) => r.height <= maxLineHeight)
|
|
.map((r) => ({ top: toContent(r.top), bottom: toContent(r.bottom) }))
|
|
.sort((a, b) => a.top - b.top);
|
|
|
|
let snapped: number;
|
|
if (forward) {
|
|
// First line not fully visible at the bottom -> it becomes the new view's top.
|
|
const bottomEdge = scrollTop + size;
|
|
const next = lines.find((l) => l.bottom > bottomEdge + 1);
|
|
if (!next) return distance;
|
|
snapped = next.top - scrollTop;
|
|
} else {
|
|
// Last line not fully visible at the top -> it becomes the new view's bottom.
|
|
const topEdge = scrollTop;
|
|
let prev: { top: number; bottom: number } | undefined;
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
if (lines[i]!.top < topEdge - 1) {
|
|
prev = lines[i]!;
|
|
break;
|
|
}
|
|
}
|
|
if (!prev) return distance;
|
|
snapped = scrollTop + size - prev.bottom;
|
|
}
|
|
// Guard against degenerate snaps; keep within roughly one page.
|
|
return snapped > size * 0.4 && snapped < size * 1.6 ? snapped : distance;
|
|
} catch {
|
|
return distance;
|
|
}
|
|
};
|
|
|
|
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;
|
|
if (viewSettings.rtl) {
|
|
side = swapLeftRight(side);
|
|
}
|
|
if (renderer.scrolled) {
|
|
// `renderer.size` is already the visible content height: in scrolled mode the
|
|
// scroll container is inset by the header/footer (parent padding), so `size`
|
|
// shrinks when they're shown. Subtracting their heights again here would
|
|
// double-count and make consecutive views overlap.
|
|
const { size } = renderer;
|
|
const scrollingOverlap = viewSettings.scrollingOverlap;
|
|
const distance = size - scrollingOverlap;
|
|
switch (mode) {
|
|
case 'section':
|
|
if (side === 'left' || side === 'up') {
|
|
return view.renderer.prevSection?.();
|
|
} else {
|
|
return view.renderer.nextSection?.();
|
|
}
|
|
case 'pan':
|
|
case 'page':
|
|
default: {
|
|
const forward = !(side === 'left' || side === 'up');
|
|
// Snap so the view's bottom edge lands between lines (not for vertical flow).
|
|
const snapped = viewSettings.vertical
|
|
? distance
|
|
: snapScrolledDistanceToLines(view, distance, forward);
|
|
return forward ? view.next(snapped) : view.prev(snapped);
|
|
}
|
|
}
|
|
} 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 '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();
|
|
}
|
|
}
|
|
};
|
|
|
|
export const usePagination = (
|
|
bookKey: string,
|
|
viewRef: React.RefObject<FoliateView | null>,
|
|
containerRef: React.RefObject<HTMLDivElement | null>,
|
|
) => {
|
|
const { appService } = useEnv();
|
|
const { getBookData } = useBookDataStore();
|
|
const { getViewSettings, getViewState } = useReaderStore();
|
|
const { hoveredBookKey, setHoveredBookKey } = useReaderStore();
|
|
const {
|
|
acquireVolumeKeyInterception,
|
|
releaseVolumeKeyInterception,
|
|
acquirePageTurnerKeyInterception,
|
|
releasePageTurnerKeyInterception,
|
|
} = useDeviceControlStore();
|
|
// Reactive subscription: drives the effect dependency array below. The
|
|
// handlers themselves re-read via getState() to avoid stale closures.
|
|
const hardwarePageTurner = useSettingsStore((s) => s.settings.hardwarePageTurner);
|
|
// While this book's TTS is actively playing, the volume keys must control the
|
|
// system volume instead of flipping pages (#4691). A paused or stopped session
|
|
// hands them back to the page-flip interception.
|
|
const [ttsPlaying, setTtsPlaying] = useState(false);
|
|
|
|
const handlePageFlip = async (
|
|
msg: MessageEvent | CustomEvent | React.MouseEvent<HTMLDivElement, MouseEvent>,
|
|
) => {
|
|
const viewState = getViewState(bookKey);
|
|
const bookData = getBookData(bookKey);
|
|
if (!viewState?.inited || !bookData) return;
|
|
|
|
const dispatchReadingRulerMove = (side: PaginationSide) => {
|
|
return eventDispatcher.dispatchSync('reading-ruler-move', {
|
|
bookKey,
|
|
direction: getReadingRulerMoveDirection(side, viewRef.current?.book.dir),
|
|
});
|
|
};
|
|
|
|
if (msg instanceof MessageEvent) {
|
|
if (msg.data && msg.data.bookKey === bookKey) {
|
|
const viewSettings = getViewSettings(bookKey)!;
|
|
if (msg.data.type === 'iframe-single-click') {
|
|
const viewElement = containerRef.current;
|
|
if (viewElement) {
|
|
const { screenX } = msg.data;
|
|
const viewRect = viewElement.getBoundingClientRect();
|
|
let windowStartX;
|
|
// Currently for tauri APP the window.screenX is always 0
|
|
if (isTauriAppPlatform()) {
|
|
if (appService?.isMobile) {
|
|
windowStartX = 0;
|
|
} else {
|
|
const windowPosition = (await tauriGetWindowLogicalPosition()) as {
|
|
x: number;
|
|
y: number;
|
|
};
|
|
windowStartX = windowPosition.x;
|
|
}
|
|
} else {
|
|
windowStartX = window.screenX;
|
|
}
|
|
const viewStartX = windowStartX + viewRect.left;
|
|
const viewCenterX = viewStartX + viewRect.width / 2;
|
|
const consumed = eventDispatcher.dispatchSync('iframe-single-click');
|
|
if (!consumed) {
|
|
const centerStartX = viewStartX + viewRect.width * 0.375;
|
|
const centerEndX = viewStartX + viewRect.width * 0.625;
|
|
if (
|
|
viewSettings.disableClick! ||
|
|
(screenX >= centerStartX && screenX <= centerEndX)
|
|
) {
|
|
// toggle visibility of the header bar and the footer bar
|
|
setHoveredBookKey(hoveredBookKey ? null : bookKey);
|
|
return;
|
|
}
|
|
|
|
if (hoveredBookKey) {
|
|
setHoveredBookKey(null);
|
|
return;
|
|
}
|
|
|
|
const side: PaginationSide =
|
|
screenX >= viewCenterX
|
|
? viewSettings.fullscreenClickArea
|
|
? 'down'
|
|
: viewSettings.swapClickArea
|
|
? 'left'
|
|
: 'right'
|
|
: viewSettings.fullscreenClickArea
|
|
? 'down'
|
|
: viewSettings.swapClickArea
|
|
? 'right'
|
|
: 'left';
|
|
|
|
if (viewSettings.readingRulerEnabled && dispatchReadingRulerMove(side)) {
|
|
return;
|
|
}
|
|
|
|
viewPagination(viewRef.current, viewSettings, side);
|
|
}
|
|
}
|
|
} else if (
|
|
msg.data.type === 'iframe-wheel' &&
|
|
!viewSettings.scrolled &&
|
|
!isPanningView(viewRef.current, viewSettings)
|
|
) {
|
|
// The wheel event is handled by the iframe itself in scrolled mode.
|
|
const { deltaY, deltaX } = msg.data;
|
|
if (deltaY > 0) {
|
|
viewPagination(viewRef.current, viewSettings, 'down');
|
|
} else if (deltaY < 0) {
|
|
viewPagination(viewRef.current, viewSettings, 'up');
|
|
} else if (deltaX < 0) {
|
|
viewPagination(viewRef.current, viewSettings, 'left');
|
|
} else if (deltaX > 0) {
|
|
viewPagination(viewRef.current, viewSettings, 'right');
|
|
}
|
|
} else if (msg.data.type === 'iframe-mouseup') {
|
|
if (msg.data.button === 3) {
|
|
viewRef.current?.history.back();
|
|
} else if (msg.data.button === 4) {
|
|
viewRef.current?.history.forward();
|
|
}
|
|
}
|
|
}
|
|
} else if (msg instanceof CustomEvent) {
|
|
const viewSettings = getViewSettings(bookKey);
|
|
if (msg.type === 'native-key-down' && viewSettings?.volumeKeysToFlip) {
|
|
const { keyName } = msg.detail;
|
|
setHoveredBookKey('');
|
|
if (keyName === 'VolumeUp') {
|
|
if (viewSettings.readingRulerEnabled && dispatchReadingRulerMove('up')) {
|
|
return;
|
|
}
|
|
viewPagination(viewRef.current, viewSettings, 'up');
|
|
} else if (keyName === 'VolumeDown') {
|
|
if (viewSettings.readingRulerEnabled && dispatchReadingRulerMove('down')) {
|
|
return;
|
|
}
|
|
viewPagination(viewRef.current, viewSettings, 'down');
|
|
}
|
|
}
|
|
} else {
|
|
if (msg.type === 'click') {
|
|
const { clientX } = msg;
|
|
const width = window.innerWidth;
|
|
const leftThreshold = width * 0.5;
|
|
const rightThreshold = width * 0.5;
|
|
const viewSettings = getViewSettings(bookKey);
|
|
if (!viewSettings?.disableClick) {
|
|
if (clientX < leftThreshold) {
|
|
viewPagination(viewRef.current, viewSettings, 'left');
|
|
} else if (clientX > rightThreshold) {
|
|
viewPagination(viewRef.current, viewSettings, 'right');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Hardware page turner: media keys arrive via the `native-key-down`
|
|
// event; D-pad / keyboard keys arrive either as a top-window `keydown`
|
|
// or — when focus is inside a book iframe — as an `iframe-keydown`
|
|
// postMessage (mirroring useShortcuts' unified window + iframe handling).
|
|
// All resolve through the shared binding registry. Suppressed while the
|
|
// toolbar is visible so D-pad keys keep driving toolbar spatial navigation.
|
|
const handleHardwarePageTurn = (candidate: KeyCandidate): boolean => {
|
|
const settings = useSettingsStore.getState().settings.hardwarePageTurner;
|
|
if (!settings?.enabled) return false;
|
|
if (useReaderStore.getState().hoveredBookKey) return false;
|
|
|
|
// Only the active book (the one driving the sidebar) responds, so a
|
|
// single key press doesn't flip every book open in a parallel view.
|
|
if (useSidebarStore.getState().sideBarBookKey !== bookKey) return false;
|
|
|
|
const viewState = getViewState(bookKey);
|
|
if (!viewState?.inited) return false;
|
|
|
|
const action = resolvePageTurn(settings, candidate);
|
|
if (!action) return false;
|
|
|
|
// E-ink full screen refresh (Android only) — clears ghosting without
|
|
// turning the page. The native bridge no-ops on non-e-ink hardware.
|
|
if (action === 'refresh') {
|
|
if (appService?.isAndroidApp) {
|
|
refreshEinkScreen().catch(() => {});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const viewSettings = getViewSettings(bookKey);
|
|
const side = action === 'pagePrev' || action === 'sectionPrev' ? 'up' : 'down';
|
|
const mode = action === 'sectionPrev' || action === 'sectionNext' ? 'section' : 'page';
|
|
setHoveredBookKey('');
|
|
if (
|
|
mode === 'page' &&
|
|
viewSettings?.readingRulerEnabled &&
|
|
eventDispatcher.dispatchSync('reading-ruler-move', {
|
|
bookKey,
|
|
direction: getReadingRulerMoveDirection(side, viewRef.current?.book.dir),
|
|
})
|
|
) {
|
|
return true;
|
|
}
|
|
viewPagination(viewRef.current, viewSettings, side, mode);
|
|
return true;
|
|
};
|
|
|
|
const handleHardwareNativeKey = (msg: CustomEvent) => {
|
|
const keyName = msg.detail?.keyName;
|
|
if (typeof keyName !== 'string') return;
|
|
handleHardwarePageTurn({ source: 'native', id: keyName });
|
|
};
|
|
|
|
const handleHardwareDomKey = (event: KeyboardEvent | MessageEvent) => {
|
|
let candidate: KeyCandidate;
|
|
if (event instanceof KeyboardEvent) {
|
|
if (event.repeat) return;
|
|
const target = event.target as HTMLElement | null;
|
|
if (target?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')) {
|
|
return;
|
|
}
|
|
candidate = normalizeDomKeyEvent(event);
|
|
} else if (event.data?.type === 'iframe-keydown' && event.data.bookKey === bookKey) {
|
|
const id = event.data.code || event.data.key;
|
|
if (typeof id !== 'string' || !id) return;
|
|
candidate = { source: 'dom', id };
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
if (handleHardwarePageTurn(candidate)) {
|
|
// Stop `useShortcuts` from also paging on this key — capture-phase
|
|
// for the window keydown, registration order for the iframe message.
|
|
if (event instanceof KeyboardEvent) event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!appService?.isMobileApp) return;
|
|
|
|
eventDispatcher.on('native-key-down', handlePageFlip);
|
|
return () => {
|
|
eventDispatcher.off('native-key-down', handlePageFlip);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Track this book's TTS playback so volume-key interception can step aside
|
|
// while audio is playing (#4691).
|
|
useEffect(() => {
|
|
const handlePlaybackState = (event: Event) => {
|
|
const detail = (event as CustomEvent).detail as { bookKey?: string; state?: string };
|
|
if (detail?.bookKey !== bookKey) return;
|
|
setTtsPlaying(detail.state === 'playing');
|
|
};
|
|
eventDispatcher.on('tts-playback-state', handlePlaybackState);
|
|
return () => {
|
|
eventDispatcher.off('tts-playback-state', handlePlaybackState);
|
|
};
|
|
}, [bookKey]);
|
|
|
|
// Volume-key page-flip interception (mobile only). Acquired only while the
|
|
// setting is on and TTS isn't playing; the matching release on re-run/unmount
|
|
// keeps the reference count balanced.
|
|
useEffect(() => {
|
|
if (!appService?.isMobileApp) return;
|
|
|
|
const viewSettings = getViewSettings(bookKey);
|
|
if (!viewSettings?.volumeKeysToFlip || ttsPlaying) return;
|
|
|
|
acquireVolumeKeyInterception();
|
|
return () => {
|
|
releaseVolumeKeyInterception();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [ttsPlaying]);
|
|
|
|
// Hardware page turner: native-key + DOM-key listeners and native
|
|
// media-key interception, re-evaluated whenever the setting changes.
|
|
useEffect(() => {
|
|
const hasNativeBinding =
|
|
hardwarePageTurner?.bindings.pagePrev?.source === 'native' ||
|
|
hardwarePageTurner?.bindings.pageNext?.source === 'native' ||
|
|
hardwarePageTurner?.bindings.sectionPrev?.source === 'native' ||
|
|
hardwarePageTurner?.bindings.sectionNext?.source === 'native' ||
|
|
hardwarePageTurner?.bindings.refresh?.source === 'native';
|
|
const needsNativeInterception =
|
|
!!appService?.isMobileApp && !!hardwarePageTurner?.enabled && hasNativeBinding;
|
|
|
|
if (needsNativeInterception) {
|
|
acquirePageTurnerKeyInterception();
|
|
}
|
|
if (hasNativeBinding) {
|
|
eventDispatcher.on('native-key-down', handleHardwareNativeKey);
|
|
}
|
|
window.addEventListener('keydown', handleHardwareDomKey, true);
|
|
window.addEventListener('message', handleHardwareDomKey);
|
|
|
|
return () => {
|
|
if (needsNativeInterception) {
|
|
releasePageTurnerKeyInterception();
|
|
}
|
|
if (hasNativeBinding) {
|
|
eventDispatcher.off('native-key-down', handleHardwareNativeKey);
|
|
}
|
|
window.removeEventListener('keydown', handleHardwareDomKey, true);
|
|
window.removeEventListener('message', handleHardwareDomKey);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
hardwarePageTurner?.enabled,
|
|
hardwarePageTurner?.bindings.pagePrev?.source,
|
|
hardwarePageTurner?.bindings.pageNext?.source,
|
|
hardwarePageTurner?.bindings.sectionPrev?.source,
|
|
hardwarePageTurner?.bindings.sectionNext?.source,
|
|
hardwarePageTurner?.bindings.refresh?.source,
|
|
]);
|
|
|
|
// Touch swipe page flip for fixed-layout books — registered as a touch interceptor
|
|
// so it participates in the priority-based consumption chain.
|
|
useTouchInterceptor(
|
|
`swipe-flip-${bookKey}`,
|
|
(bk, detail) => {
|
|
if (bk !== bookKey || detail.phase !== 'end') return false;
|
|
const bookData = getBookData(bookKey);
|
|
const viewSettings = getViewSettings(bookKey);
|
|
if (!bookData?.isFixedLayout || viewSettings?.scrolled) return false;
|
|
if (viewSettings?.disableSwipe) return false;
|
|
if (isPanningView(viewRef.current, viewSettings)) return false;
|
|
|
|
const { deltaX, deltaY, deltaT } = detail;
|
|
const vx = Math.abs(deltaX / (deltaT || 1));
|
|
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 30 && vx > 0.2) {
|
|
viewPagination(viewRef.current, viewSettings, deltaX > 0 ? 'left' : 'right');
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
0,
|
|
);
|
|
|
|
return {
|
|
handlePageFlip,
|
|
};
|
|
};
|