diff --git a/frontend/src/core/components/viewer/CustomSearchLayer.tsx b/frontend/src/core/components/viewer/CustomSearchLayer.tsx index 05b50ecab..29f3b528c 100644 --- a/frontend/src/core/components/viewer/CustomSearchLayer.tsx +++ b/frontend/src/core/components/viewer/CustomSearchLayer.tsx @@ -44,8 +44,10 @@ export function CustomSearchLayer({ } const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => { + if (!state) return; + // Auto-scroll to active search result - if (state?.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) { + if (state.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) { const activeResult = state.results[state.activeResultIndex]; if (activeResult) { const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 9ca8bac0a..94bff5a35 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -43,6 +43,8 @@ const EmbedPdfViewerContent = ({ isThumbnailSidebarVisible, toggleThumbnailSidebar, isBookmarkSidebarVisible, + isSearchInterfaceVisible, + searchInterfaceActions, zoomActions, panActions: _panActions, rotationActions: _rotationActions, @@ -184,7 +186,7 @@ const EmbedPdfViewerContent = ({ onZoomOut: zoomActions.zoomOut, }); - // Handle keyboard zoom shortcuts + // Handle keyboard shortcuts (zoom and search) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (!isViewerHovered) return; @@ -199,6 +201,16 @@ const EmbedPdfViewerContent = ({ // Ctrl+- for zoom out event.preventDefault(); zoomActions.zoomOut(); + } else if (event.key === 'f' || event.key === 'F') { + // Ctrl+F for search + event.preventDefault(); + if (isSearchInterfaceVisible) { + // If already open, trigger refocus event + window.dispatchEvent(new CustomEvent('refocus-search-input')); + } else { + // Open search interface + searchInterfaceActions.open(); + } } } }; @@ -207,7 +219,7 @@ const EmbedPdfViewerContent = ({ return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [isViewerHovered]); + }, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]); // Register checker for unsaved changes (annotations only for now) useEffect(() => { diff --git a/frontend/src/core/components/viewer/SearchAPIBridge.tsx b/frontend/src/core/components/viewer/SearchAPIBridge.tsx index 4b0eadd23..4003e1d0b 100644 --- a/frontend/src/core/components/viewer/SearchAPIBridge.tsx +++ b/frontend/src/core/components/viewer/SearchAPIBridge.tsx @@ -28,11 +28,13 @@ export function SearchAPIBridge() { if (!search) return; const unsubscribe = search.onSearchResultStateChange?.((state: any) => { + if (!state) return; + const newState = { - results: state?.results || null, - activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index + results: state.results || null, + activeIndex: (state.activeResultIndex || 0) + 1 // Convert to 1-based index }; - + setLocalState(prevState => { // Only update if state actually changed if (prevState.results !== newState.results || prevState.activeIndex !== newState.activeIndex) { @@ -52,16 +54,42 @@ export function SearchAPIBridge() { state: localState, api: { search: async (query: string) => { - search.startSearch(); - return search.searchAllPages(query); + if (search?.startSearch && search?.searchAllPages) { + search.startSearch(); + return search.searchAllPages(query); + } }, clear: () => { - search.stopSearch(); + try { + if (search?.stopSearch) { + search.stopSearch(); + } + } catch (error) { + console.warn('Error stopping search:', error); + } setLocalState({ results: null, activeIndex: 0 }); }, - next: () => search.nextResult(), - previous: () => search.previousResult(), - goToResult: (index: number) => search.goToResult(index), + next: () => { + try { + search?.nextResult?.(); + } catch (error) { + console.warn('Error navigating to next result:', error); + } + }, + previous: () => { + try { + search?.previousResult?.(); + } catch (error) { + console.warn('Error navigating to previous result:', error); + } + }, + goToResult: (index: number) => { + try { + search?.goToResult?.(index); + } catch (error) { + console.warn('Error going to result:', error); + } + }, } }); } diff --git a/frontend/src/core/components/viewer/SearchInterface.tsx b/frontend/src/core/components/viewer/SearchInterface.tsx index da6f6472a..eefac8154 100644 --- a/frontend/src/core/components/viewer/SearchInterface.tsx +++ b/frontend/src/core/components/viewer/SearchInterface.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { LocalIcon } from '@app/components/shared/LocalIcon'; @@ -12,7 +12,9 @@ interface SearchInterfaceProps { export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { const { t } = useTranslation(); const viewerContext = React.useContext(ViewerContext); - + const inputRef = useRef(null); + const searchTimeoutRef = useRef(null); + const searchState = viewerContext?.getSearchState(); const searchResults = searchState?.results; const searchActiveIndex = searchState?.activeIndex; @@ -26,6 +28,61 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { } | null>(null); const [isSearching, setIsSearching] = useState(false); + // Auto-focus search input when visible + useEffect(() => { + if (visible) { + inputRef.current?.focus(); + } + }, [visible]); + + // Listen for refocus event (when Ctrl+F pressed while already open) + useEffect(() => { + const handleRefocus = () => { + inputRef.current?.focus(); + inputRef.current?.select(); + }; + + window.addEventListener('refocus-search-input', handleRefocus); + return () => { + window.removeEventListener('refocus-search-input', handleRefocus); + }; + }, []); + + // Auto-search as user types (debounced) + useEffect(() => { + // Clear existing timeout + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + // If query is empty, clear search immediately + if (!searchQuery.trim()) { + searchActions?.clear(); + setResultInfo(null); + return; + } + + // Debounce search by 300ms + searchTimeoutRef.current = setTimeout(async () => { + if (searchQuery.trim() && searchActions) { + setIsSearching(true); + try { + await searchActions.search(searchQuery.trim()); + } catch (error) { + console.error('Search failed:', error); + } finally { + setIsSearching(false); + } + } + }, 300); + + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [searchQuery, searchActions]); + // Monitor search state changes useEffect(() => { if (!visible) return; @@ -59,30 +116,21 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { return () => clearInterval(interval); }, [visible, searchResults, searchActiveIndex, searchQuery]); - const handleSearch = async (query: string) => { - if (!query.trim()) { - // If query is empty, clear the search - handleClearSearch(); - return; - } - - if (query.trim() && searchActions) { - setIsSearching(true); - try { - await searchActions.search(query.trim()); - } catch (error) { - console.error('Search failed:', error); - } finally { - setIsSearching(false); - } - } - }; - const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { - handleSearch(searchQuery); + // Navigate to next result on Enter + event.preventDefault(); + handleNext(); } else if (event.key === 'Escape') { onClose(); + } else if (event.key === 'ArrowDown') { + // Navigate to next result + event.preventDefault(); + handleNext(); + } else if (event.key === 'ArrowUp') { + // Navigate to previous result + event.preventDefault(); + handlePrevious(); } }; @@ -103,17 +151,17 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { // No longer need to sync with external API on mount - removed const handleJumpToResult = (index: number) => { - // Use context actions instead of window API - functionality simplified for now if (resultInfo && index >= 1 && index <= resultInfo.totalResults) { - // Note: goToResult functionality would need to be implemented in SearchAPIBridge - console.log('Jump to result:', index); + // Convert to 0-based index for the API + searchActions?.goToResult?.(index - 1); } }; const handleJumpToSubmit = () => { - const index = parseInt(jumpToValue); - if (index && resultInfo && index >= 1 && index <= resultInfo.totalResults) { + const index = parseInt(jumpToValue, 10); + if (!isNaN(index) && resultInfo && index >= 1 && index <= resultInfo.totalResults) { handleJumpToResult(index); + setJumpToValue(''); // Clear the input after jumping } }; @@ -123,7 +171,14 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { } }; - const _handleClose = () => { + const handleInputBlur = () => { + // Close popover on blur if no text is entered + if (!searchQuery.trim()) { + onClose(); + } + }; + + const handleCloseClick = () => { handleClearSearch(); onClose(); }; @@ -135,100 +190,99 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { padding: '0px' }} > - {/* Header */} - + {/* Header with close button */} + {t('search.title', 'Search PDF')} + + + {/* Search input */} { const newValue = e.currentTarget.value; setSearchQuery(newValue); - // If user clears the input, clear the search highlights - if (!newValue.trim()) { - handleClearSearch(); - } }} onKeyDown={handleKeyDown} + onBlur={handleInputBlur} style={{ flex: 1 }} rightSection={ - handleSearch(searchQuery)} - disabled={!searchQuery.trim() || isSearching} - loading={isSearching} - > - - + searchQuery.trim() && ( + + + + ) } /> - {/* Results info and navigation */} - {resultInfo && ( - - {resultInfo.totalResults === 0 ? ( - - {t('search.noResults', 'No results found')} - - ) : ( - - setJumpToValue(e.currentTarget.value)} - onKeyDown={handleJumpToKeyDown} - onBlur={handleJumpToSubmit} - placeholder={resultInfo.currentIndex.toString()} - style={{ width: '3rem' }} - type="number" - min="1" - max={resultInfo.totalResults} - /> - - of {resultInfo.totalResults} - - - )} - - {resultInfo.totalResults > 0 && ( - - - - - = resultInfo.totalResults} - aria-label="Next result" - > - - - - - - - )} + {/* Results info and navigation - always show */} + + + { + const newValue = e.currentTarget.value; + setJumpToValue(newValue); + + // Jump immediately as user types + const index = parseInt(newValue, 10); + if (resultInfo && !isNaN(index) && index >= 1 && index <= resultInfo.totalResults) { + handleJumpToResult(index); + } + }} + onKeyDown={handleJumpToKeyDown} + onBlur={() => setJumpToValue('')} // Clear on blur instead of submit + placeholder={(resultInfo?.currentIndex || 0).toString()} + style={{ width: '3rem' }} + type="number" + min="1" + max={resultInfo?.totalResults || 0} + disabled={!resultInfo || resultInfo.totalResults === 0} + /> + + of {resultInfo?.totalResults || 0} + - )} + + + + + + = resultInfo.totalResults} + aria-label="Next result" + > + + + + {/* Loading state */} {isSearching && ( diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx index 995d7d095..9ef19dd71 100644 --- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx +++ b/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx @@ -36,7 +36,14 @@ export function useViewerRightRailButtons() { order: 10, render: ({ disabled }) => ( - +
@@ -52,7 +60,7 @@ export function useViewerRightRailButtons() {
- {}} /> +
diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 937c6067a..9217511ef 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -80,6 +80,14 @@ interface ViewerContextType { isBookmarkSidebarVisible: boolean; toggleBookmarkSidebar: () => void; + // Search interface visibility + isSearchInterfaceVisible: boolean; + searchInterfaceActions: { + open: () => void; + close: () => void; + toggle: () => void; + }; + // Annotation visibility toggle isAnnotationsVisible: boolean; toggleAnnotationsVisibility: () => void; @@ -145,6 +153,7 @@ export const ViewerProvider: React.FC = ({ children }) => { // UI state - only state directly managed by this context const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false); + const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false); const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true); const [isAnnotationMode, setIsAnnotationModeState] = useState(false); const [activeFileIndex, setActiveFileIndex] = useState(0); @@ -207,6 +216,12 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsBookmarkSidebarVisible(prev => !prev); }; + const searchInterfaceActions = { + open: () => setSearchInterfaceVisible(true), + close: () => setSearchInterfaceVisible(false), + toggle: () => setSearchInterfaceVisible(prev => !prev), + }; + const toggleAnnotationsVisibility = () => { setIsAnnotationsVisible(prev => !prev); }; @@ -294,6 +309,10 @@ export const ViewerProvider: React.FC = ({ children }) => { isBookmarkSidebarVisible, toggleBookmarkSidebar, + // Search interface + isSearchInterfaceVisible, + searchInterfaceActions, + // Annotation controls isAnnotationsVisible, toggleAnnotationsVisibility, diff --git a/frontend/src/core/contexts/viewer/viewerActions.ts b/frontend/src/core/contexts/viewer/viewerActions.ts index d32c5077c..882ba8aa8 100644 --- a/frontend/src/core/contexts/viewer/viewerActions.ts +++ b/frontend/src/core/contexts/viewer/viewerActions.ts @@ -52,6 +52,7 @@ export interface SearchActions { next: () => void; previous: () => void; clear: () => void; + goToResult: (index: number) => void; } export interface ExportActions { @@ -287,6 +288,12 @@ export function createViewerActions({ api.clear(); } }, + goToResult: (index: number) => { + const api = registry.current.search?.api; + if (api?.goToResult) { + api.goToResult(index); + } + }, }; const exportActions: ExportActions = {