mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 01:19:24 +00:00
search pdf
This commit is contained in:
parent
368e9801a1
commit
143f0c5031
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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'}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Zoom Out */}
|
||||
<Tooltip content={t('rightRail.zoomOut', 'Zoom Out')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => (window as any).embedPdfZoom?.zoomOut()}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="zoom-out" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Zoom In */}
|
||||
<Tooltip content={t('rightRail.zoomIn', 'Zoom In')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => (window as any).embedPdfZoom?.zoomIn()}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="zoom-in" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{/* Area Zoom */}
|
||||
<Tooltip content={t('rightRail.areaZoom', 'Area Zoom')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => (window as any).embedPdfZoom?.toggleMarqueeZoom()}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="crop-free" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Pan Mode */}
|
||||
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow>
|
||||
|
160
frontend/src/components/viewer/CustomSearchLayer.tsx
Normal file
160
frontend/src/components/viewer/CustomSearchLayer.tsx
Normal file
@ -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<SearchResultState | null>(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 (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10
|
||||
}}>
|
||||
{pageResults.map(({ result, originalIndex }, idx) => (
|
||||
<div key={`result-${idx}`}>
|
||||
{result.rects.map((rect, rectIdx) => (
|
||||
<div
|
||||
key={`rect-${idx}-${rectIdx}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${rect.origin.y * scale - padding}px`,
|
||||
left: `${rect.origin.x * scale - padding}px`,
|
||||
width: `${rect.size.width * scale + (padding * 2)}px`,
|
||||
height: `${rect.size.height * scale + (padding * 2)}px`,
|
||||
backgroundColor: originalIndex === searchResultState?.activeResultIndex
|
||||
? activeHighlightColor
|
||||
: highlightColor,
|
||||
opacity: opacity,
|
||||
borderRadius: `${borderRadius}px`,
|
||||
transform: 'scale(1.02)',
|
||||
transformOrigin: 'center',
|
||||
transition: 'opacity 0.3s ease-in-out, background-color 0.2s ease-in-out',
|
||||
pointerEvents: 'none',
|
||||
boxShadow: originalIndex === searchResultState?.activeResultIndex
|
||||
? '0 0 0 1px rgba(255, 191, 0, 0.8)'
|
||||
: 'none'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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<HTMLDivElement>(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 (
|
||||
<Box
|
||||
ref={viewerRef}
|
||||
@ -177,6 +218,12 @@ const EmbedPdfViewer = ({
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search Interface Overlay */}
|
||||
<SearchInterface
|
||||
visible={isSearchVisible}
|
||||
onClose={() => setIsSearchVisible(false)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -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) {
|
||||
<SelectionControlsExporter />
|
||||
<PanControlsExporter />
|
||||
<SpreadControlsExporter />
|
||||
<SearchControlsExporter />
|
||||
<GlobalPointerProvider>
|
||||
<Viewport
|
||||
style={{
|
||||
@ -214,7 +221,10 @@ export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) {
|
||||
{/* 2. High-resolution tile layer on top */}
|
||||
<TilingLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* 3. Selection layer for text interaction */}
|
||||
{/* 3. Search highlight layer */}
|
||||
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* 4. Selection layer for text interaction */}
|
||||
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||
</div>
|
||||
</PagePointerProvider>
|
||||
|
94
frontend/src/components/viewer/SearchControlsExporter.tsx
Normal file
94
frontend/src/components/viewer/SearchControlsExporter.tsx
Normal file
@ -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
|
||||
}
|
233
frontend/src/components/viewer/SearchInterface.tsx
Normal file
233
frontend/src/components/viewer/SearchInterface.tsx
Normal file
@ -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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Box
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '20px',
|
||||
right: '20px',
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
minWidth: '320px',
|
||||
maxWidth: '400px'
|
||||
}}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('search.title', 'Search PDF')}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
aria-label="Close search"
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Search input */}
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
placeholder={t('search.placeholder', 'Enter search term...')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => handleSearch(searchQuery)}
|
||||
disabled={!searchQuery.trim() || isSearching}
|
||||
loading={isSearching}
|
||||
>
|
||||
<LocalIcon icon="search" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Results info and navigation */}
|
||||
{resultInfo && (
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{resultInfo.totalResults === 0
|
||||
? t('search.noResults', 'No results found')
|
||||
: t('search.resultCount', '{{current}} of {{total}}', {
|
||||
current: resultInfo.currentIndex,
|
||||
total: resultInfo.totalResults
|
||||
})
|
||||
}
|
||||
</Text>
|
||||
|
||||
{resultInfo.totalResults > 0 && (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handlePrevious}
|
||||
disabled={resultInfo.currentIndex <= 1}
|
||||
aria-label="Previous result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleNext}
|
||||
disabled={resultInfo.currentIndex >= resultInfo.totalResults}
|
||||
aria-label="Next result"
|
||||
>
|
||||
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
<Text size="xs" c="dimmed" ta="center" mt="sm">
|
||||
{t('search.searching', 'Searching...')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user