(bug-fix: 1984) Update search highlighter to use overlay (#2035)
This commit is contained in:
@@ -86,6 +86,10 @@ module.exports = [
|
||||
React: 'readonly',
|
||||
handleAction: 'readonly',
|
||||
requestAnimationFrame: 'readonly',
|
||||
ResizeObserver: 'readonly',
|
||||
MutationObserver: 'readonly',
|
||||
NodeFilter: 'readonly',
|
||||
Text: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user