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