From b411560787435e2b3ef661bf2702fc74c07a0fa3 Mon Sep 17 00:00:00 2001 From: PVBLIC Foundation Date: Fri, 20 Jun 2025 09:20:17 -0700 Subject: [PATCH] feat: complete Step 4 - Visual Highlighting with improved UX --- src/lib/components/chat/ChatSearch.svelte | 124 +++++++++++++++++++++- 1 file changed, 119 insertions(+), 5 deletions(-) diff --git a/src/lib/components/chat/ChatSearch.svelte b/src/lib/components/chat/ChatSearch.svelte index 1387b117e..68a787e8f 100644 --- a/src/lib/components/chat/ChatSearch.svelte +++ b/src/lib/components/chat/ChatSearch.svelte @@ -19,6 +19,7 @@ let matchingMessageIds: string[] = []; let currentIndex = 0; let isNavigating = false; // Visual feedback for navigation + let currentSearchTerm = ''; // Track current highlighted term // Computed values $: totalResults = matchingMessageIds.length; @@ -53,17 +54,23 @@ }; const closeSearch = () => { + clearHighlights(); searchQuery = ''; matchingMessageIds = []; currentIndex = 0; isNavigating = false; + currentSearchTerm = ''; dispatch('close'); }; const performSearch = (query: string) => { + // Clear previous highlights + clearHighlights(); + if (!query.trim() || !history?.messages) { matchingMessageIds = []; currentIndex = 0; + currentSearchTerm = ''; return; } @@ -81,13 +88,101 @@ matchingMessageIds = messageIds; currentIndex = messageIds.length > 0 ? 0 : 0; + currentSearchTerm = searchTerm; - // Auto-navigate to first result when search finds matches + // Apply highlights and auto-navigate to first result if (messageIds.length > 0) { + highlightMatches(searchTerm); scrollToCurrentResult(); } }; + const highlightMatches = (searchTerm: string) => { + if (!searchTerm.trim()) return; + + matchingMessageIds.forEach(messageId => { + const messageElement = document.getElementById(`message-${messageId}`); + if (messageElement) { + highlightInElement(messageElement, searchTerm); + } + }); + }; + + const highlightInElement = (element: Element, searchTerm: string) => { + const walker = document.createTreeWalker( + element, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => { + // Skip if parent already has highlight class or is a script/style tag + const parent = node.parentElement; + if (!parent || parent.classList.contains('search-highlight') || + parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE') { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + } + ); + + const textNodes: Text[] = []; + let node; + while (node = walker.nextNode()) { + textNodes.push(node as Text); + } + + textNodes.forEach(textNode => { + const text = textNode.textContent || ''; + const lowerText = text.toLowerCase(); + const lowerSearchTerm = searchTerm.toLowerCase(); + + if (lowerText.includes(lowerSearchTerm)) { + const parent = textNode.parentNode; + if (!parent) return; + + // Create document fragment with highlighted content + const fragment = document.createDocumentFragment(); + let lastIndex = 0; + let match; + + while ((match = lowerText.indexOf(lowerSearchTerm, lastIndex)) !== -1) { + // Add text before match + if (match > lastIndex) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex, match))); + } + + // Add highlighted match + const highlight = document.createElement('span'); + highlight.className = 'search-highlight bg-yellow-200 dark:bg-yellow-600 px-0.5 rounded'; + highlight.textContent = text.slice(match, match + searchTerm.length); + fragment.appendChild(highlight); + + lastIndex = match + searchTerm.length; + } + + // Add remaining text + if (lastIndex < text.length) { + fragment.appendChild(document.createTextNode(text.slice(lastIndex))); + } + + // Replace the text node with highlighted content + parent.replaceChild(fragment, textNode); + } + }); + }; + + const clearHighlights = () => { + // Remove all existing highlights + const highlights = document.querySelectorAll('.search-highlight'); + highlights.forEach(highlight => { + const parent = highlight.parentNode; + if (parent) { + parent.replaceChild(document.createTextNode(highlight.textContent || ''), highlight); + parent.normalize(); // Merge adjacent text nodes + } + }); + }; + const handleInput = () => { performSearch(searchQuery); }; @@ -131,9 +226,12 @@ inline: 'nearest' }); - // Add subtle visual feedback to the message + // Turn all highlights blue during navigation + setHighlightColor('blue'); + + // Add message background flash messageElement.style.transition = 'background-color 0.3s ease'; - messageElement.style.backgroundColor = 'rgba(59, 130, 246, 0.1)'; // Light blue highlight + messageElement.style.backgroundColor = 'rgba(59, 130, 246, 0.1)'; // More visible blue setTimeout(() => { messageElement.style.backgroundColor = ''; @@ -142,6 +240,17 @@ } }; + const setHighlightColor = (color: 'yellow' | 'blue') => { + const allHighlights = document.querySelectorAll('.search-highlight'); + const colorClass = color === 'blue' + ? 'search-highlight bg-blue-200 dark:bg-blue-600 px-0.5 rounded' + : 'search-highlight bg-yellow-200 dark:bg-yellow-600 px-0.5 rounded'; + + allHighlights.forEach(highlight => { + highlight.className = colorClass; + }); + }; + // Click outside handler const handleClickOutside = (e: MouseEvent) => { if (show && searchContainer && !searchContainer.contains(e.target as Node)) { @@ -154,6 +263,7 @@ }); onDestroy(() => { + clearHighlights(); // Clean up highlights when component is destroyed document.removeEventListener('click', handleClickOutside); }); @@ -183,7 +293,7 @@ {#if totalResults > 0}
- {currentResult} of {totalResults} + {currentResult} of {totalResults} {totalResults === 1 ? 'message' : 'messages'}
{:else if searchQuery.trim()}
@@ -234,9 +344,13 @@
{:else if totalResults > 1}
- Navigate with Enter / + Navigate between messages with Enter / Shift+Enter
+ {:else if totalResults === 1} +
+ Found in 1 message with highlights +
{/if} {/if}