frontend/src/js/components/PageViewer/PageViewer.tsx (119 lines of code) (raw):

import React, { FC, useCallback, useEffect, useState } from 'react'; import { Controls } from './Controls'; import styles from './PageViewer.module.css'; import { VirtualScroll } from './VirtualScroll'; import { HighlightForSearchNavigation } from './model'; import { range, uniq } from 'lodash'; type PageViewerProps = { uri: string, totalPages: number; }; export type HighlightsState = { // Beware !focusedIndex for checking null, since it can be 0 focusedIndex: number | null, highlights: HighlightForSearchNavigation[] }; function getPreloadPages(highlightState: HighlightsState): number[] { if (highlightState.focusedIndex === null || highlightState.highlights.length === 0) { return []; } const length = highlightState.highlights.length; // From three highlights before to three highlights after, // wrapping if we hit an edge. If there are fewer than seven highlights, // we'll get them all, the uniq() call will prevent duplicates. const indexesOfHighlightsToPreload = uniq( range(-3, 3).map((offset) => { // type guard does not extend into .map() it seems const offsetIndex = (highlightState.focusedIndex ?? 0) + offset; // modulo - the regular % is 'remainder' in JS which is different return ((offsetIndex % length) + length) % length; }) ); return uniq(indexesOfHighlightsToPreload.map( (idx) => highlightState.highlights[idx].pageNumber )); } export const PageViewer: FC<PageViewerProps> = ({uri, totalPages}) => { const params = new URLSearchParams(document.location.search); const searchQuery = params.get("q") ?? undefined; // The below are stored here because they are set (debounced) by // <Controls /> when the user types in the find query box, and are used // by <VirtualScroll /> to refresh highlights and preload pages with hits. const [findQuery, setFindQuery] = useState(''); const [findHighlightsState, setFindHighlightsState] = useState<HighlightsState>({ // Beware !focusedIndex for checking null, since it can be 0 focusedIndex: null, highlights: [] }); // For search highlights, the query is fixed so no need to store it in state. // But we do need to keep track of the highlights and our position within them. const [searchHighlightsState, setSearchHighlightsState] = useState<HighlightsState>({ // Beware !focusedIndex for checking null, since it can be 0 focusedIndex: null, highlights: [] }); const [focusedFindHighlight, setFocusedFindHighlight] = useState<HighlightForSearchNavigation | null>(null); const [focusedSearchHighlight, setFocusedSearchHighlight] = useState<HighlightForSearchNavigation | null>(null); const [pageNumbersToPreload, setPageNumbersToPreload] = useState<number[]>([]); const [rotation, setRotation] = useState(0); const [scale, setScale] = useState(1); useEffect(() => { const findHighlightsPreloadPages = getPreloadPages(findHighlightsState); const searchHighlightsPreloadPages = getPreloadPages(searchHighlightsState); setPageNumbersToPreload(uniq([ ...findHighlightsPreloadPages, ...searchHighlightsPreloadPages ])); }, [findHighlightsState, searchHighlightsState]); const onFindHighlightStateChange = useCallback((newState) => { setFindHighlightsState(newState); setFocusedFindHighlight((newState.focusedIndex !== null) ? newState.highlights[newState.focusedIndex] : null ); }, []); const onSearchHighlightStateChange = useCallback((newState) => { setSearchHighlightsState(newState); setFocusedSearchHighlight((newState.focusedIndex !== null) ? newState.highlights[newState.focusedIndex] : null ); }, []); const onFindQueryChange = useCallback((newQuery) => { setFindQuery(newQuery); }, []); const onSearchQueryChange = useCallback(() => {}, []); return ( <main className={styles.main}> <div className={styles.controls}> {searchQuery !== undefined && <Controls rotateAnticlockwise={() => setRotation((r) => r - 90)} rotateClockwise={() => setRotation((r) => r + 90)} zoomIn={() => { setScale((currentScale) => currentScale + 0.75); }} zoomOut={() => { setScale((currentScale) => currentScale - 0.75); }} uri={uri} onHighlightStateChange={onSearchHighlightStateChange} onQueryChange={onSearchQueryChange} fixedQuery={searchQuery} /> } <Controls rotateAnticlockwise={() => setRotation((r) => r - 90)} rotateClockwise={() => setRotation((r) => r + 90)} zoomIn={() => { setScale((currentScale) => currentScale + 0.75); }} zoomOut={() => { setScale((currentScale) => currentScale - 0.75); }} uri={uri} onHighlightStateChange={onFindHighlightStateChange} onQueryChange={onFindQueryChange} /> </div> {totalPages ? ( <VirtualScroll uri={uri} searchQuery={searchQuery} findQuery={findQuery} focusedFindHighlight={focusedFindHighlight} focusedSearchHighlight={focusedSearchHighlight} totalPages={totalPages} pageNumbersToPreload={pageNumbersToPreload} rotation={rotation} scale={scale} /> ) : null} </main> ); };