Thread viewer for Bluesky

added search match highlighting

+51 -93
+42 -2
src/components/posts/PostBody.svelte
··· 4 4 import RichTextFromFacets from '../RichTextFromFacets.svelte'; 5 5 6 6 let { post } = getContext('post'); 7 + let { highlightedMatches = undefined } = $props(); 8 + 9 + let bodyElement; 10 + 11 + /** @param {string[]} terms */ 12 + 13 + function highlightSearchResults(terms) { 14 + let regexp = new RegExp(`\\b(${terms.join('|')})\\b`, 'gi'); 15 + let walker = document.createTreeWalker(bodyElement, NodeFilter.SHOW_TEXT); 16 + let ranges = []; 17 + 18 + while (walker.nextNode()) { 19 + let node = walker.currentNode; 20 + if (!node.textContent) { continue; } 21 + 22 + regexp.lastIndex = 0; 23 + 24 + for (;;) { 25 + let match = regexp.exec(node.textContent); 26 + if (match === null) break; 27 + 28 + let range = new Range(); 29 + range.setStart(node, match.index); 30 + range.setEnd(node, match.index + match[0].length); 31 + ranges.push(range); 32 + } 33 + } 34 + 35 + let highlight = CSS.highlights.get('search-results') || new Highlight(); 36 + ranges.forEach(r => highlight.add(r)); 37 + CSS.highlights.set('search-results', highlight); 38 + } 39 + 40 + $effect(() => { 41 + if (highlightedMatches && highlightedMatches.length > 0) { 42 + highlightSearchResults(highlightedMatches); 43 + } 44 + }); 7 45 </script> 8 46 9 47 {#if post.originalFediContent} 10 - <div class="body"> 48 + <div class="body" bind:this={bodyElement}> 11 49 {@html sanitizeHTML(post.originalFediContent)} 12 50 </div> 13 51 {:else} 14 - <p class="body"><RichTextFromFacets text={post.text} facets={post.facets} /></p> 52 + <p class="body" bind:this={bodyElement}> 53 + <RichTextFromFacets text={post.text} facets={post.facets} /> 54 + </p> 15 55 {/if}
+2 -2
src/components/posts/PostComponent.svelte
··· 29 29 - feed - a post on the hashtag feed page 30 30 */ 31 31 32 - let { post, context, ...props } = $props(); 32 + let { post, context, highlightedMatches = undefined, ...props } = $props(); 33 33 34 34 let collapsed = $state(false); 35 35 let replies = $state(post.replies); ··· 92 92 </script> 93 93 94 94 {#snippet body()} 95 - <PostBody /> 95 + <PostBody {highlightedMatches} /> 96 96 97 97 {#if post.tags} 98 98 <PostTagsRow />
+4 -2
src/pages/LycanSearchPage.svelte
··· 23 23 let loadingPosts = $state(false); 24 24 let finishedPosts = $state(false); 25 25 let results = $state([]); 26 + let highlightedMatches = $state([]); 26 27 27 28 checkImportStatus(); 28 29 ··· 59 60 finishedPosts = false; 60 61 61 62 lycan.searchPosts(selectedCollection, q, { 62 - onPostsLoaded: (posts) => { 63 + onPostsLoaded: ({ posts, terms }) => { 63 64 loadingPosts = false; 64 65 results.splice(results.length, 0, ...posts); 66 + highlightedMatches = terms; 65 67 }, 66 68 onFinish: () => { 67 69 finishedPosts = true; ··· 214 216 <p>...</p> 215 217 {:else} 216 218 {#each results as post} 217 - <PostComponent {post} context="feed" /> 219 + <PostComponent {post} context="feed" {highlightedMatches} /> 218 220 {/each} 219 221 {#if finishedPosts} 220 222 <p class="results-end">{results.length > 0 ? "No more results." : "No results."}</p>
-79
src/post_component.js
··· 1 - import * as svelte from 'svelte'; 2 - import { $ } from './utils.js'; 3 - import { $tag } from './utils_ts.js'; 4 - import { Post } from './models/posts.js'; 5 - 6 - /** 7 - * Renders a post/thread view and its subviews. 8 - */ 9 - 10 - export class PostComponent { 11 - /** 12 - * Post component's root HTML element, if built. 13 - * @type {HTMLElement | undefined} 14 - */ 15 - _rootElement; 16 - 17 - /** 18 - @param {AnyPost} post, @param {PostContext} context 19 - */ 20 - constructor(post, context) { 21 - this.post = /** @type {Post}, TODO */ (post); 22 - this.context = context; 23 - } 24 - 25 - /** 26 - * @returns {HTMLElement} 27 - */ 28 - get rootElement() { 29 - if (!this._rootElement) { 30 - throw new Error("rootElement not initialized"); 31 - } 32 - 33 - return this._rootElement; 34 - } 35 - 36 - /** @param {string[]} terms */ 37 - 38 - highlightSearchResults(terms) { 39 - let regexp = new RegExp(`\\b(${terms.join('|')})\\b`, 'gi'); 40 - 41 - let root = this.rootElement; 42 - let body = $(root.querySelector(':scope > .content > .body, :scope > .content > details .body')); 43 - let walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); 44 - let textNodes = []; 45 - 46 - while (walker.nextNode()) { 47 - textNodes.push(walker.currentNode); 48 - } 49 - 50 - for (let node of textNodes) { 51 - if (!node.textContent) { continue; } 52 - 53 - let markedText = document.createDocumentFragment(); 54 - let currentPosition = 0; 55 - 56 - for (;;) { 57 - let match = regexp.exec(node.textContent); 58 - if (match === null) break; 59 - 60 - if (match.index > currentPosition) { 61 - let earlierText = node.textContent.slice(currentPosition, match.index); 62 - markedText.appendChild(document.createTextNode(earlierText)); 63 - } 64 - 65 - let span = $tag('span.highlight', { text: match[0] }); 66 - markedText.appendChild(span); 67 - 68 - currentPosition = match.index + match[0].length; 69 - } 70 - 71 - if (currentPosition < node.textContent.length) { 72 - let remainingText = node.textContent.slice(currentPosition); 73 - markedText.appendChild(document.createTextNode(remainingText)); 74 - } 75 - 76 - $(node.parentNode).replaceChild(markedText, node); 77 - } 78 - } 79 - }
+2 -4
src/services/lycan.js
··· 34 34 /** 35 35 * @param {string} collection 36 36 * @param {string} query 37 - * @param {{ onPostsLoaded: (posts: Post[]) => void, onFinish?: () => void }} callbacks 37 + * @param {{ onPostsLoaded: (data: { posts: Post[], terms: string[] }) => void, onFinish?: () => void }} callbacks 38 38 */ 39 39 40 40 searchPosts(collection, query, callbacks) { ··· 52 52 53 53 isLoading = false; 54 54 55 - callbacks.onPostsLoaded(posts); 56 - 57 - // component.highlightSearchResults(response.terms); 55 + callbacks.onPostsLoaded({ posts: posts, terms: response.terms }); 58 56 59 57 cursor = response.cursor; 60 58
+1 -4
style.css
··· 424 424 margin-top: 18px; 425 425 } 426 426 427 - .post .body .highlight { 427 + ::highlight(search-results) { 428 428 background-color: rgba(255, 255, 0, 0.75); 429 - padding: 1px 2px; 430 - margin-left: -1px; 431 - margin-right: -1px; 432 429 } 433 430 434 431 .post .quote-embed {