346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
/**
|
|
* 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, 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) {
|
|
// Store the current match index before clearing
|
|
const currentIndex = this.currentMatchIndex;
|
|
|
|
this.clearHighlights();
|
|
this.currentTerm = term;
|
|
this.caseSensitive = caseSensitive;
|
|
|
|
if (!term.trim()) return [];
|
|
|
|
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 NodeFilter.FILTER_ACCEPT;
|
|
},
|
|
});
|
|
|
|
const matches: { node: Text; startOffset: number; endOffset: number }[] = [];
|
|
let node: Text | null;
|
|
|
|
// Find all matches
|
|
while ((node = walker.nextNode() as Text)) {
|
|
const text = node.textContent || '';
|
|
let match;
|
|
|
|
// 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,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Create highlight elements
|
|
this.highlights = matches.map(({ node, startOffset, endOffset }) => {
|
|
range.setStart(node, startOffset);
|
|
range.setEnd(node, endOffset);
|
|
|
|
const rects = range.getClientRects();
|
|
const highlight = document.createElement('div');
|
|
highlight.className = 'search-highlight-container';
|
|
|
|
// Handle multi-line highlights
|
|
Array.from(rects).forEach((rect) => {
|
|
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;
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
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();
|
|
}
|
|
}
|