Files
tkmind_go/ui/desktop/src/components/conversation/SearchView.tsx
T

168 lines
4.8 KiB
TypeScript

import React, { useState, useEffect, PropsWithChildren } from 'react';
import { SearchBar } from './SearchBar';
import { SearchHighlighter } from '../../utils/searchHighlighter';
import { ScrollAreaHandle } from '../ui/scroll-area';
import '../../styles/search.css';
/**
* Props for the SearchView component
*/
interface SearchViewProps {
/** Optional CSS class name */
className?: string;
/** Reference to the scroll area for navigation */
scrollAreaRef?: React.RefObject<ScrollAreaHandle>;
}
/**
* SearchView wraps content in a searchable container with a search bar that appears
* when Cmd/Ctrl+F is pressed. Supports case-sensitive search and result navigation.
*/
export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
className = '',
children,
scrollAreaRef,
}) => {
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [searchResults, setSearchResults] = useState<{
currentIndex: number;
count: number;
} | null>(null);
const highlighterRef = React.useRef<SearchHighlighter | null>(null);
const containerRef = React.useRef<HTMLDivElement | null>(null);
// temporarily disabling search for launch until issue https://github.com/block/goose/issues/1984 is resolved
//
// useEffect(() => {
// const handleKeyDown = (e: KeyboardEvent) => {
// if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
// e.preventDefault();
// setIsSearchVisible(true);
// }
// };
//
// window.addEventListener('keydown', handleKeyDown);
// return () => {
// window.removeEventListener('keydown', handleKeyDown);
// };
// }, []);
const handleSearch = (term: string, caseSensitive: boolean) => {
if (!term) {
setSearchResults(null);
clearHighlights();
return;
}
const container = containerRef.current;
if (!container) return;
if (!highlighterRef.current) {
highlighterRef.current = new SearchHighlighter(container);
}
highlighterRef.current.clearHighlights();
highlighterRef.current.highlight(term, caseSensitive);
const marks = container.querySelectorAll('mark');
const count = marks.length;
if (count > 0) {
setSearchResults({
currentIndex: 1,
count: count,
});
scrollToMatch(0);
} else {
setSearchResults(null);
}
};
const navigateResults = (direction: 'next' | 'prev') => {
if (!searchResults || searchResults.count === 0) return;
let newIndex: number;
const currentIdx = searchResults.currentIndex - 1; // Convert to 0-based
if (direction === 'next') {
newIndex = (currentIdx + 1) % searchResults.count;
} else {
newIndex = (currentIdx - 1 + searchResults.count) % searchResults.count;
}
setSearchResults({
...searchResults,
currentIndex: newIndex + 1,
});
scrollToMatch(newIndex);
};
const scrollToMatch = (index: number) => {
if (!containerRef.current || !scrollAreaRef?.current) return;
const marks = containerRef.current.querySelectorAll('mark');
const mark = marks[index] as HTMLElement;
if (!mark) return;
// Update highlight
marks.forEach((m) => m.classList.remove('current'));
mark.classList.add('current');
// Find the viewport element
const viewport = mark.closest('[data-radix-scroll-area-viewport]') as HTMLElement;
if (!viewport) return;
// Get measurements
const viewportRect = viewport.getBoundingClientRect();
const markRect = mark.getBoundingClientRect();
const currentScrollTop = viewport.scrollTop;
// Calculate how far the element is from the top of the viewport
const elementRelativeToViewport = markRect.top - viewportRect.top;
// Calculate the new scroll position that would center the element
const targetPosition =
currentScrollTop + elementRelativeToViewport - (viewportRect.height - markRect.height) / 2;
// Ensure we don't scroll past the bottom
const maxScroll = viewport.scrollHeight - viewport.clientHeight;
const finalPosition = Math.max(0, Math.min(targetPosition, maxScroll));
// Use requestAnimationFrame to ensure DOM measurements are accurate
requestAnimationFrame(() => {
scrollAreaRef.current?.scrollToPosition({
top: finalPosition,
behavior: 'smooth',
});
});
};
const clearHighlights = () => {
if (highlighterRef.current) {
highlighterRef.current.clearHighlights();
}
};
const handleCloseSearch = () => {
setIsSearchVisible(false);
setSearchResults(null);
clearHighlights();
};
return (
<div ref={containerRef} className={`search-container ${className}`}>
{isSearchVisible && (
<SearchBar
onSearch={handleSearch}
onClose={handleCloseSearch}
onNavigate={navigateResults}
searchResults={searchResults}
/>
)}
{children}
</div>
);
};