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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 244 additions and 114 deletions

View File

@ -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

View File

@ -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(() => {

View File

@ -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);
}
},
}
});
}

View File

@ -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<HTMLInputElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(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<HTMLInputElement>) => {
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 */}
<Group mb="md">
{/* Header with close button */}
<Group mb="md" justify="space-between">
<Text size="sm" fw={600}>
{t('search.title', 'Search PDF')}
</Text>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleCloseClick}
aria-label="Close search"
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
{/* Search input */}
<Group mb="md">
<TextInput
ref={inputRef}
placeholder={t('search.placeholder', 'Enter search term...')}
value={searchQuery}
onChange={(e) => {
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={
<ActionIcon
variant="subtle"
onClick={() => handleSearch(searchQuery)}
disabled={!searchQuery.trim() || isSearching}
loading={isSearching}
>
<LocalIcon icon="search" width="1rem" height="1rem" />
</ActionIcon>
searchQuery.trim() && (
<ActionIcon
variant="subtle"
onClick={handleClearSearch}
aria-label="Clear search"
>
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
</ActionIcon>
)
}
/>
</Group>
{/* Results info and navigation */}
{resultInfo && (
<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">
<TextInput
size="xs"
value={jumpToValue}
onChange={(e) => setJumpToValue(e.currentTarget.value)}
onKeyDown={handleJumpToKeyDown}
onBlur={handleJumpToSubmit}
placeholder={resultInfo.currentIndex.toString()}
style={{ width: '3rem' }}
type="number"
min="1"
max={resultInfo.totalResults}
/>
<Text size="sm" c="dimmed">
of {resultInfo.totalResults}
</Text>
</Group>
)}
{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>
)}
{/* Results info and navigation - always show */}
<Group justify="space-between" align="center">
<Group gap="xs" align="center">
<TextInput
size="xs"
value={jumpToValue}
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}
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}
/>
<Text size="sm" c="dimmed">
of {resultInfo?.totalResults || 0}
</Text>
</Group>
)}
<Group gap="xs">
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevious}
disabled={!resultInfo || 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 || resultInfo.currentIndex >= resultInfo.totalResults}
aria-label="Next result"
>
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
</ActionIcon>
</Group>
</Group>
{/* Loading state */}
{isSearching && (

View File

@ -36,7 +36,14 @@ export function useViewerRightRailButtons() {
order: 10,
render: ({ disabled }) => (
<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>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
@ -45,6 +52,7 @@ export function useViewerRightRailButtons() {
className="right-rail-icon"
disabled={disabled}
aria-label={searchLabel}
onClick={viewer.searchInterfaceActions.toggle}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
</ActionIcon>
@ -52,7 +60,7 @@ export function useViewerRightRailButtons() {
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '20rem' }}>
<SearchInterface visible={true} onClose={() => {}} />
<SearchInterface visible={viewer.isSearchInterfaceVisible} onClose={viewer.searchInterfaceActions.close} />
</div>
</Popover.Dropdown>
</Popover>

View File

@ -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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ children }) => {
isBookmarkSidebarVisible,
toggleBookmarkSidebar,
// Search interface
isSearchInterfaceVisible,
searchInterfaceActions,
// Annotation controls
isAnnotationsVisible,
toggleAnnotationsVisibility,

View File

@ -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 = {