diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx index 29cbc5e4..e4ccf29f 100644 --- a/ui/desktop/src/components/conversation/SearchBar.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, KeyboardEvent, useState, useCallback } from 'react'; +import React, { useEffect, useState, useRef, KeyboardEvent } from 'react'; import { Search as SearchIcon } from 'lucide-react'; import { ArrowDown, ArrowUp, Close } from '../icons'; import { debounce } from 'lodash'; @@ -26,13 +26,6 @@ interface SearchBarProps { /** * SearchBar provides a search input with case-sensitive toggle and result navigation. - * Features: - * - Case-sensitive search toggle - * - Result count display - * - Navigation between results with arrows - * - Keyboard shortcuts (↑/↓ for navigation, Esc to close) - * - Smooth animations for enter/exit - * - Debounced search for better performance */ export const SearchBar: React.FC = ({ onSearch, @@ -41,23 +34,26 @@ export const SearchBar: React.FC = ({ searchResults, inputRef: externalInputRef, initialSearchTerm = '', -}) => { +}: SearchBarProps) => { const [searchTerm, setSearchTerm] = useState(initialSearchTerm); - const [displayTerm, setDisplayTerm] = useState(initialSearchTerm); // For immediate visual feedback const [caseSensitive, setCaseSensitive] = useState(false); const [isExiting, setIsExiting] = useState(false); const internalInputRef = React.useRef(null); const inputRef = externalInputRef || internalInputRef; + const debouncedSearchRef = useRef>(); // Create debounced search function - const debouncedSearch = useCallback( - (term: string, isCaseSensitive: boolean) => { - debounce((searchTerm: string, caseSensitive: boolean) => { - onSearch(searchTerm, caseSensitive); - }, 150)(term, isCaseSensitive); - }, - [onSearch] - ); + useEffect(() => { + const debouncedFn = debounce((term: string, caseSensitive: boolean) => { + onSearch(term, caseSensitive); + }, 200); + + debouncedSearchRef.current = debouncedFn; + + return () => { + debouncedFn.cancel(); + }; + }, [onSearch]); useEffect(() => { inputRef.current?.focus(); @@ -67,29 +63,48 @@ export const SearchBar: React.FC = ({ useEffect(() => { if (initialSearchTerm) { setSearchTerm(initialSearchTerm); - setDisplayTerm(initialSearchTerm); - debouncedSearch(initialSearchTerm, caseSensitive); + if (initialSearchTerm.length >= 2) { + debouncedSearchRef.current?.(initialSearchTerm, caseSensitive); + } } - }, [initialSearchTerm, caseSensitive, debouncedSearch]); + }, [initialSearchTerm, caseSensitive, debouncedSearchRef]); - // Cleanup debounced function on unmount + const [localSearchResults, setLocalSearchResults] = useState(null); + + // Sync external search results with local state useEffect(() => { - return () => { - debouncedSearch.cancel?.(); - }; - }, [debouncedSearch]); + // Only set results if we have a search term + if (!searchTerm) { + setLocalSearchResults(null); + } else { + setLocalSearchResults(searchResults); + } + }, [searchResults, searchTerm]); const handleSearch = (event: React.ChangeEvent) => { const value = event.target.value; - setDisplayTerm(value); // Update display immediately - setSearchTerm(value); // Update actual term - debouncedSearch(value, caseSensitive); + + // Always cancel pending searches first + if (debouncedSearchRef.current) { + debouncedSearchRef.current.cancel(); + } + + // Update display term immediately for UI feedback + setSearchTerm(value); + + // Only trigger search if we have 2 or more characters + if (value.length >= 2) { + debouncedSearchRef.current?.(value, caseSensitive); + } else { + // Clear results if less than 2 characters + onSearch('', caseSensitive); + } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'ArrowUp') { handleNavigate('prev', event); - } else if (event.key === 'ArrowDown') { + } else if (event.key === 'ArrowDown' || event.key === 'Enter') { handleNavigate('next', event); } else if (event.key === 'Escape') { event.preventDefault(); @@ -99,26 +114,32 @@ export const SearchBar: React.FC = ({ const handleNavigate = (direction: 'next' | 'prev', e?: React.MouseEvent | KeyboardEvent) => { e?.preventDefault(); - onNavigate?.(direction); - inputRef.current?.focus(); + if (searchResults && searchResults.count > 0) { + inputRef.current?.focus(); + onNavigate?.(direction); + } }; const toggleCaseSensitive = () => { const newCaseSensitive = !caseSensitive; setCaseSensitive(newCaseSensitive); // Immediately trigger a new search with updated case sensitivity - debouncedSearch(searchTerm, newCaseSensitive); + if (searchTerm) { + debouncedSearchRef.current?.(searchTerm, newCaseSensitive); + } inputRef.current?.focus(); }; const handleClose = () => { setIsExiting(true); - debouncedSearch.cancel?.(); // Cancel any pending searches + debouncedSearchRef.current?.cancel(); // Cancel any pending searches setTimeout(() => { onClose(); }, 150); // Match animation duration }; + const hasResults = searchResults && searchResults.count > 0; + return (
= ({ ref={inputRef} id="search-input" type="text" - value={displayTerm} + value={searchTerm} onChange={handleSearch} onKeyDown={handleKeyDown} placeholder="Search conversation..." - className="w-full text-sm pl-9 pr-10 py-3 bg-bgAppInverse + className="w-full text-sm pl-9 pr-24 py-3 bg-bgAppInverse placeholder:text-textSubtleInverse focus:outline-none active:border-borderProminent" />
-
- {searchResults && searchResults.count} +
+
+
+ {(() => { + return localSearchResults?.count > 0 && searchTerm + ? `${localSearchResults.currentIndex}/${localSearchResults.count}` + : null; + })()} +
+
- - +
+ + +
-
); }; + +export default SearchBar; diff --git a/ui/desktop/src/components/conversation/SearchView.tsx b/ui/desktop/src/components/conversation/SearchView.tsx index 182dc7b3..4f331dab 100644 --- a/ui/desktop/src/components/conversation/SearchView.tsx +++ b/ui/desktop/src/components/conversation/SearchView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, PropsWithChildren, useCallback } from 'react'; -import { SearchBar } from './SearchBar'; +import SearchBar from './SearchBar'; import { SearchHighlighter } from '../../utils/searchHighlighter'; import { debounce } from 'lodash'; import '../../styles/search.css'; @@ -10,6 +10,19 @@ import '../../styles/search.css'; interface SearchViewProps { /** Optional CSS class name */ className?: string; + /** Optional callback for search term changes */ + onSearch?: (term: string, caseSensitive: boolean) => void; + /** Optional callback for navigating between search results */ + onNavigate?: (direction: 'next' | 'prev') => void; + /** Current search results state */ + searchResults?: { + count: number; + currentIndex: number; + } | null; +} + +interface SearchContainerElement extends HTMLDivElement { + _searchHighlighter: SearchHighlighter | null; } /** @@ -20,17 +33,20 @@ interface SearchViewProps { export const SearchView: React.FC> = ({ className = '', children, + onSearch, + onNavigate, + searchResults, }) => { const [isSearchVisible, setIsSearchVisible] = useState(false); const [initialSearchTerm, setInitialSearchTerm] = useState(''); - const [searchResults, setSearchResults] = useState<{ + const [internalSearchResults, setInternalSearchResults] = useState<{ currentIndex: number; count: number; } | null>(null); const searchInputRef = React.useRef(null); const highlighterRef = React.useRef(null); - const containerRef = React.useRef(null); + const containerRef = React.useRef(null); const lastSearchRef = React.useRef<{ term: string; caseSensitive: boolean }>({ term: '', caseSensitive: false, @@ -39,23 +55,37 @@ export const SearchView: React.FC> = ({ // Create debounced highlight function const debouncedHighlight = useCallback( (term: string, caseSensitive: boolean, highlighter: SearchHighlighter) => { - debounce( - (searchTerm: string, isCaseSensitive: boolean, searchHighlighter: SearchHighlighter) => { - const highlights = searchHighlighter.highlight(searchTerm, isCaseSensitive); - const count = highlights.length; + const performHighlight = () => { + const highlights = highlighter.highlight(term, caseSensitive); + const count = highlights.length; - if (count > 0) { - setSearchResults({ - currentIndex: 1, - count, - }); - searchHighlighter.setCurrentMatch(0, true); // Explicitly scroll when setting initial match - } else { - setSearchResults(null); - } - }, - 150 - )(term, caseSensitive, highlighter); + if (count > 0) { + setInternalSearchResults({ + currentIndex: 1, + count, + }); + highlighter.setCurrentMatch(0, true); + } else { + setInternalSearchResults(null); + } + }; + + // If this is a case sensitivity change (same term, different case setting), + // execute immediately + if ( + term === lastSearchRef.current.term && + caseSensitive !== lastSearchRef.current.caseSensitive + ) { + performHighlight(); + return; + } + + // Create a debounced version of performHighlight + const debouncedFn = debounce(performHighlight, 150); + debouncedFn(); + + // Store the debounced function for potential cancellation + return debouncedFn; }, [] ); @@ -69,10 +99,18 @@ export const SearchView: React.FC> = ({ const handleSearch = useCallback( (term: string, caseSensitive: boolean) => { // Store the latest search parameters + const isCaseChange = + term === lastSearchRef.current.term && + caseSensitive !== lastSearchRef.current.caseSensitive; + lastSearchRef.current = { term, caseSensitive }; + // Call the onSearch callback if provided + onSearch?.(term, caseSensitive); + + // If empty, clear everything and return if (!term) { - setSearchResults(null); + setInternalSearchResults(null); if (highlighterRef.current) { highlighterRef.current.clearHighlights(); } @@ -82,62 +120,73 @@ export const SearchView: React.FC> = ({ const container = containerRef.current; if (!container) return; - if (!highlighterRef.current) { - highlighterRef.current = new SearchHighlighter(container, (count) => { - // Only update if this is still the latest search - if ( - lastSearchRef.current.term === term && - lastSearchRef.current.caseSensitive === caseSensitive - ) { - if (count > 0) { - setSearchResults((prev) => ({ - currentIndex: prev?.currentIndex || 1, - count, - })); - } else { - setSearchResults(null); - } - } - }); + // For case sensitivity changes, reuse existing highlighter + if (isCaseChange && highlighterRef.current) { + debouncedHighlight(term, caseSensitive, highlighterRef.current); + return; } - // Debounce the highlight operation + // Otherwise create new highlighter + if (highlighterRef.current) { + highlighterRef.current.clearHighlights(); + highlighterRef.current.destroy(); + } + + highlighterRef.current = new SearchHighlighter(container, (count) => { + // Only update if this is still the latest search + if ( + lastSearchRef.current.term === term && + lastSearchRef.current.caseSensitive === caseSensitive + ) { + if (count > 0) { + setInternalSearchResults({ + currentIndex: 1, + count, + }); + } else { + setInternalSearchResults(null); + } + } + }); + debouncedHighlight(term, caseSensitive, highlighterRef.current); }, - [debouncedHighlight] + [debouncedHighlight, onSearch] ); /** * Navigates between search results in the specified direction. * @param direction - Direction to navigate ('next' or 'prev') */ - const navigateResults = useCallback( + const handleNavigate = useCallback( (direction: 'next' | 'prev') => { - if (!searchResults || searchResults.count === 0 || !highlighterRef.current) return; - - let newIndex: number; - const currentIdx = searchResults.currentIndex - 1; // Convert to 0-based - - if (direction === 'next') { - newIndex = currentIdx + 1; - if (newIndex >= searchResults.count) { - newIndex = 0; - } - } else { - newIndex = currentIdx - 1; - if (newIndex < 0) { - newIndex = searchResults.count - 1; - } + // If external navigation is provided, use that + if (onNavigate) { + onNavigate(direction); + return; } - setSearchResults({ - ...searchResults, - currentIndex: newIndex + 1, + // Otherwise use internal navigation + if (!internalSearchResults || !highlighterRef.current) return; + + let newIndex: number; + if (direction === 'next') { + newIndex = (internalSearchResults.currentIndex % internalSearchResults.count) + 1; + } else { + newIndex = + internalSearchResults.currentIndex === 1 + ? internalSearchResults.count + : internalSearchResults.currentIndex - 1; + } + + setInternalSearchResults({ + ...internalSearchResults, + currentIndex: newIndex, }); - highlighterRef.current.setCurrentMatch(newIndex, true); // Explicitly scroll when navigating + highlighterRef.current.setCurrentMatch(newIndex - 1, true); }, - [searchResults] + [internalSearchResults, onNavigate] ); const handleFindCommand = useCallback(() => { @@ -151,15 +200,15 @@ export const SearchView: React.FC> = ({ const handleFindNext = useCallback(() => { if (isSearchVisible) { - navigateResults('next'); + handleNavigate('next'); } - }, [isSearchVisible, navigateResults]); + }, [isSearchVisible, handleNavigate]); const handleFindPrevious = useCallback(() => { if (isSearchVisible) { - navigateResults('prev'); + handleNavigate('prev'); } - }, [isSearchVisible, navigateResults]); + }, [isSearchVisible, handleNavigate]); const handleUseSelectionFind = useCallback(() => { const selection = window.getSelection()?.toString().trim(); @@ -173,13 +222,21 @@ export const SearchView: React.FC> = ({ */ const handleCloseSearch = useCallback(() => { setIsSearchVisible(false); - setSearchResults(null); + setInternalSearchResults(null); + lastSearchRef.current = { term: '', caseSensitive: false }; + if (highlighterRef.current) { highlighterRef.current.clearHighlights(); + highlighterRef.current.destroy(); + highlighterRef.current = null; } + // Cancel any pending highlight operations debouncedHighlight.cancel?.(); - }, [debouncedHighlight]); + + // Clear search when closing + onSearch?.('', false); + }, [debouncedHighlight, onSearch]); // Clean up highlighter and debounced functions on unmount useEffect(() => { @@ -228,10 +285,10 @@ export const SearchView: React.FC> = ({ e.preventDefault(); if (e.shiftKey) { // ⇧⌘G - Find Previous - navigateResults('prev'); + handleNavigate('prev'); } else { // ⌘G - Find Next - navigateResults('next'); + handleNavigate('next'); } } }; @@ -240,7 +297,7 @@ export const SearchView: React.FC> = ({ return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, [isSearchVisible, navigateResults, handleSearch, handleUseSelectionFind]); + }, [isSearchVisible, handleNavigate, handleSearch, handleUseSelectionFind]); // Listen for Find menu commands useEffect(() => { @@ -258,13 +315,22 @@ export const SearchView: React.FC> = ({ }, [handleFindCommand, handleFindNext, handleFindPrevious, handleUseSelectionFind]); return ( -
+
{ + if (el) { + containerRef.current = el; + // Expose the highlighter instance + containerRef.current._searchHighlighter = highlighterRef.current; + } + }} + className={`search-container ${className}`} + > {isSearchVisible && ( diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index 17322379..917dcd25 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { MessageSquareText, Target, @@ -16,129 +16,288 @@ import { ScrollArea } from '../ui/scroll-area'; import { View, ViewOptions } from '../../App'; import { formatMessageTimestamp } from '../../utils/timeUtils'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; +import { SearchView } from '../conversation/SearchView'; +import { SearchHighlighter } from '../../utils/searchHighlighter'; + +interface SearchContainerElement extends HTMLDivElement { + _searchHighlighter: SearchHighlighter | null; +} interface SessionListViewProps { setView: (view: View, viewOptions?: ViewOptions) => void; onSelectSession: (sessionId: string) => void; } +const ITEM_HEIGHT = 90; // Adjust based on your card height +const BUFFER_SIZE = 5; // Number of items to render above/below viewport + const SessionListView: React.FC = ({ setView, onSelectSession }) => { const [sessions, setSessions] = useState([]); + const [filteredSessions, setFilteredSessions] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [searchResults, setSearchResults] = useState<{ + count: number; + currentIndex: number; + } | null>(null); + const containerRef = useRef(null); + const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 }); useEffect(() => { - // Load sessions on component mount loadSessions(); }, []); + // Handle scroll events to update visible range + useEffect(() => { + const viewportEl = containerRef.current?.closest('[data-radix-scroll-area-viewport]'); + if (!viewportEl) return; + + const handleScroll = () => { + const scrollTop = viewportEl.scrollTop; + const viewportHeight = viewportEl.clientHeight; + + const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER_SIZE); + const end = Math.min( + filteredSessions.length, + Math.ceil((scrollTop + viewportHeight) / ITEM_HEIGHT) + BUFFER_SIZE + ); + + setVisibleRange({ start, end }); + }; + + handleScroll(); // Initial calculation + viewportEl.addEventListener('scroll', handleScroll); + + const resizeObserver = new ResizeObserver(handleScroll); + resizeObserver.observe(viewportEl); + + return () => { + viewportEl.removeEventListener('scroll', handleScroll); + resizeObserver.disconnect(); + }; + }, [filteredSessions.length]); + + // Filter sessions when search term or case sensitivity changes + const handleSearch = (term: string, caseSensitive: boolean) => { + if (!term) { + setFilteredSessions(sessions); + setSearchResults(null); + return; + } + + const searchTerm = caseSensitive ? term : term.toLowerCase(); + const filtered = sessions.filter((session) => { + const description = session.metadata.description || session.id; + const path = session.path; + const workingDir = session.metadata.working_dir; + + if (caseSensitive) { + return ( + description.includes(searchTerm) || + path.includes(searchTerm) || + workingDir.includes(searchTerm) + ); + } else { + return ( + description.toLowerCase().includes(searchTerm) || + path.toLowerCase().includes(searchTerm) || + workingDir.toLowerCase().includes(searchTerm) + ); + } + }); + + setFilteredSessions(filtered); + setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null); + + // Reset scroll position when search changes + const viewportEl = containerRef.current?.closest('[data-radix-scroll-area-viewport]'); + if (viewportEl) { + viewportEl.scrollTop = 0; + } + setVisibleRange({ start: 0, end: 20 }); + }; + const loadSessions = async () => { setIsLoading(true); setError(null); try { const sessions = await fetchSessions(); setSessions(sessions); + setFilteredSessions(sessions); } catch (err) { console.error('Failed to load sessions:', err); setError('Failed to load sessions. Please try again later.'); setSessions([]); + setFilteredSessions([]); } finally { setIsLoading(false); } }; - return ( -
- + // Handle search result navigation + const handleSearchNavigation = (direction: 'next' | 'prev') => { + if (!searchResults || filteredSessions.length === 0) return; - -
-
- setView('chat')} /> -
+ let newIndex: number; + if (direction === 'next') { + newIndex = (searchResults.currentIndex % filteredSessions.length) + 1; + } else { + newIndex = + searchResults.currentIndex === 1 ? filteredSessions.length : searchResults.currentIndex - 1; + } - {/* Content Area */} -
-

Previous goose sessions

-

- View previous goose sessions and their contents to pick up where you left off. + setSearchResults({ ...searchResults, currentIndex: newIndex }); + + // Find the SearchView's container element + const searchContainer = + containerRef.current?.querySelector('.search-container'); + if (searchContainer?._searchHighlighter) { + // Update the current match in the highlighter + searchContainer._searchHighlighter.setCurrentMatch(newIndex - 1, true); + } + }; + + // Render a session item + const SessionItem = React.memo(function SessionItem({ session }: { session: Session }) { + return ( + onSelectSession(session.id)} + className="p-2 mx-4 mb-2 bg-bgSecondary hover:bg-bgSubtle cursor-pointer transition-all duration-150" + > +
+
+

+ {session.metadata.description || session.id}

+
+
+ + {formatMessageTimestamp(Date.parse(session.modified) / 1000)} +
+
+ + {session.metadata.working_dir} +
+
-
- {isLoading ? ( -
- -
- ) : error ? ( -
- -

Error Loading Sessions

-

{error}

- -
- ) : sessions.length > 0 ? ( -
- {sessions.map((session) => ( - onSelectSession(session.id)} - className="p-2 bg-bgSecondary hover:bg-bgSubtle cursor-pointer transition-all duration-150" - > -
-
-

- {session.metadata.description || session.id} -

-
-
- - - {formatMessageTimestamp(Date.parse(session.modified) / 1000)} - -
-
- - {session.metadata.working_dir} -
-
-
-
-
-
- {session.path.split('/').pop() || session.path} -
-
-
- - {session.metadata.message_count} -
- {session.metadata.total_tokens !== null && ( -
- - {session.metadata.total_tokens.toLocaleString()} -
- )} -
-
- -
-
-
- ))} +
+
+
+ {session.path.split('/').pop() || session.path}
- ) : ( -
- -

No chat sessions found

-

Your chat history will appear here

+
+
+ + {session.metadata.message_count} +
+ {session.metadata.total_tokens !== null && ( +
+ + {session.metadata.total_tokens.toLocaleString()} +
+ )}
- )} +
+
- + + ); + }); + + const renderContent = () => { + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +

Error Loading Sessions

+

{error}

+ +
+ ); + } + + if (filteredSessions.length === 0) { + if (searchResults === null && sessions.length > 0) { + return ( +
+ +

No matching sessions found

+

Try adjusting your search terms

+
+ ); + } + return ( +
+ +

No chat sessions found

+

Your chat history will appear here

+
+ ); + } + + const visibleSessions = filteredSessions.slice(visibleRange.start, visibleRange.end); + + return ( +
+
+ {visibleSessions.map((session) => ( + + ))} +
+
+ ); + }; + + return ( +
+ + +
+
+ setView('chat')} /> +
+ + {/* Content Area */} +
+

