f636a97faa
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
291 lines
9.8 KiB
TypeScript
291 lines
9.8 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 onMatchesChange?: (count: number) => void;
|
|
private currentMatchIndex: number = -1;
|
|
private isScrollingProgrammatically: boolean = false;
|
|
private highlightTimeout?: ReturnType<typeof setTimeout>;
|
|
|
|
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 (look for our custom data attribute first, then fall back to radix)
|
|
const searchScrollArea = container.closest('[data-search-scroll-area]');
|
|
this.scrollContainer =
|
|
searchScrollArea?.querySelector('[data-radix-scroll-area-viewport]') ||
|
|
(searchScrollArea as HTMLElement) ||
|
|
container.closest('[data-radix-scroll-area-viewport]');
|
|
|
|
if (this.scrollContainer) {
|
|
this.scrollContainer.style.position = 'relative';
|
|
this.scrollContainer.appendChild(this.overlay);
|
|
|
|
// Add scroll end detection
|
|
this.scrollContainer.addEventListener(
|
|
'scroll',
|
|
() => {
|
|
if (!this.isScrollingProgrammatically) {
|
|
// User is manually scrolling, update highlight positions
|
|
this.updateHighlightPositions();
|
|
}
|
|
},
|
|
{ passive: true }
|
|
);
|
|
} else {
|
|
container.style.position = 'relative';
|
|
container.appendChild(this.overlay);
|
|
}
|
|
|
|
// Handle content changes
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
if (this.highlights.length > 0) {
|
|
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) {
|
|
// Ignore mutations from our own overlay
|
|
if (mutation.target === this.overlay || this.overlay.contains(mutation.target as Node)) {
|
|
continue;
|
|
}
|
|
// Ignore mutations that only add/remove our highlight elements
|
|
const isOnlyHighlights = Array.from(mutation.addedNodes).every(
|
|
(node) =>
|
|
node instanceof HTMLElement &&
|
|
(node.classList.contains('search-highlight') ||
|
|
node.classList.contains('search-highlight-container'))
|
|
);
|
|
if (isOnlyHighlights) {
|
|
continue;
|
|
}
|
|
shouldUpdate = true;
|
|
break;
|
|
}
|
|
}
|
|
if (shouldUpdate && this.currentTerm) {
|
|
// Debounce the highlight update to avoid rapid re-highlighting
|
|
if (this.highlightTimeout) {
|
|
clearTimeout(this.highlightTimeout);
|
|
}
|
|
this.highlightTimeout = setTimeout(() => {
|
|
this.highlight(this.currentTerm, this.caseSensitive);
|
|
}, 100);
|
|
}
|
|
});
|
|
this.mutationObserver.observe(container, { childList: true, subtree: true });
|
|
}
|
|
|
|
highlight(term: string, caseSensitive = false) {
|
|
// Store the current match index and count before clearing
|
|
const currentIndex = this.currentMatchIndex;
|
|
const oldHighlightCount = this.highlights.length;
|
|
|
|
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';
|
|
|
|
// 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
|
|
// rect.top/left are already relative to viewport, so we need to adjust for scroll container position
|
|
const top = rect.top - containerRect.top + scrollTop;
|
|
const left = rect.left - containerRect.left + scrollLeft;
|
|
|
|
highlightRect.style.cssText = `
|
|
position: absolute;
|
|
pointer-events: none;
|
|
top: ${top}px;
|
|
left: ${left}px;
|
|
width: ${rect.width}px;
|
|
height: ${rect.height}px;
|
|
`;
|
|
highlight.appendChild(highlightRect);
|
|
});
|
|
|
|
this.overlay.appendChild(highlight);
|
|
return highlight;
|
|
});
|
|
|
|
// 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 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;
|
|
}
|
|
|
|
setCurrentMatch(index: number, shouldScroll = true) {
|
|
if (!this.highlights.length) return;
|
|
|
|
// 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 all parts of the highlight
|
|
const currentHighlight = this.highlights[wrappedIndex];
|
|
const highlightElements = currentHighlight.querySelectorAll('.search-highlight');
|
|
highlightElements.forEach((el) => {
|
|
el.classList.add('current');
|
|
});
|
|
|
|
// 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();
|
|
|
|
// Calculate the target position that would center the highlight
|
|
const targetScrollTop =
|
|
this.scrollContainer.scrollTop +
|
|
(highlightRect.top - containerRect.top) -
|
|
(containerRect.height - highlightRect.height) / 2;
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
private updateHighlightPositions() {
|
|
if (this.currentTerm) {
|
|
const currentIndex = this.currentMatchIndex;
|
|
const oldHighlights = this.highlights.length; // Store the current count
|
|
this.highlight(this.currentTerm, this.caseSensitive);
|
|
|
|
// If we still have the same number of highlights, restore the current index
|
|
if (this.highlights.length === oldHighlights && currentIndex >= 0) {
|
|
this.setCurrentMatch(currentIndex, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
clearHighlights() {
|
|
this.highlights.forEach((h) => h.remove());
|
|
this.highlights = [];
|
|
this.currentTerm = '';
|
|
this.currentMatchIndex = -1;
|
|
this.overlay.innerHTML = '';
|
|
}
|
|
|
|
destroy() {
|
|
if (this.highlightTimeout) {
|
|
clearTimeout(this.highlightTimeout);
|
|
}
|
|
this.resizeObserver.disconnect();
|
|
this.mutationObserver.disconnect();
|
|
this.overlay.remove();
|
|
}
|
|
}
|