Files
tkmind_go/ui/desktop/src/utils/searchHighlighter.ts
T
Josh Soref f636a97faa Spelling (#7137)
Signed-off-by: Josh Soref <2119212+jsoref@users.noreply.github.com>
2026-02-11 14:35:24 +00:00

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();
}
}