(bug-fix: 1984) Update search highlighter to use overlay (#2035)

This commit is contained in:
Joshua Swigut
2025-04-04 16:43:03 -04:00
committed by GitHub
parent 544751908d
commit aed70f624f
6 changed files with 380 additions and 153 deletions
+4
View File
@@ -86,6 +86,10 @@ module.exports = [
React: 'readonly',
handleAction: 'readonly',
requestAnimationFrame: 'readonly',
ResizeObserver: 'readonly',
MutationObserver: 'readonly',
NodeFilter: 'readonly',
Text: 'readonly',
},
},
plugins: {
+1 -1
View File
@@ -388,7 +388,7 @@ export default function ChatView({
/>
) : (
<ScrollArea ref={scrollRef} className="flex-1" autoScroll>
<SearchView scrollAreaRef={scrollRef}>
<SearchView>
{filteredMessages.map((message, index) => (
<div key={message.id || index} className="mt-4 px-4">
{isUserMessage(message) ? (
@@ -51,18 +51,17 @@ export const SearchBar: React.FC<SearchBarProps> = ({
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
onNavigate?.('prev');
handleNavigate('prev', event);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
onNavigate?.('next');
handleNavigate('next', event);
} else if (event.key === 'Escape') {
event.preventDefault();
handleClose();
}
};
const handleNavigate = (direction: 'next' | 'prev') => {
const handleNavigate = (direction: 'next' | 'prev', e?: React.MouseEvent | KeyboardEvent) => {
e?.preventDefault();
onNavigate?.(direction);
inputRef.current?.focus();
};
@@ -123,7 +122,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({
</button>
<button
onClick={() => handleNavigate('prev')}
onClick={(e) => handleNavigate('prev', e)}
className={`p-1 text-textSubtleInverse ${!searchResults || searchResults.count === 0 ? '' : 'hover:text-textStandardInverse'}`}
title="Previous (↑)"
disabled={!searchResults || searchResults.count === 0}
@@ -131,7 +130,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({
<ArrowUp className="h-5 w-5" />
</button>
<button
onClick={() => handleNavigate('next')}
onClick={(e) => handleNavigate('next', e)}
className={`p-1 text-textSubtleInverse ${!searchResults || searchResults.count === 0 ? '' : 'hover:text-textStandardInverse'}`}
title="Next (↓)"
disabled={!searchResults || searchResults.count === 0}
@@ -1,7 +1,6 @@
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';
/**
@@ -10,8 +9,6 @@ import '../../styles/search.css';
interface SearchViewProps {
/** Optional CSS class name */
className?: string;
/** Reference to the scroll area for navigation */
scrollAreaRef?: React.RefObject<ScrollAreaHandle>;
}
/**
@@ -21,7 +18,6 @@ interface SearchViewProps {
export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
className = '',
children,
scrollAreaRef,
}) => {
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [searchResults, setSearchResults] = useState<{
@@ -32,26 +28,41 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
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);
// };
// }, []);
// Clean up highlighter on unmount
useEffect(() => {
return () => {
if (highlighterRef.current) {
highlighterRef.current.destroy();
highlighterRef.current = null;
}
};
}, []);
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);
};
}, []);
/**
* Handles the search operation when a user enters a search term.
* @param term - The text to search for
* @param caseSensitive - Whether to perform a case-sensitive search
*/
const handleSearch = (term: string, caseSensitive: boolean) => {
if (!term) {
setSearchResults(null);
clearHighlights();
if (highlighterRef.current) {
highlighterRef.current.clearHighlights();
}
return;
}
@@ -59,36 +70,52 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
if (!container) return;
if (!highlighterRef.current) {
highlighterRef.current = new SearchHighlighter(container);
highlighterRef.current = new SearchHighlighter(container, (count) => {
if (count > 0) {
setSearchResults((prev) => ({
currentIndex: prev?.currentIndex || 1,
count,
}));
} else {
setSearchResults(null);
}
});
}
highlighterRef.current.clearHighlights();
highlighterRef.current.highlight(term, caseSensitive);
const marks = container.querySelectorAll('mark');
const count = marks.length;
const highlights = highlighterRef.current.highlight(term, caseSensitive);
const count = highlights.length;
if (count > 0) {
setSearchResults({
currentIndex: 1,
count: count,
count,
});
scrollToMatch(0);
highlighterRef.current.setCurrentMatch(0, true); // Explicitly scroll when setting initial match
} else {
setSearchResults(null);
}
};
/**
* Navigates between search results in the specified direction.
* @param direction - Direction to navigate ('next' or 'prev')
*/
const navigateResults = (direction: 'next' | 'prev') => {
if (!searchResults || searchResults.count === 0) return;
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) % searchResults.count;
newIndex = currentIdx + 1;
if (newIndex >= searchResults.count) {
newIndex = 0;
}
} else {
newIndex = (currentIdx - 1 + searchResults.count) % searchResults.count;
newIndex = currentIdx - 1;
if (newIndex < 0) {
newIndex = searchResults.count - 1;
}
}
setSearchResults({
@@ -96,59 +123,18 @@ export const SearchView: React.FC<PropsWithChildren<SearchViewProps>> = ({
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();
}
highlighterRef.current.setCurrentMatch(newIndex, true); // Explicitly scroll when navigating
};
/**
* Closes the search interface and clears all highlights.
*/
const handleCloseSearch = () => {
setIsSearchVisible(false);
setSearchResults(null);
clearHighlights();
if (highlighterRef.current) {
highlighterRef.current.clearHighlights();
}
};
return (
+3 -2
View File
@@ -1,14 +1,15 @@
/* Search highlight styles */
mark {
.search-highlight {
background-color: rgba(255, 213, 0, 0.5);
color: var(--text-standard);
padding: 0;
border-radius: 2px;
}
mark.current {
.search-highlight.current {
background-color: rgba(252, 213, 3, 0.6);
box-shadow: inset 0 0 0 1px var(--text-prominent);
z-index: 2;
}
@keyframes expandDown {
+301 -64
View File
@@ -1,108 +1,345 @@
/* eslint-disable no-cond-assign */
/**
* Utility class for highlighting search matches in text content.
* Supports case-sensitive search and maintains a "current" highlight state.
* SearchHighlighter provides overlay-based text search highlighting
* with support for navigation and scrolling control.
*/
export class SearchHighlighter {
private readonly container: HTMLElement;
private readonly overlay: HTMLElement;
private highlights: HTMLElement[] = [];
private resizeObserver: ResizeObserver;
private mutationObserver: MutationObserver;
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;
/**
* 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) {
constructor(container: HTMLElement, onMatchesChange?: (count: number) => void) {
this.container = container;
this.onMatchesChange = onMatchesChange;
// Create overlay
this.overlay = document.createElement('div');
this.overlay.className = 'search-highlight-overlay';
this.overlay.style.cssText = `
position: absolute;
pointer-events: none;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
`;
// Find scroll container (usually the radix scroll area viewport)
this.scrollContainer = 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?.();
}
});
};
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
} else {
container.style.position = 'relative';
container.appendChild(this.overlay);
}
// Handle content changes with debouncing
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.resizeObserver.observe(container);
// Watch for DOM changes (new messages)
this.mutationObserver = new MutationObserver((mutations) => {
let shouldUpdate = false;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
shouldUpdate = true;
break;
}
}
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.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): void {
highlight(term: string, caseSensitive = false) {
// Store the current match index before clearing
const currentIndex = this.currentMatchIndex;
this.clearHighlights();
this.currentTerm = term;
this.caseSensitive = caseSensitive;
if (!term.trim()) return;
if (!term.trim()) return [];
const walker = document.createTreeWalker(this.container, window.NodeFilter.SHOW_TEXT, {
acceptNode: (node: Node): number => {
// Check if this node or any of its ancestors have the excluded classes
let element = node.parentElement;
while (element) {
if (
element.classList.contains('search-input') ||
element.classList.contains('search-results') ||
element.classList.contains('case-sensitive-btn')
) {
return window.NodeFilter.FILTER_REJECT;
}
element = element.parentElement;
const range = document.createRange();
const regex = new RegExp(
term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
caseSensitive ? 'g' : 'gi'
);
// Find all text nodes in the container
const walker = document.createTreeWalker(this.container, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
// Skip search UI elements
const parent = node.parentElement;
if (parent?.closest('.search-bar, .search-results')) {
return NodeFilter.FILTER_REJECT;
}
return window.NodeFilter.FILTER_ACCEPT;
return NodeFilter.FILTER_ACCEPT;
},
});
const matches: Node[] = [];
let node: Node | null;
const matches: { node: Text; startOffset: number; endOffset: number }[] = [];
let node: Text | null;
// Find all text nodes containing the search term
while ((node = walker.nextNode())) {
const nodeText = node.textContent || '';
const searchText = caseSensitive ? nodeText : nodeText.toLowerCase();
const searchTerm = caseSensitive ? term : term.toLowerCase();
// Find all matches
while ((node = walker.nextNode() as Text)) {
const text = node.textContent || '';
let match;
if (searchText.includes(searchTerm)) {
matches.push(node);
// Reset lastIndex to ensure we find all matches
regex.lastIndex = 0;
while ((match = regex.exec(text)) !== null) {
matches.push({
node,
startOffset: match.index,
endOffset: match.index + match[0].length,
});
}
}
// Highlight matches
matches.forEach((textNode) => {
const text = textNode.textContent || '';
const searchTerm = caseSensitive ? term : term.toLowerCase();
const regex = new RegExp(
searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
caseSensitive ? 'g' : 'gi'
);
const fragment = document.createDocumentFragment();
let lastIndex = 0;
let match;
// Create highlight elements
this.highlights = matches.map(({ node, startOffset, endOffset }) => {
range.setStart(node, startOffset);
range.setEnd(node, endOffset);
while ((match = regex.exec(text)) !== null) {
// Add text before the match
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
const rects = range.getClientRects();
const highlight = document.createElement('div');
highlight.className = 'search-highlight-container';
// Add highlighted match
const mark = document.createElement('mark');
mark.textContent = match[0]; // Use the actual matched text to preserve case
fragment.appendChild(mark);
// Handle multi-line highlights
Array.from(rects).forEach((rect) => {
const highlightRect = document.createElement('div');
highlightRect.className = 'search-highlight';
lastIndex = regex.lastIndex;
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;
highlightRect.style.cssText = `
position: absolute;
pointer-events: none;
top: ${rect.top + scrollTop - containerTop}px;
left: ${rect.left + scrollLeft - containerLeft}px;
width: ${rect.width}px;
height: ${rect.height}px;
`;
highlight.appendChild(highlightRect);
});
this.overlay.appendChild(highlight);
return highlight;
});
// Notify about updated match count
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
}
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;
// Save the scroll flag
this.shouldScrollToMatch = shouldScroll;
// 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;
// Find all highlight elements within the current highlight container
const highlightElements = this.highlights[wrappedIndex].querySelectorAll('.search-highlight');
// Add 'current' class to all parts of the highlight (for multi-line matches)
highlightElements.forEach((el) => {
el.classList.add('current');
});
// Only scroll if explicitly requested (e.g., when navigating)
if (shouldScroll) {
// Ensure we call scrollToMatch with the correct index
setTimeout(() => this.scrollToMatch(wrappedIndex), 0);
}
}
}
// Add remaining text
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
}
/**
* 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;
textNode.parentNode?.replaceChild(fragment, textNode);
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',
});
}
/**
* Removes all search highlights from the container
* Updates the positions of all highlights after content changes.
* This preserves the current match selection but doesn't scroll.
*/
clearHighlights(): void {
const marks = this.container.getElementsByTagName('mark');
while (marks.length > 0) {
const mark = marks[0];
const parent = mark.parentNode;
if (parent) {
parent.replaceChild(document.createTextNode(mark.textContent || ''), mark);
parent.normalize();
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
this.highlight(this.currentTerm, this.caseSensitive);
// Ensure the current match is still highlighted, but don't scroll
if (currentIndex >= 0 && this.highlights.length > 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
}
/**
* 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();
}
}