Files
readest/apps/readest-app/src/app/reader/hooks/useBookShortcuts.ts
T
Huang Xin 44a6900da0 feat(reader): extend selections and highlights across pages (#4741) (#4767)
* 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>
2026-06-24 19:23:11 +02:00

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;