feat(ui): add search functionality to chat view (#1790)

Co-authored-by: Nahiyan Khan <nahiyan.khan@gmail.com>
This commit is contained in:
Joshua Swigut
2025-03-30 21:54:04 -04:00
committed by GitHub
parent 4eb5a64e6a
commit 326f087637
10 changed files with 534 additions and 6 deletions
+108
View File
@@ -0,0 +1,108 @@
/* eslint-disable no-cond-assign */
/**
* Utility class for highlighting search matches in text content.
* Supports case-sensitive search and maintains a "current" highlight state.
*/
export class SearchHighlighter {
private readonly container: HTMLElement;
/**
* Creates a new SearchHighlighter instance.
* @param container - The root HTML element to search within
*/
constructor(container: HTMLElement) {
this.container = container;
}
/**
* 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
*/
highlight(term: string, caseSensitive = false): void {
this.clearHighlights();
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;
}
return window.NodeFilter.FILTER_ACCEPT;
},
});
const matches: Node[] = [];
let node: Node | 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();
if (searchText.includes(searchTerm)) {
matches.push(node);
}
}
// 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;
while ((match = regex.exec(text)) !== null) {
// Add text before the match
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
// Add highlighted match
const mark = document.createElement('mark');
mark.textContent = match[0]; // Use the actual matched text to preserve case
fragment.appendChild(mark);
lastIndex = regex.lastIndex;
}
// Add remaining text
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
}
textNode.parentNode?.replaceChild(fragment, textNode);
});
}
/**
* Removes all search highlights from the container
*/
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();
}
}
}
}