forked from akai/readest
44a6900da0
* docs(plan): design for cross-page corner auto-turn (#4741) Extract useAutoPageTurn so the corner-dwell page turn works for instant highlight drags and for range-editor handle drags, not just native text selection. Decouple the dwell liveness from the DOM selection and anchor each range's non-dragged end to a DOM position so it survives the scroll. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): add keyboard turn-on-cross to cross-page design (#4741) Shift+Arrow selection adjust extends into the off-screen next column without turning the page. Fold it into the feature with an immediate turn-on-cross (no dwell) in the keyboard path, reusing the page-edge geometry from useAutoPageTurn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): extend selections and highlights across pages (#4741) Extract the corner-dwell auto page-turn (#1354) into useAutoPageTurn, decoupled from the DOM selection, so every selection gesture can drive it in paginated mode, not just native text selection: - Instant Highlight drag: feed the finger corner into the dwell machine and DOM-anchor the highlight start so it survives the page scroll. - SelectionRangeEditor and AnnotationRangeEditor handle drags: feed the dragged-handle corner; anchor the non-dragged end to a DOM position so the edited range spans pages (the annotation editor previously resolved both ends from window coordinates and lost the previous page). - Shift+Arrow keyboard selection adjust: turn the page immediately when the extended focus leaves the visible page, so the growing selection stays in view. An after-turn re-emit rebuilds each gesture's range from the held position so the selection extends onto the new page without waiting for the next move. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useReaderStore } from '@/store/readerStore';
|
|
import { useNotebookStore } from '@/store/notebookStore';
|
|
import { isTauriAppPlatform } from '@/services/environment';
|
|
import { useSidebarStore } from '@/store/sidebarStore';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useBookDataStore } from '@/store/bookDataStore';
|
|
import { useCommandPalette } from '@/components/command-palette';
|
|
import { tauriHandleClose, tauriHandleToggleFullScreen, tauriQuitApp } from '@/utils/window';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { setShortcutsDialogVisible } from '@/components/KeyboardShortcutsHelp';
|
|
import { MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL, ZOOM_STEP } from '@/services/constants';
|
|
import { getParagraphActionForKey } from '@/utils/paragraphPresentation';
|
|
import { getScrollGapAttr } from '@/utils/webtoon';
|
|
import { extendSelectionFromContents, KeyModifiers } from '@/utils/sel';
|
|
import { getReadingAreaRect, keyboardTurnDirection } from './useAutoPageTurn';
|
|
import { viewPagination } from './usePagination';
|
|
import useShortcuts from '@/hooks/useShortcuts';
|
|
import useBooksManager from './useBooksManager';
|
|
import { getReadingRulerMoveDirection, isReadingRulerMoveKey } from '../utils/readingRuler';
|
|
|
|
interface UseBookShortcutsProps {
|
|
sideBarBookKey: string | null;
|
|
bookKeys: string[];
|
|
}
|
|
|
|
const useBookShortcuts = ({ sideBarBookKey, bookKeys }: UseBookShortcutsProps) => {
|
|
const { getView, getViewState, getViewSettings, setViewSettings } = useReaderStore();
|
|
const { toggleSideBar, setSideBarBookKey } = useSidebarStore();
|
|
const { setSettingsDialogOpen } = useSettingsStore();
|
|
const { getBookData } = useBookDataStore();
|
|
const { toggleNotebook } = useNotebookStore();
|
|
const { getNextBookKey } = useBooksManager();
|
|
const { open: openCommandPalette } = useCommandPalette();
|
|
const lastParagraphToggleRef = useRef(0);
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
const fontSize = viewSettings?.defaultFontSize ?? 16;
|
|
const lineHeight = viewSettings?.lineHeight ?? 1.6;
|
|
const distance = fontSize * lineHeight * 3;
|
|
|
|
const moveReadingRuler = (side: 'left' | 'right' | 'up' | 'down') => {
|
|
if (!sideBarBookKey) return false;
|
|
|
|
const viewSettings = getViewSettings(sideBarBookKey);
|
|
if (!viewSettings?.readingRulerEnabled) return false;
|
|
// In vertical layout, only Up/Down move the ruler; Left/Right turn pages.
|
|
if (!isReadingRulerMoveKey(side, !!viewSettings.vertical)) return false;
|
|
|
|
return eventDispatcher.dispatchSync('reading-ruler-move', {
|
|
bookKey: sideBarBookKey,
|
|
direction: getReadingRulerMoveDirection(side, getView(sideBarBookKey)?.book.dir),
|
|
});
|
|
};
|
|
|
|
const toggleScrollMode = () => {
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
if (viewSettings && sideBarBookKey) {
|
|
viewSettings.scrolled = !viewSettings.scrolled;
|
|
// Webtoon Mode requires scrolled flow; leaving scrolled exits Webtoon Mode
|
|
// and restores the default page gap (mirror the View menu's behavior).
|
|
if (!viewSettings.scrolled && viewSettings.webtoonMode) {
|
|
viewSettings.webtoonMode = false;
|
|
getView(sideBarBookKey)?.renderer.setAttribute('scroll-gap', getScrollGapAttr(false));
|
|
}
|
|
setViewSettings(sideBarBookKey, viewSettings!);
|
|
const flowMode = viewSettings.scrolled ? 'scrolled' : 'paginated';
|
|
getView(sideBarBookKey)?.renderer.setAttribute('flow', flowMode);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const switchSideBar = () => {
|
|
if (sideBarBookKey) setSideBarBookKey(getNextBookKey(sideBarBookKey));
|
|
};
|
|
|
|
// Standard desktop selection shortcuts (#4728). After a selection the reader
|
|
// container holds focus, so Shift+←/→ keystrokes land here in the parent (not
|
|
// the iframe). Extend the iframe selection ourselves; for keys forwarded from
|
|
// a focused iframe (the browser already extended natively) just report that a
|
|
// selection exists. Returning true stops the page-turn shortcut from firing.
|
|
const adjustTextSelection = (event?: KeyboardEvent | MessageEvent) => {
|
|
const isNative = event instanceof KeyboardEvent;
|
|
const src: KeyModifiers | undefined = isNative ? event : event?.data;
|
|
if (!src?.key) return false;
|
|
const view = getView(sideBarBookKey ?? '');
|
|
const contents = view?.renderer?.getContents?.() ?? [];
|
|
const extended = extendSelectionFromContents(contents, src, isNative);
|
|
// Keyboard turn-on-cross (#4741): when the extended selection's focus leaves
|
|
// the visible page in paginated mode, turn the page so the growing selection
|
|
// stays in view. Only for keys we extended ourselves (the native parent
|
|
// path); the focused-iframe path lets the browser scroll the focus in.
|
|
if (extended && isNative && !getViewSettings(sideBarBookKey ?? '')?.scrolled) {
|
|
const dir = keyboardTurnDirection(contents, getReadingAreaRect(sideBarBookKey ?? ''));
|
|
if (dir === 'next') view?.next();
|
|
else if (dir === 'prev') view?.prev();
|
|
}
|
|
return extended;
|
|
};
|
|
|
|
const goLeft = () => {
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
// If paragraph mode is enabled, navigate to previous paragraph instead
|
|
if (viewSettings?.paragraphMode?.enabled && sideBarBookKey) {
|
|
const action = getParagraphActionForKey('ArrowLeft', viewSettings);
|
|
eventDispatcher.dispatch(action === 'next' ? 'paragraph-next' : 'paragraph-prev', {
|
|
bookKey: sideBarBookKey,
|
|
});
|
|
return;
|
|
}
|
|
if (moveReadingRuler('left')) return;
|
|
viewPagination(getView(sideBarBookKey), viewSettings, 'left', 'pan', distance);
|
|
};
|
|
|
|
const goRight = () => {
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
// If paragraph mode is enabled, navigate to next paragraph instead
|
|
if (viewSettings?.paragraphMode?.enabled && sideBarBookKey) {
|
|
const action = getParagraphActionForKey('ArrowRight', viewSettings);
|
|
eventDispatcher.dispatch(action === 'prev' ? 'paragraph-prev' : 'paragraph-next', {
|
|
bookKey: sideBarBookKey,
|
|
});
|
|
return;
|
|
}
|
|
if (moveReadingRuler('right')) return;
|
|
viewPagination(getView(sideBarBookKey), viewSettings, 'right', 'pan', distance);
|
|
};
|
|
|
|
const goUp = (event?: KeyboardEvent | MessageEvent) => {
|
|
const view = getView(sideBarBookKey);
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
// If paragraph mode is enabled, navigate to previous paragraph instead
|
|
if (viewSettings?.paragraphMode?.enabled && sideBarBookKey) {
|
|
const action = getParagraphActionForKey('ArrowUp', viewSettings);
|
|
eventDispatcher.dispatch(action === 'next' ? 'paragraph-next' : 'paragraph-prev', {
|
|
bookKey: sideBarBookKey,
|
|
});
|
|
return;
|
|
}
|
|
if (moveReadingRuler('up')) return;
|
|
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 paragraph mode is enabled, navigate to next paragraph instead
|
|
if (viewSettings?.paragraphMode?.enabled && sideBarBookKey) {
|
|
const action = getParagraphActionForKey('ArrowDown', viewSettings);
|
|
eventDispatcher.dispatch(action === 'prev' ? 'paragraph-prev' : 'paragraph-next', {
|
|
bookKey: sideBarBookKey,
|
|
});
|
|
return;
|
|
}
|
|
if (moveReadingRuler('down')) return;
|
|
if (view?.renderer.scrolled && event instanceof MessageEvent) return;
|
|
viewPagination(view, viewSettings, 'down', 'pan', distance);
|
|
};
|
|
|
|
const goPrevSection = () => {
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
viewPagination(getView(sideBarBookKey), viewSettings, 'up', 'section');
|
|
};
|
|
|
|
const goNextSection = () => {
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
viewPagination(getView(sideBarBookKey), viewSettings, 'down', 'section');
|
|
};
|
|
|
|
const goLeftSection = () => {
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
viewPagination(getView(sideBarBookKey), viewSettings, 'left', 'section');
|
|
};
|
|
|
|
const goRightSection = () => {
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
viewPagination(getView(sideBarBookKey), viewSettings, 'right', 'section');
|
|
};
|
|
|
|
const goPrev = () => {
|
|
if (moveReadingRuler('up')) return;
|
|
getView(sideBarBookKey)?.prev(distance);
|
|
};
|
|
|
|
const goNext = () => {
|
|
if (moveReadingRuler('down')) return;
|
|
getView(sideBarBookKey)?.next(distance);
|
|
};
|
|
|
|
const goBack = () => {
|
|
getView(sideBarBookKey)?.history.back();
|
|
};
|
|
|
|
const goHalfPageDown = () => {
|
|
const view = getView(sideBarBookKey);
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
if (view && viewSettings && viewSettings.scrolled) {
|
|
view.next(view.renderer.size / 2);
|
|
}
|
|
};
|
|
|
|
const goHalfPageUp = () => {
|
|
const view = getView(sideBarBookKey);
|
|
const viewSettings = getViewSettings(sideBarBookKey ?? '');
|
|
if (view && viewSettings && viewSettings.scrolled) {
|
|
view.prev(view.renderer.size / 2);
|
|
}
|
|
};
|
|
|
|
const goForward = () => {
|
|
getView(sideBarBookKey)?.history.forward();
|
|
};
|
|
|
|
const reloadPage = () => {
|
|
window.location.reload();
|
|
};
|
|
|
|
const toggleFullscreen = async () => {
|
|
if (isTauriAppPlatform()) {
|
|
await tauriHandleToggleFullScreen();
|
|
}
|
|
};
|
|
|
|
const closeWindow = async () => {
|
|
if (isTauriAppPlatform()) {
|
|
await tauriHandleClose();
|
|
}
|
|
};
|
|
|
|
const quitApp = async () => {
|
|
// on web platform use browser's default shortcut to close the tab
|
|
if (isTauriAppPlatform()) {
|
|
await tauriQuitApp();
|
|
}
|
|
};
|
|
|
|
const showSearchBar = () => {
|
|
setTimeout(() => {
|
|
eventDispatcher.dispatch('search-term', { term: null, bookKey: sideBarBookKey });
|
|
}, 100);
|
|
};
|
|
|
|
const applyZoomLevel = (zoomLevel: number) => {
|
|
if (!sideBarBookKey) return;
|
|
const view = getView(sideBarBookKey);
|
|
const bookData = getBookData(sideBarBookKey);
|
|
const viewSettings = getViewSettings(sideBarBookKey)!;
|
|
if (bookData?.isFixedLayout) {
|
|
view?.renderer.setAttribute('scale-factor', zoomLevel);
|
|
viewSettings!.zoomLevel = zoomLevel;
|
|
setViewSettings(sideBarBookKey, viewSettings!);
|
|
}
|
|
};
|
|
|
|
const zoomInFactor = (factor = 1.0) => {
|
|
if (!sideBarBookKey) return;
|
|
const viewSettings = getViewSettings(sideBarBookKey)!;
|
|
const zoomLevel = viewSettings!.zoomLevel + ZOOM_STEP * factor;
|
|
applyZoomLevel(Math.min(zoomLevel, MAX_ZOOM_LEVEL));
|
|
};
|
|
|
|
const zoomOutFactor = (factor = 1.0) => {
|
|
if (!sideBarBookKey) return;
|
|
const viewSettings = getViewSettings(sideBarBookKey)!;
|
|
const zoomLevel = viewSettings!.zoomLevel - ZOOM_STEP * factor;
|
|
applyZoomLevel(Math.max(zoomLevel, MIN_ZOOM_LEVEL));
|
|
};
|
|
|
|
const zoomIn = () => {
|
|
zoomInFactor();
|
|
};
|
|
|
|
const zoomOut = () => {
|
|
zoomOutFactor();
|
|
};
|
|
|
|
const handleZoomIn = (event: CustomEvent) => {
|
|
const factor = event.detail?.factor || 1.0;
|
|
zoomInFactor(factor);
|
|
};
|
|
|
|
const handleZoomOut = (event: CustomEvent) => {
|
|
const factor = event.detail?.factor || 1.0;
|
|
zoomOutFactor(factor);
|
|
};
|
|
|
|
const resetZoom = () => {
|
|
if (!sideBarBookKey) return;
|
|
applyZoomLevel(100);
|
|
};
|
|
|
|
const toggleToolbar = () => {
|
|
if (!sideBarBookKey) return;
|
|
// Don't intercept Enter when a button is focused (let native click fire)
|
|
const active = document.activeElement;
|
|
if (active && active.tagName === 'BUTTON') return;
|
|
const { hoveredBookKey, setHoveredBookKey } = useReaderStore.getState();
|
|
setHoveredBookKey(hoveredBookKey === sideBarBookKey ? '' : sideBarBookKey);
|
|
};
|
|
|
|
const toggleTTS = () => {
|
|
if (!sideBarBookKey) return;
|
|
const bookKey = sideBarBookKey;
|
|
const viewState = getViewState(bookKey);
|
|
eventDispatcher.dispatch(viewState?.ttsEnabled ? 'tts-stop' : 'tts-speak', { bookKey });
|
|
};
|
|
|
|
const ttsPlayPause = () => {
|
|
if (!sideBarBookKey) return false;
|
|
const viewState = getViewState(sideBarBookKey);
|
|
if (!viewState?.ttsEnabled) return false;
|
|
eventDispatcher.dispatch('tts-toggle-play', { bookKey: sideBarBookKey });
|
|
return true;
|
|
};
|
|
|
|
const ttsGoNextSentence = () => {
|
|
if (!sideBarBookKey) return;
|
|
eventDispatcher.dispatch('tts-forward', { bookKey: sideBarBookKey, byMark: true });
|
|
};
|
|
|
|
const ttsGoPreviousSentence = () => {
|
|
if (!sideBarBookKey) return;
|
|
eventDispatcher.dispatch('tts-backward', { bookKey: sideBarBookKey, byMark: true });
|
|
};
|
|
|
|
const ttsGoNextParagraph = () => {
|
|
if (!sideBarBookKey) return;
|
|
eventDispatcher.dispatch('tts-forward', { bookKey: sideBarBookKey, byMark: false });
|
|
};
|
|
|
|
const ttsGoPreviousParagraph = () => {
|
|
if (!sideBarBookKey) return;
|
|
eventDispatcher.dispatch('tts-backward', { bookKey: sideBarBookKey, byMark: false });
|
|
};
|
|
|
|
const ttsHighlightSentence = () => {
|
|
if (!sideBarBookKey) return;
|
|
eventDispatcher.dispatch('tts-highlight-sentence', { bookKey: sideBarBookKey });
|
|
};
|
|
|
|
const toggleBookmark = () => {
|
|
if (!sideBarBookKey) return;
|
|
eventDispatcher.dispatch('toggle-bookmark', { bookKey: sideBarBookKey });
|
|
};
|
|
|
|
const toggleParagraphMode = (event?: KeyboardEvent | MessageEvent) => {
|
|
if (!sideBarBookKey) return false;
|
|
if (event instanceof KeyboardEvent && event.repeat) return true;
|
|
|
|
const now = Date.now();
|
|
if (now - lastParagraphToggleRef.current < 300) return true;
|
|
lastParagraphToggleRef.current = now;
|
|
|
|
eventDispatcher.dispatch('toggle-paragraph-mode', { bookKey: sideBarBookKey });
|
|
return true;
|
|
};
|
|
|
|
const startRSVP = () => {
|
|
if (!sideBarBookKey) return;
|
|
eventDispatcher.dispatch('rsvp-start', { bookKey: sideBarBookKey });
|
|
};
|
|
|
|
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]);
|
|
|
|
useShortcuts(
|
|
{
|
|
// Listed first so an active selection intercepts Shift+←/→ before the
|
|
// page-navigation actions below can turn the page (#4728).
|
|
onAdjustTextSelection: adjustTextSelection,
|
|
onSwitchSideBar: switchSideBar,
|
|
onToggleSideBar: toggleSideBar,
|
|
onToggleNotebook: toggleNotebook,
|
|
onToggleScrollMode: toggleScrollMode,
|
|
onToggleBookmark: toggleBookmark,
|
|
onToggleParagraphMode: toggleParagraphMode,
|
|
onStartRSVP: startRSVP,
|
|
onToggleToolbar: toggleToolbar,
|
|
onOpenFontLayoutSettings: () => setSettingsDialogOpen(true),
|
|
onShowSearchBar: showSearchBar,
|
|
onToggleFullscreen: toggleFullscreen,
|
|
onToggleTTS: toggleTTS,
|
|
onTTSPlayPause: ttsPlayPause,
|
|
onTTSGoNextSentence: ttsGoNextSentence,
|
|
onTTSGoPreviousSentence: ttsGoPreviousSentence,
|
|
onTTSGoNextParagraph: ttsGoNextParagraph,
|
|
onTTSGoPreviousParagraph: ttsGoPreviousParagraph,
|
|
onTTSHighlightSentence: ttsHighlightSentence,
|
|
onReloadPage: reloadPage,
|
|
onCloseWindow: closeWindow,
|
|
onQuitApp: quitApp,
|
|
onGoLeft: goLeft,
|
|
onGoRight: goRight,
|
|
onGoUp: goUp,
|
|
onGoDown: goDown,
|
|
onGoPrev: goPrev,
|
|
onGoNext: goNext,
|
|
onGoHalfPageDown: goHalfPageDown,
|
|
onGoHalfPageUp: goHalfPageUp,
|
|
onGoPrevSection: goPrevSection,
|
|
onGoNextSection: goNextSection,
|
|
onGoLeftSection: goLeftSection,
|
|
onGoRightSection: goRightSection,
|
|
onGoBack: goBack,
|
|
onGoForward: goForward,
|
|
onZoomIn: zoomIn,
|
|
onZoomOut: zoomOut,
|
|
onResetZoom: resetZoom,
|
|
onOpenCommandPalette: openCommandPalette,
|
|
onOpenShortcutsHelp: () => setShortcutsDialogVisible(true),
|
|
},
|
|
[sideBarBookKey, bookKeys],
|
|
);
|
|
};
|
|
|
|
export default useBookShortcuts;
|