Previous goose sessions

+

+ View previous goose sessions and their contents to pick up where you left off. +

+
+ +
+ +
+ + {renderContent()} + +
+
+
+
); }; diff --git a/ui/desktop/src/components/sessions/SharedSessionView.tsx b/ui/desktop/src/components/sessions/SharedSessionView.tsx index a9155c20..7fa04bff 100644 --- a/ui/desktop/src/components/sessions/SharedSessionView.tsx +++ b/ui/desktop/src/components/sessions/SharedSessionView.tsx @@ -21,7 +21,7 @@ const SharedSessionView: React.FC = ({ }) => { return (
-
+
{/* Top Row - back, info (fixed) */} diff --git a/ui/desktop/src/utils/searchHighlighter.ts b/ui/desktop/src/utils/searchHighlighter.ts index 0327007c..f479192c 100644 --- a/ui/desktop/src/utils/searchHighlighter.ts +++ b/ui/desktop/src/utils/searchHighlighter.ts @@ -11,18 +11,10 @@ export class SearchHighlighter { private scrollContainer: HTMLElement | null = null; private currentTerm: string = ''; private caseSensitive: boolean = false; - private scrollHandler: (() => void) | null = null; private onMatchesChange?: (count: number) => void; private currentMatchIndex: number = -1; - private isUpdatingPositions: boolean = false; - private updatePending: boolean = false; - private shouldScrollToMatch: boolean = false; + private isScrollingProgrammatically: boolean = false; - /** - * Creates a new SearchHighlighter instance. - * @param container - The root HTML element to search within - * @param onMatchesChange - Optional callback that receives the count of matches when changed - */ constructor(container: HTMLElement, onMatchesChange?: (count: number) => void) { this.container = container; this.onMatchesChange = onMatchesChange; @@ -40,55 +32,37 @@ export class SearchHighlighter { z-index: 1; `; - // Find scroll container (usually the radix scroll area viewport) - this.scrollContainer = container.closest('[data-radix-scroll-area-viewport]'); + // Find scroll container (look for our custom data attribute first, then fallback to radix) + this.scrollContainer = + container + .closest('[data-search-scroll-area]') + ?.querySelector('[data-radix-scroll-area-viewport]') || + container.closest('[data-radix-scroll-area-viewport]'); + if (this.scrollContainer) { this.scrollContainer.style.position = 'relative'; this.scrollContainer.appendChild(this.overlay); - // Add scroll handler with debouncing to prevent performance issues - this.scrollHandler = () => { - if (this.isUpdatingPositions) { - this.updatePending = true; - return; - } - - this.isUpdatingPositions = true; - requestAnimationFrame(() => { - this.updateHighlightPositions(); - this.isUpdatingPositions = false; - - if (this.updatePending) { - this.updatePending = false; - this.scrollHandler?.(); + // Add scroll end detection + this.scrollContainer.addEventListener( + 'scroll', + () => { + if (!this.isScrollingProgrammatically) { + // User is manually scrolling, update highlight positions + this.updateHighlightPositions(); } - }); - }; - this.scrollContainer.addEventListener('scroll', this.scrollHandler); + }, + { passive: true } + ); } else { container.style.position = 'relative'; container.appendChild(this.overlay); } - // Handle content changes with debouncing + // Handle content changes this.resizeObserver = new ResizeObserver(() => { if (this.highlights.length > 0) { - if (this.isUpdatingPositions) { - this.updatePending = true; - return; - } - - this.isUpdatingPositions = true; - requestAnimationFrame(() => { - this.updateHighlightPositions(); - this.isUpdatingPositions = false; - - if (this.updatePending) { - this.updatePending = false; - // Re-run the update - requestAnimationFrame(() => this.updateHighlightPositions()); - } - }); + this.updateHighlightPositions(); } }); this.resizeObserver.observe(container); @@ -103,36 +77,16 @@ export class SearchHighlighter { } } if (shouldUpdate && this.currentTerm) { - if (this.isUpdatingPositions) { - this.updatePending = true; - return; - } - - this.isUpdatingPositions = true; - requestAnimationFrame(() => { - this.highlight(this.currentTerm, this.caseSensitive); - this.isUpdatingPositions = false; - - if (this.updatePending) { - this.updatePending = false; - // Re-run the update - requestAnimationFrame(() => this.highlight(this.currentTerm, this.caseSensitive)); - } - }); + this.highlight(this.currentTerm, this.caseSensitive); } }); this.mutationObserver.observe(container, { childList: true, subtree: true }); } - /** - * Highlights all occurrences of a search term within the container. - * @param term - The text to search for - * @param caseSensitive - Whether to perform a case-sensitive search - * @returns Array of highlight elements created - */ highlight(term: string, caseSensitive = false) { - // Store the current match index before clearing + // Store the current match index and count before clearing const currentIndex = this.currentMatchIndex; + const oldHighlightCount = this.highlights.length; this.clearHighlights(); this.currentTerm = term; @@ -191,16 +145,20 @@ export class SearchHighlighter { const highlightRect = document.createElement('div'); highlightRect.className = 'search-highlight'; - const scrollTop = this.scrollContainer ? this.scrollContainer.scrollTop : window.scrollY; - const scrollLeft = this.scrollContainer ? this.scrollContainer.scrollLeft : window.scrollX; - const containerTop = this.scrollContainer?.getBoundingClientRect().top || 0; - const containerLeft = this.scrollContainer?.getBoundingClientRect().left || 0; + // Get the scroll container's position + const containerRect = this.scrollContainer?.getBoundingClientRect() || { top: 0, left: 0 }; + const scrollTop = this.scrollContainer?.scrollTop || 0; + const scrollLeft = this.scrollContainer?.scrollLeft || 0; + + // Calculate the highlight position relative to the scroll container + const top = rect.top + scrollTop - containerRect.top; + const left = rect.left + scrollLeft - containerRect.left; highlightRect.style.cssText = ` position: absolute; pointer-events: none; - top: ${rect.top + scrollTop - containerTop}px; - left: ${rect.left + scrollLeft - containerLeft}px; + top: ${top}px; + left: ${left}px; width: ${rect.width}px; height: ${rect.height}px; `; @@ -211,135 +169,97 @@ export class SearchHighlighter { return highlight; }); - // Notify about updated match count - this.onMatchesChange?.(this.highlights.length); + // Only notify about count changes if the number of matches has actually changed + if (this.highlights.length !== oldHighlightCount) { + this.onMatchesChange?.(this.highlights.length); + } - // Restore current match if it was set - if (currentIndex >= 0 && this.highlights.length > 0) { - // Use the stored index, not this.currentMatchIndex which was reset in clearHighlights - this.setCurrentMatch(currentIndex, false); // Don't scroll when restoring highlight + // Restore current match if we have the same number of highlights + if (currentIndex >= 0 && this.highlights.length === oldHighlightCount) { + this.setCurrentMatch(currentIndex, false); + } + // Otherwise, if we have highlights but the count changed, start from the beginning + else if (this.highlights.length > 0) { + this.setCurrentMatch(0, false); } return this.highlights; } - /** - * Sets the current match and optionally scrolls to it. - * @param index - Zero-based index of the match to set as current - * @param shouldScroll - Whether to scroll to the match (true for explicit navigation) - */ setCurrentMatch(index: number, shouldScroll = true) { - // Store the current match index - this.currentMatchIndex = index; + if (!this.highlights.length) return; - // Save the scroll flag - this.shouldScrollToMatch = shouldScroll; + // Ensure index wraps around + const wrappedIndex = + ((index % this.highlights.length) + this.highlights.length) % this.highlights.length; + + // Store the current match index + this.currentMatchIndex = wrappedIndex; // Remove current class from all highlights this.overlay.querySelectorAll('.search-highlight').forEach((el) => { el.classList.remove('current'); }); - // Add current class to the matched highlight - if (this.highlights.length > 0) { - // Ensure index wraps around - const wrappedIndex = - ((index % this.highlights.length) + this.highlights.length) % this.highlights.length; + // Add current class to all parts of the highlight + const currentHighlight = this.highlights[wrappedIndex]; + const highlightElements = currentHighlight.querySelectorAll('.search-highlight'); + highlightElements.forEach((el) => { + el.classList.add('current'); + }); - // Find all highlight elements within the current highlight container - const highlightElements = this.highlights[wrappedIndex].querySelectorAll('.search-highlight'); + // Only scroll if explicitly requested + if (shouldScroll && this.scrollContainer) { + const firstHighlight = highlightElements[0] as HTMLElement; + if (firstHighlight) { + // Calculate the target scroll position + const containerRect = this.scrollContainer.getBoundingClientRect(); + const highlightRect = firstHighlight.getBoundingClientRect(); - // Add 'current' class to all parts of the highlight (for multi-line matches) - highlightElements.forEach((el) => { - el.classList.add('current'); - }); + // Calculate the target position that would center the highlight + const targetScrollTop = + this.scrollContainer.scrollTop + + (highlightRect.top - containerRect.top) - + (containerRect.height - highlightRect.height) / 2; - // Only scroll if explicitly requested (e.g., when navigating) - if (shouldScroll) { - // Ensure we call scrollToMatch with the correct index - setTimeout(() => this.scrollToMatch(wrappedIndex), 0); + // Set flag before scrolling + this.isScrollingProgrammatically = true; + + // Perform the scroll + this.scrollContainer.scrollTop = targetScrollTop; + + // Clear flag after a short delay + setTimeout(() => { + this.isScrollingProgrammatically = false; + }, 100); } } } - /** - * Scrolls to center the specified match in the viewport. - * @param index - Zero-based index of the match to scroll to - */ - private scrollToMatch(index: number) { - if (!this.scrollContainer || !this.highlights[index]) return; - - const currentHighlight = this.highlights[index].querySelector( - '.search-highlight' - ) as HTMLElement; - if (!currentHighlight) return; - - const rect = currentHighlight.getBoundingClientRect(); - const containerRect = this.scrollContainer.getBoundingClientRect(); - - // Calculate how far the element is from the top of the viewport - const elementRelativeToViewport = rect.top - containerRect.top; - - // Calculate the new scroll position that would center the element - const currentScrollTop = this.scrollContainer.scrollTop; - const targetPosition = - currentScrollTop + elementRelativeToViewport - (containerRect.height - rect.height) / 2; - - // Ensure we don't scroll past the bottom - const maxScroll = this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight; - const finalPosition = Math.max(0, Math.min(targetPosition, maxScroll)); - - this.scrollContainer.scrollTo({ - top: finalPosition, - behavior: 'smooth', - }); - } - - /** - * Updates the positions of all highlights after content changes. - * This preserves the current match selection but doesn't scroll. - */ private updateHighlightPositions() { if (this.currentTerm) { - // Store the current index for restoration const currentIndex = this.currentMatchIndex; - - // Clear and recreate all highlights - this.overlay.innerHTML = ''; - this.highlights = []; - - // Re-highlight with the current term + const oldHighlights = this.highlights.length; // Store the current count this.highlight(this.currentTerm, this.caseSensitive); - // Ensure the current match is still highlighted, but don't scroll - if (currentIndex >= 0 && this.highlights.length > 0) { + // If we still have the same number of highlights, restore the current index + if (this.highlights.length === oldHighlights && currentIndex >= 0) { this.setCurrentMatch(currentIndex, false); } } } - /** - * Removes all search highlights from the container. - */ clearHighlights() { this.highlights.forEach((h) => h.remove()); this.highlights = []; this.currentTerm = ''; this.currentMatchIndex = -1; - this.shouldScrollToMatch = false; - this.overlay.innerHTML = ''; // Ensure all highlights are removed + this.overlay.innerHTML = ''; } - /** - * Cleans up all resources used by the highlighter. - * Should be called when the component using this highlighter unmounts. - */ destroy() { this.resizeObserver.disconnect(); this.mutationObserver.disconnect(); - if (this.scrollHandler && this.scrollContainer) { - this.scrollContainer.removeEventListener('scroll', this.scrollHandler); - } this.overlay.remove(); } }