diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fff464cbc..46958caae 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@embedpdf/plugin-pan": "^1.1.1", "@embedpdf/plugin-render": "^1.1.1", "@embedpdf/plugin-scroll": "^1.1.1", + "@embedpdf/plugin-search": "^1.1.1", "@embedpdf/plugin-selection": "^1.1.1", "@embedpdf/plugin-spread": "^1.1.1", "@embedpdf/plugin-tiling": "^1.1.1", @@ -729,6 +730,22 @@ "vue": ">=3.2.0" } }, + "node_modules/@embedpdf/plugin-search": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.1.1.tgz", + "integrity": "sha512-h3gc9HVK8HCOD2MGCikhbkpwWjAYnu/KQ1ZxT2mb5AvbcIog+LQhVElSyV80deeFStqqLn/epz27b2TqQSz0EA==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "@embedpdf/plugin-loader": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, "node_modules/@embedpdf/plugin-selection": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d5a6ce969..88fe1f36d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@embedpdf/plugin-pan": "^1.1.1", "@embedpdf/plugin-render": "^1.1.1", "@embedpdf/plugin-scroll": "^1.1.1", + "@embedpdf/plugin-search": "^1.1.1", "@embedpdf/plugin-selection": "^1.1.1", "@embedpdf/plugin-spread": "^1.1.1", "@embedpdf/plugin-tiling": "^1.1.1", diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index 6d2b7a998..3f9272028 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -219,50 +219,13 @@ export default function RightRail() { variant="subtle" radius="md" className="right-rail-icon" - onClick={() => (window as any).embedPdfControls?.search()} + onClick={() => (window as any).togglePdfSearch?.()} disabled={currentView !== 'viewer'} > - {/* Zoom Out */} - - (window as any).embedPdfZoom?.zoomOut()} - disabled={currentView !== 'viewer'} - > - - - - - {/* Zoom In */} - - (window as any).embedPdfZoom?.zoomIn()} - disabled={currentView !== 'viewer'} - > - - - - {/* Area Zoom */} - - (window as any).embedPdfZoom?.toggleMarqueeZoom()} - disabled={currentView !== 'viewer'} - > - - - {/* Pan Mode */} diff --git a/frontend/src/components/viewer/CustomSearchLayer.tsx b/frontend/src/components/viewer/CustomSearchLayer.tsx new file mode 100644 index 000000000..46f81c835 --- /dev/null +++ b/frontend/src/components/viewer/CustomSearchLayer.tsx @@ -0,0 +1,160 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useSearch } from '@embedpdf/plugin-search/react'; + +interface SearchLayerProps { + pageIndex: number; + scale: number; + highlightColor?: string; + activeHighlightColor?: string; + opacity?: number; + padding?: number; + borderRadius?: number; +} + +interface SearchResultState { + results: Array<{ + pageIndex: number; + rects: Array<{ + origin: { x: number; y: number }; + size: { width: number; height: number }; + }>; + }>; + activeResultIndex?: number; +} + +export function CustomSearchLayer({ + pageIndex, + scale, + highlightColor = '#FFFF00', + activeHighlightColor = '#FFBF00', + opacity = 0.6, + padding = 2, + borderRadius = 4 +}: SearchLayerProps) { + const { provides: searchProvides } = useSearch(); + const [searchResultState, setSearchResultState] = useState(null); + + // Subscribe to search result state changes + useEffect(() => { + if (!searchProvides) { + console.log('🔍 CustomSearchLayer: No search provides available for page', pageIndex); + return; + } + + console.log('🔍 CustomSearchLayer: Setting up search result subscription for page', pageIndex); + console.log('🔍 CustomSearchLayer: Available search methods:', Object.keys(searchProvides)); + + const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => { + console.log('🔍 CustomSearchLayer: Search result state changed for page', pageIndex, ':', { + state, + resultsCount: state?.results?.length || 0, + activeIndex: state?.activeResultIndex, + results: state?.results + }); + + // Expose search results globally for SearchInterface + if (state?.results) { + (window as any).currentSearchResults = state.results; + (window as any).currentActiveIndex = (state.activeResultIndex || 0) + 1; // Convert to 1-based index + console.log('🔍 CustomSearchLayer: Exposed global search data:', { + totalResults: state.results.length, + activeIndex: (state.activeResultIndex || 0) + 1 + }); + + // Auto-scroll to active result if we have one + if (state.activeResultIndex !== undefined && state.activeResultIndex >= 0) { + const activeResult = state.results[state.activeResultIndex]; + if (activeResult) { + console.log('🔍 CustomSearchLayer: Auto-scrolling to active result on page', activeResult.pageIndex); + + // Use the scroll API to navigate to the page containing the active result + const scrollAPI = (window as any).embedPdfScroll; + if (scrollAPI && scrollAPI.scrollToPage) { + // Convert 0-based page index to 1-based page number + const pageNumber = activeResult.pageIndex + 1; + scrollAPI.scrollToPage(pageNumber); + console.log('🔍 CustomSearchLayer: Scrolled to page', pageNumber); + } + } + } + } else { + (window as any).currentSearchResults = null; + (window as any).currentActiveIndex = 0; + } + + setSearchResultState(state); + }); + + if (!unsubscribe) { + console.warn('🔍 CustomSearchLayer: No onSearchResultStateChange method available'); + } + + return unsubscribe; + }, [searchProvides, pageIndex]); + + // Filter results for current page while preserving original indices + const pageResults = useMemo(() => { + if (!searchResultState?.results) { + console.log(`🔍 CustomSearchLayer: No search results for page ${pageIndex} (no results array)`); + return []; + } + + const filtered = searchResultState.results + .map((result, originalIndex) => ({ result, originalIndex })) + .filter(({ result }) => result.pageIndex === pageIndex); + + console.log(`🔍 CustomSearchLayer: Page ${pageIndex} filtering:`, { + totalResults: searchResultState.results.length, + pageResults: filtered.length, + allPageIndices: searchResultState.results.map(r => r.pageIndex), + currentPage: pageIndex, + filtered + }); + return filtered; + }, [searchResultState, pageIndex]); + + if (!pageResults.length) { + return null; + } + + return ( +
+ {pageResults.map(({ result, originalIndex }, idx) => ( +
+ {result.rects.map((rect, rectIdx) => ( +
+ ))} +
+ ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx index 7f54dd11c..defb4685a 100644 --- a/frontend/src/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx @@ -8,6 +8,7 @@ import { useFileState } from "../../contexts/FileContext"; import { useFileWithUrl } from "../../hooks/useFileWithUrl"; import { LocalEmbedPDF } from './LocalEmbedPDF'; import { PdfViewerToolbar } from './PdfViewerToolbar'; +import { SearchInterface } from './SearchInterface'; export interface EmbedPdfViewerProps { sidebarsVisible: boolean; @@ -27,6 +28,7 @@ const EmbedPdfViewer = ({ const { colorScheme } = useMantineColorScheme(); const viewerRef = React.useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); + const [isSearchVisible, setIsSearchVisible] = React.useState(false); // Get current file from FileContext const { selectors } = useFileState(); @@ -88,6 +90,45 @@ const EmbedPdfViewer = ({ } }, []); + // Handle keyboard zoom shortcuts + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!isViewerHovered) return; + + // Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed + if (event.ctrlKey || event.metaKey) { + const zoomAPI = (window as any).embedPdfZoom; + if (zoomAPI) { + if (event.key === '=' || event.key === '+') { + // Ctrl+= or Ctrl++ for zoom in + event.preventDefault(); + zoomAPI.zoomIn(); + } else if (event.key === '-' || event.key === '_') { + // Ctrl+- for zoom out + event.preventDefault(); + zoomAPI.zoomOut(); + } + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isViewerHovered]); + + // Expose search toggle function globally for right rail button + React.useEffect(() => { + (window as any).togglePdfSearch = () => { + setIsSearchVisible(prev => !prev); + }; + + return () => { + delete (window as any).togglePdfSearch; + }; + }, []); + return ( )} + + {/* Search Interface Overlay */} + setIsSearchVisible(false)} + /> ); }; diff --git a/frontend/src/components/viewer/LocalEmbedPDF.tsx b/frontend/src/components/viewer/LocalEmbedPDF.tsx index 91da33f14..481e5c0a1 100644 --- a/frontend/src/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/components/viewer/LocalEmbedPDF.tsx @@ -14,11 +14,14 @@ import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selecti import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react'; +import { SearchPluginPackage } from '@embedpdf/plugin-search/react'; +import { CustomSearchLayer } from './CustomSearchLayer'; import { ZoomControlsExporter } from './ZoomControlsExporter'; import { ScrollControlsExporter } from './ScrollControlsExporter'; import { SelectionControlsExporter } from './SelectionControlsExporter'; import { PanControlsExporter } from './PanControlsExporter'; import { SpreadControlsExporter } from './SpreadControlsExporter'; +import { SearchControlsExporter } from './SearchControlsExporter'; interface LocalEmbedPDFProps { file?: File | Blob; @@ -79,7 +82,7 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) { // Register zoom plugin with configuration createPluginRegistration(ZoomPluginPackage, { - defaultZoomLevel: ZoomMode.FitPage, + defaultZoomLevel: 1.0, // Start at exactly 100% zoom minZoom: 0.2, maxZoom: 3.0, }), @@ -95,6 +98,9 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) { createPluginRegistration(SpreadPluginPackage, { defaultSpreadMode: SpreadMode.None, // Start with single page view }), + + // Register search plugin for text search + createPluginRegistration(SearchPluginPackage), ]; }, [pdfUrl]); @@ -174,6 +180,7 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) { + - {/* 3. Selection layer for text interaction */} + {/* 3. Search highlight layer */} + + + {/* 4. Selection layer for text interaction */}
diff --git a/frontend/src/components/viewer/SearchControlsExporter.tsx b/frontend/src/components/viewer/SearchControlsExporter.tsx new file mode 100644 index 000000000..cd4c17530 --- /dev/null +++ b/frontend/src/components/viewer/SearchControlsExporter.tsx @@ -0,0 +1,94 @@ +import { useEffect } from 'react'; +import { useSearch } from '@embedpdf/plugin-search/react'; + +/** + * Component that runs inside EmbedPDF context and exports search controls globally + */ +export function SearchControlsExporter() { + const { provides: search, state } = useSearch(); + + useEffect(() => { + if (search && state) { + // Debug: log the actual search hook structure + console.log('🔍 EmbedPDF search hook structure:', { + search, + state, + searchKeys: Object.keys(search), + stateKeys: Object.keys(state), + currentQuery: state.query, + isActive: state.active, + isLoading: state.loading + }); + + // Export search controls to global window for toolbar access + (window as any).embedPdfSearch = { + search: async (query: string) => { + console.log('🔍 EmbedPDF: Starting search for:', query); + console.log('🔍 Search API available:', !!search); + console.log('🔍 Search API methods:', search ? Object.keys(search) : 'none'); + + try { + // First start the search session + search.startSearch(); + console.log('🔍 startSearch() called'); + + // Then search all pages with the query + const results = await search.searchAllPages(query); + console.log('🔍 searchAllPages() results:', results); + + return results; + } catch (error) { + console.error('🔍 Search error:', error); + throw error; + } + }, + clearSearch: () => { + console.log('🔍 EmbedPDF: Stopping search'); + search.stopSearch(); + }, + nextResult: () => { + console.log('🔍 EmbedPDF: Going to next search result'); + const newIndex = search.nextResult(); + console.log('🔍 EmbedPDF: New active result index:', newIndex); + // The goToResult method should handle scrolling automatically + return newIndex; + }, + previousResult: () => { + console.log('🔍 EmbedPDF: Going to previous search result'); + const newIndex = search.previousResult(); + console.log('🔍 EmbedPDF: New active result index:', newIndex); + // The goToResult method should handle scrolling automatically + return newIndex; + }, + goToResult: (index: number) => { + console.log('🔍 EmbedPDF: Going to search result:', index); + const resultIndex = search.goToResult(index); + console.log('🔍 EmbedPDF: Navigated to result index:', resultIndex); + return resultIndex; + }, + // State getters + getSearchQuery: () => state.query, + isActive: () => state.active, + isLoading: () => state.loading, + // Current state for UI updates + state: state, + // Debug info + searchAPI: search, + availableMethods: search ? Object.keys(search) : [], + }; + + console.log('🔍 EmbedPDF search controls exported to window.embedPdfSearch', { + currentQuery: state.query, + isActive: state.active, + isLoading: state.loading, + searchAPI: search, + availableMethods: search ? Object.keys(search) : 'no search API', + state: state + }); + } else { + console.warn('EmbedPDF search hook not available yet'); + } + }, [search, state]); + + return null; // This component doesn't render anything +} \ No newline at end of file diff --git a/frontend/src/components/viewer/SearchInterface.tsx b/frontend/src/components/viewer/SearchInterface.tsx new file mode 100644 index 000000000..63fa93aca --- /dev/null +++ b/frontend/src/components/viewer/SearchInterface.tsx @@ -0,0 +1,233 @@ +import React, { useState, useEffect } from 'react'; +import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { LocalIcon } from '../shared/LocalIcon'; + +interface SearchInterfaceProps { + visible: boolean; + onClose: () => void; +} + +export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(''); + const [resultInfo, setResultInfo] = useState<{ + currentIndex: number; + totalResults: number; + query: string; + } | null>(null); + const [isSearching, setIsSearching] = useState(false); + + // Monitor search state changes + useEffect(() => { + if (!visible) return; + + const checkSearchState = () => { + const searchAPI = (window as any).embedPdfSearch; + if (searchAPI) { + const state = searchAPI.state; + + console.log('🔍 SearchInterface: Checking search state:', { + state, + hasState: !!state, + query: state?.query, + active: state?.active, + loading: state?.loading + }); + + if (state && state.query && state.active) { + // Try to get result info from the global search data + // The CustomSearchLayer stores results, let's try to access them + const searchResults = (window as any).currentSearchResults; + const activeIndex = (window as any).currentActiveIndex || 1; + + console.log('🔍 SearchInterface: Search results data:', { + searchResults, + activeIndex, + totalResults: searchResults ? searchResults.length : 0 + }); + + setResultInfo({ + currentIndex: activeIndex, + totalResults: searchResults ? searchResults.length : 0, + query: state.query + }); + } else if (state && !state.active) { + setResultInfo(null); + } + + setIsSearching(state ? state.loading : false); + } + }; + + // Check immediately and then poll for updates + checkSearchState(); + const interval = setInterval(checkSearchState, 200); + + return () => clearInterval(interval); + }, [visible]); + + const handleSearch = async (query: string) => { + if (!query.trim()) return; + + const searchAPI = (window as any).embedPdfSearch; + if (searchAPI) { + setIsSearching(true); + try { + const results = await searchAPI.search(query.trim()); + console.log('Search completed:', results); + } catch (error) { + console.error('Search failed:', error); + } finally { + setIsSearching(false); + } + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleSearch(searchQuery); + } else if (event.key === 'Escape') { + onClose(); + } + }; + + const handleNext = () => { + const searchAPI = (window as any).embedPdfSearch; + if (searchAPI) { + const newIndex = searchAPI.nextResult(); + console.log('Next result:', newIndex); + } + }; + + const handlePrevious = () => { + const searchAPI = (window as any).embedPdfSearch; + if (searchAPI) { + const newIndex = searchAPI.previousResult(); + console.log('Previous result:', newIndex); + } + }; + + const handleClearSearch = () => { + const searchAPI = (window as any).embedPdfSearch; + if (searchAPI) { + searchAPI.clearSearch(); + } + setSearchQuery(''); + setResultInfo(null); + }; + + const handleClose = () => { + handleClearSearch(); + onClose(); + }; + + if (!visible) return null; + + return ( + + {/* Header with close button */} + + + {t('search.title', 'Search PDF')} + + + + + + + {/* Search input */} + + setSearchQuery(e.currentTarget.value)} + onKeyDown={handleKeyDown} + style={{ flex: 1 }} + rightSection={ + handleSearch(searchQuery)} + disabled={!searchQuery.trim() || isSearching} + loading={isSearching} + > + + + } + /> + + + {/* Results info and navigation */} + {resultInfo && ( + + + {resultInfo.totalResults === 0 + ? t('search.noResults', 'No results found') + : t('search.resultCount', '{{current}} of {{total}}', { + current: resultInfo.currentIndex, + total: resultInfo.totalResults + }) + } + + + {resultInfo.totalResults > 0 && ( + + + + + = resultInfo.totalResults} + aria-label="Next result" + > + + + + + + + )} + + )} + + {/* Loading state */} + {isSearching && ( + + {t('search.searching', 'Searching...')} + + )} + + ); +} \ No newline at end of file