Chore/v2/ctrlf (#5217)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Reece Browne
2025-12-11 12:42:16 +00:00
committed by GitHub
parent ae72344317
commit f29d85565a
7 changed files with 244 additions and 114 deletions
@@ -44,8 +44,10 @@ export function CustomSearchLayer({
} }
const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => { const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => {
if (!state) return;
// Auto-scroll to active search result // 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]; const activeResult = state.results[state.activeResultIndex];
if (activeResult) { if (activeResult) {
const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number
@@ -43,6 +43,8 @@ const EmbedPdfViewerContent = ({
isThumbnailSidebarVisible, isThumbnailSidebarVisible,
toggleThumbnailSidebar, toggleThumbnailSidebar,
isBookmarkSidebarVisible, isBookmarkSidebarVisible,
isSearchInterfaceVisible,
searchInterfaceActions,
zoomActions, zoomActions,
panActions: _panActions, panActions: _panActions,
rotationActions: _rotationActions, rotationActions: _rotationActions,
@@ -184,7 +186,7 @@ const EmbedPdfViewerContent = ({
onZoomOut: zoomActions.zoomOut, onZoomOut: zoomActions.zoomOut,
}); });
// Handle keyboard zoom shortcuts // Handle keyboard shortcuts (zoom and search)
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (!isViewerHovered) return; if (!isViewerHovered) return;
@@ -199,6 +201,16 @@ const EmbedPdfViewerContent = ({
// Ctrl+- for zoom out // Ctrl+- for zoom out
event.preventDefault(); event.preventDefault();
zoomActions.zoomOut(); 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 () => { return () => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [isViewerHovered]); }, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
// Register checker for unsaved changes (annotations only for now) // Register checker for unsaved changes (annotations only for now)
useEffect(() => { useEffect(() => {
@@ -28,9 +28,11 @@ export function SearchAPIBridge() {
if (!search) return; if (!search) return;
const unsubscribe = search.onSearchResultStateChange?.((state: any) => { const unsubscribe = search.onSearchResultStateChange?.((state: any) => {
if (!state) return;
const newState = { const newState = {
results: state?.results || null, results: state.results || null,
activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index activeIndex: (state.activeResultIndex || 0) + 1 // Convert to 1-based index
}; };
setLocalState(prevState => { setLocalState(prevState => {
@@ -52,16 +54,42 @@ export function SearchAPIBridge() {
state: localState, state: localState,
api: { api: {
search: async (query: string) => { search: async (query: string) => {
if (search?.startSearch && search?.searchAllPages) {
search.startSearch(); search.startSearch();
return search.searchAllPages(query); return search.searchAllPages(query);
}
}, },
clear: () => { clear: () => {
try {
if (search?.stopSearch) {
search.stopSearch(); search.stopSearch();
}
} catch (error) {
console.warn('Error stopping search:', error);
}
setLocalState({ results: null, activeIndex: 0 }); setLocalState({ results: null, activeIndex: 0 });
}, },
next: () => search.nextResult(), next: () => {
previous: () => search.previousResult(), try {
goToResult: (index: number) => search.goToResult(index), 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);
}
},
} }
}); });
} }
@@ -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 { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LocalIcon } from '@app/components/shared/LocalIcon'; import { LocalIcon } from '@app/components/shared/LocalIcon';
@@ -12,6 +12,8 @@ interface SearchInterfaceProps {
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext); const viewerContext = React.useContext(ViewerContext);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchState = viewerContext?.getSearchState(); const searchState = viewerContext?.getSearchState();
const searchResults = searchState?.results; const searchResults = searchState?.results;
@@ -26,6 +28,61 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
} | null>(null); } | null>(null);
const [isSearching, setIsSearching] = useState(false); 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 // Monitor search state changes
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
@@ -59,30 +116,21 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [visible, searchResults, searchActiveIndex, searchQuery]); }, [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<HTMLInputElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
handleSearch(searchQuery); // Navigate to next result on Enter
event.preventDefault();
handleNext();
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
onClose(); 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 // No longer need to sync with external API on mount - removed
const handleJumpToResult = (index: number) => { const handleJumpToResult = (index: number) => {
// Use context actions instead of window API - functionality simplified for now
if (resultInfo && index >= 1 && index <= resultInfo.totalResults) { if (resultInfo && index >= 1 && index <= resultInfo.totalResults) {
// Note: goToResult functionality would need to be implemented in SearchAPIBridge // Convert to 0-based index for the API
console.log('Jump to result:', index); searchActions?.goToResult?.(index - 1);
} }
}; };
const handleJumpToSubmit = () => { const handleJumpToSubmit = () => {
const index = parseInt(jumpToValue); const index = parseInt(jumpToValue, 10);
if (index && resultInfo && index >= 1 && index <= resultInfo.totalResults) { if (!isNaN(index) && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
handleJumpToResult(index); 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(); handleClearSearch();
onClose(); onClose();
}; };
@@ -135,75 +190,84 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
padding: '0px' padding: '0px'
}} }}
> >
{/* Header */} {/* Header with close button */}
<Group mb="md"> <Group mb="md" justify="space-between">
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
{t('search.title', 'Search PDF')} {t('search.title', 'Search PDF')}
</Text> </Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleCloseClick}
aria-label="Close search"
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group> </Group>
{/* Search input */} {/* Search input */}
<Group mb="md"> <Group mb="md">
<TextInput <TextInput
ref={inputRef}
placeholder={t('search.placeholder', 'Enter search term...')} placeholder={t('search.placeholder', 'Enter search term...')}
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
const newValue = e.currentTarget.value; const newValue = e.currentTarget.value;
setSearchQuery(newValue); setSearchQuery(newValue);
// If user clears the input, clear the search highlights
if (!newValue.trim()) {
handleClearSearch();
}
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
style={{ flex: 1 }} style={{ flex: 1 }}
rightSection={ rightSection={
searchQuery.trim() && (
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
onClick={() => handleSearch(searchQuery)} onClick={handleClearSearch}
disabled={!searchQuery.trim() || isSearching} aria-label="Clear search"
loading={isSearching}
> >
<LocalIcon icon="search" width="1rem" height="1rem" /> <LocalIcon icon="close" width="0.875rem" height="0.875rem" />
</ActionIcon> </ActionIcon>
)
} }
/> />
</Group> </Group>
{/* Results info and navigation */} {/* Results info and navigation - always show */}
{resultInfo && (
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
{resultInfo.totalResults === 0 ? (
<Text size="sm" c="dimmed">
{t('search.noResults', 'No results found')}
</Text>
) : (
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<TextInput <TextInput
size="xs" size="xs"
value={jumpToValue} value={jumpToValue}
onChange={(e) => setJumpToValue(e.currentTarget.value)} onChange={(e) => {
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} onKeyDown={handleJumpToKeyDown}
onBlur={handleJumpToSubmit} onBlur={() => setJumpToValue('')} // Clear on blur instead of submit
placeholder={resultInfo.currentIndex.toString()} placeholder={(resultInfo?.currentIndex || 0).toString()}
style={{ width: '3rem' }} style={{ width: '3rem' }}
type="number" type="number"
min="1" min="1"
max={resultInfo.totalResults} max={resultInfo?.totalResults || 0}
disabled={!resultInfo || resultInfo.totalResults === 0}
/> />
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
of {resultInfo.totalResults} of {resultInfo?.totalResults || 0}
</Text> </Text>
</Group> </Group>
)}
{resultInfo.totalResults > 0 && (
<Group gap="xs"> <Group gap="xs">
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
size="sm" size="sm"
onClick={handlePrevious} onClick={handlePrevious}
disabled={resultInfo.currentIndex <= 1} disabled={!resultInfo || resultInfo.currentIndex <= 1}
aria-label="Previous result" aria-label="Previous result"
> >
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" /> <LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
@@ -212,23 +276,13 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
variant="subtle" variant="subtle"
size="sm" size="sm"
onClick={handleNext} onClick={handleNext}
disabled={resultInfo.currentIndex >= resultInfo.totalResults} disabled={!resultInfo || resultInfo.currentIndex >= resultInfo.totalResults}
aria-label="Next result" aria-label="Next result"
> >
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" /> <LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
</ActionIcon> </ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleClearSearch}
aria-label="Clear search"
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group> </Group>
)}
</Group> </Group>
)}
{/* Loading state */} {/* Loading state */}
{isSearching && ( {isSearching && (
@@ -36,7 +36,14 @@ export function useViewerRightRailButtons() {
order: 10, order: 10,
render: ({ disabled }) => ( render: ({ disabled }) => (
<Tooltip content={searchLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}> <Tooltip content={searchLabel} position={tooltipPosition} offset={12} arrow portalTarget={document.body}>
<Popover position={tooltipPosition} withArrow shadow="md" offset={8}> <Popover
position={tooltipPosition}
withArrow
shadow="md"
offset={8}
opened={viewer.isSearchInterfaceVisible}
onClose={viewer.searchInterfaceActions.close}
>
<Popover.Target> <Popover.Target>
<div style={{ display: 'inline-flex' }}> <div style={{ display: 'inline-flex' }}>
<ActionIcon <ActionIcon
@@ -45,6 +52,7 @@ export function useViewerRightRailButtons() {
className="right-rail-icon" className="right-rail-icon"
disabled={disabled} disabled={disabled}
aria-label={searchLabel} aria-label={searchLabel}
onClick={viewer.searchInterfaceActions.toggle}
> >
<LocalIcon icon="search" width="1.5rem" height="1.5rem" /> <LocalIcon icon="search" width="1.5rem" height="1.5rem" />
</ActionIcon> </ActionIcon>
@@ -52,7 +60,7 @@ export function useViewerRightRailButtons() {
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
<div style={{ minWidth: '20rem' }}> <div style={{ minWidth: '20rem' }}>
<SearchInterface visible={true} onClose={() => {}} /> <SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
</div> </div>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -80,6 +80,14 @@ interface ViewerContextType {
isBookmarkSidebarVisible: boolean; isBookmarkSidebarVisible: boolean;
toggleBookmarkSidebar: () => void; toggleBookmarkSidebar: () => void;
// Search interface visibility
isSearchInterfaceVisible: boolean;
searchInterfaceActions: {
open: () => void;
close: () => void;
toggle: () => void;
};
// Annotation visibility toggle // Annotation visibility toggle
isAnnotationsVisible: boolean; isAnnotationsVisible: boolean;
toggleAnnotationsVisibility: () => void; toggleAnnotationsVisibility: () => void;
@@ -145,6 +153,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context // UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false); const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true); const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false); const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
const [activeFileIndex, setActiveFileIndex] = useState(0); const [activeFileIndex, setActiveFileIndex] = useState(0);
@@ -207,6 +216,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsBookmarkSidebarVisible(prev => !prev); setIsBookmarkSidebarVisible(prev => !prev);
}; };
const searchInterfaceActions = {
open: () => setSearchInterfaceVisible(true),
close: () => setSearchInterfaceVisible(false),
toggle: () => setSearchInterfaceVisible(prev => !prev),
};
const toggleAnnotationsVisibility = () => { const toggleAnnotationsVisibility = () => {
setIsAnnotationsVisible(prev => !prev); setIsAnnotationsVisible(prev => !prev);
}; };
@@ -294,6 +309,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
isBookmarkSidebarVisible, isBookmarkSidebarVisible,
toggleBookmarkSidebar, toggleBookmarkSidebar,
// Search interface
isSearchInterfaceVisible,
searchInterfaceActions,
// Annotation controls // Annotation controls
isAnnotationsVisible, isAnnotationsVisible,
toggleAnnotationsVisibility, toggleAnnotationsVisibility,
@@ -52,6 +52,7 @@ export interface SearchActions {
next: () => void; next: () => void;
previous: () => void; previous: () => void;
clear: () => void; clear: () => void;
goToResult: (index: number) => void;
} }
export interface ExportActions { export interface ExportActions {
@@ -287,6 +288,12 @@ export function createViewerActions({
api.clear(); api.clear();
} }
}, },
goToResult: (index: number) => {
const api = registry.current.search?.api;
if (api?.goToResult) {
api.goToResult(index);
}
},
}; };
const exportActions: ExportActions = { const exportActions: ExportActions = {