PageSourceSearch

https://faktaoklimatu.cz/assets/js/search.js

js faktaoklimatu.cz collected 2026-10-02 10:25:11 UTC 11,680 bytes, 329 lines download raw bytes

1class Search {
2    constructor() {
3        this.fuse = null;
4        this.searchString = null;
5        this.timeout = null;
6        this.fixedWindowWidth = null;
7
8        // CSS classes to use for different modes.
9        this.classes = Object.freeze({
10            selectedEntry: 'active',
11            searchingMode: 'navbar-search',
12            openSearchbox: 'searchbox-open',
13        });
14
15        // CSS selectors for accessing page elements.
16        this.selectors = Object.freeze({
17            searchbox: '#searchbox',
18            results: '#omnisearch-suggestions',
19            resultElements: '#omnisearch-suggestions *',
20            resultAllEntries: '#omnisearch-suggestions a',
21            resultSelectedEntry: `#omnisearch-suggestions a.${this.classes.selectedEntry}`,
22            resultsFirstEntry: '#omnisearch-suggestions a:first-of-type',
23            nav: 'body',
24            show: '#searchShow',
25            hide: '#searchHide',
26        });
27
28        // Search index types.
29        this.types = Object.freeze({
30            title: 'title',
31            perex: 'perex',
32            content: 'content',
33        });
34
35        // Keycodes used in navigation shortcuts.
36        this.shortcuts = Object.freeze({
37            escape: 27,
38            enter: 13,
39            up: 38,
40            down: 40,
41        });
42
43        // Options for the fuse library.
44        this.fuseOptions = Object.freeze({
45            shouldSort: true,
46            threshold: 0.3,
47            ignoreLocation: true,
48            maxPatternLength: 32,
49            minMatchCharLength: 2,
50            includeMatches: true,
51            includeScore: true,
52            keys: [
53                {
54                    name: this.types.title,
55                    weight: 0.2		// give title more importance
56                },
57                {
58                    name: this.types.perex,
59                    weight: 0.3		// give perex more importance
60                },
61                {
62                    name: this.types.content,
63                    weight: 0.6
64                }
65            ]
66        });
67
68        $(this.selectors.show).on('click', $.proxy(this.showSearch, this));
69        $(this.selectors.hide).on('click', $.proxy(this.hideSearch, this));
70
71        $(this.selectors.searchbox).on('blur', $.proxy(this.maybeHideSearch, this));
72        $(this.selectors.searchbox).on('keydown', $.proxy(this.navigate, this));
73        $(this.selectors.searchbox).on('focus', $.proxy(this.instantSearch, this));
74        $(this.selectors.searchbox).on('keyup', $.proxy(this.delayedSearch, this));
75
76        $(this.selectors.results).on('blur', $.proxy(this.maybeHideSearch, this));
77
78        // With open suggestions we set a fixed body width which does not work with
79        // resizing content. Hide search on resize to avoid this glitch.
80        $(window).on('resize', $.proxy(this.onResize, this));
81    }
82
83    /**
84     * Event callbacks.
85     */
86    showSearch() {
87        $(this.selectors.nav).addClass(this.classes.searchingMode);
88        $(this.selectors.searchbox).focus();
89    }
90
91    hideSearch() {
92        $(this.selectors.nav).removeClass(this.classes.searchingMode);
93        this.hideSuggestions();
94    }
95
96    maybeHideSearch(event) {
97        // Do not hide if
98        // a) focus is lost to either the search box or the results list, or
99        // b) the whole window loses focus (e.g. when switching keyboard layout).
100        if ($(this.selectors.searchbox)[0].contains(event.relatedTarget) ||
101            $(this.selectors.results)[0].contains(event.relatedTarget) ||
102            document.activeElement === event.target)
103        {
104            return;
105        }
106        this.hideSearch();
107    }
108
109    onResize() {
110        if (this.fixedWindowWidth === null ||
111            this.fixedWindowWidth == window.innerWidth)
112        {
113            return;
114        }
115        // Hide the searchbox if the width of the window has changed since the
116        // search results box got opened. This avoids weird artifacts of
117        // resizing the body while it's width is fixed (fixing the width itself
118        // protects the body from scrolling while search results are open).
119        this.hideSearch();
120    }
121
122    navigate(e) {
123        switch (e.keyCode) {
124            case this.shortcuts.escape:
125                e.preventDefault();
126                this.hideSearch();
127                return;
128            case this.shortcuts.enter:
129                $(this.selectors.resultSelectedEntry)[0].click();
130                return false;
131            case this.shortcuts.down:
132                e.preventDefault();
133                this.move(true);
134                return;
135            case this.shortcuts.up:
136                e.preventDefault();
137                this.move(false);
138                return;
139        }
140    }
141
142    move(down) {
143        let allEntries = $(this.selectors.resultAllEntries);
144        for (let i = 0; i < allEntries.length; i++) {
145            if (allEntries.eq(i).hasClass(this.classes.selectedEntry)) {
146                let next = i + (down ? 1 : -1);
147                if (next < 0 || next >= allEntries.length) {
148                    return;
149                }
150                allEntries.eq(i).removeClass(this.classes.selectedEntry);
151                allEntries.eq(next).addClass(this.classes.selectedEntry);
152                allEntries.get(next).scrollIntoView({block: "nearest", inline: "nearest"});
153                break;
154            }
155        }
156    }
157
158    isShortcut(keyCode) {
159        return Object.values(this.shortcuts).includes(keyCode);
160    }
161    
162    instantSearch(e) {
163        // Skip for special shortcuts, these are handled by navigate().
164        if (this.isShortcut(e.keyCode)) {
165            return;
166        }
167        this.search();
168    }
169
170    delayedSearch(e) {
171        // Skip for special shortcuts, these are handled by navigate().
172        if (this.isShortcut(e.keyCode)) {
173            return;
174        }
175        window.clearTimeout(this.timeout);
176        this.timeout = window.setTimeout($.proxy(this.search, this), 200);
177    }
178
179    /**
180     * Private implementation functions.
181     */
182
183    search() {
184        if (this.fuse === null) {
185            jQuery.get("/search.json", $.proxy(this.onSiteJsonSucceeded, this)).fail($.proxy(this.onSiteJsonFailed, this));
186            return;
187        }
188
189        let newSearchString = $(this.selectors.searchbox).val();
190        if (newSearchString != this.searchString) {
191            this.searchString = newSearchString;
192            let searchResultsHtml = '';
193            if (this.searchString.length > 1) {
194                let searchResults = this.fuse.search(this.searchString);
195                searchResultsHtml = searchResults.length ? 
196                                        this.getResultsHtml(searchResults) : 
197                                        this.getEmptyResultsHtml('Nic nenalezeno');
198            } else {
199                searchResultsHtml = '';
200            }
201            this.updateResults(searchResultsHtml);
202        }
203        if ($(this.selectors.resultElements).length > 0) {
204            this.showSuggestions();
205        } else {
206            this.hideSuggestions();
207        }
208    }
209
210    onSiteJsonSucceeded (data) {
211        this.fuse = new Fuse(data, this.fuseOptions);
212        this.search();
213    }
214
215    onSiteJsonFailed () {
216        this.updateResults(this.getEmptyResultsHtml('Hledání nefunguje. Možná je chyba v připojení k internetu?'));
217        this.showSuggestions();
218    }
219
220    updateResults(resultsHtml) {
221        $(this.selectors.results).html(resultsHtml);
222        $(this.selectors.resultsFirstEntry).addClass(this.classes.selectedEntry);
223    }
224
225    showSuggestions() {
226        let $body = $(document.body);
227        let oldWidth = $body.innerWidth();
228        $body.width(oldWidth);
229        $(this.selectors.nav).width(oldWidth);
230        $body.css("overflow", "hidden");
231        $(this.selectors.results).show();
232        $(this.selectors.searchbox).addClass(this.classes.openSearchbox);
233        this.fixedWindowWidth = window.innerWidth;
234    }
235
236    hideSuggestions() {
237        $(this.selectors.results).hide();
238        this.fixedWindowWidth = null;
239        let $body = $(document.body);
240        $body.css("overflow", "auto");
241        $body.width("auto");
242        $(this.selectors.nav).width("auto");
243        $(this.selectors.searchbox).removeClass(this.classes.openSearchbox);
244    }
245
246    getResultsHtml(results) {
247        var resultsHtml = '';
248        results.forEach(res => {
249            let item = res.item;
250            let title = this.getTitle(res.matches, item.title);
251            let snippet = this.getSnippet(res.matches);
252            let card = `<div class="title">${title}</div>` +
253                       `<div class="search-preview card">${item.block}</div>` +
254                       `<div class="snippet">${snippet}</div>`;
255            let url = `${item.url}?q=${encodeURIComponent(this.searchString)}`;
256            let is_url_absolute = /^(?:[a-z]+:)?\/\//i.test(url);
257            let target = is_url_absolute ? ' target="_blank"' : "";
258            resultsHtml += `<a class="dropdown-item clearfix" href="${url}"${target} tabindex="0">${card}</a>`;
259        });
260        return resultsHtml;
261    }
262
263    getEmptyResultsHtml(infoText) {
264        return `<div class="dropdown-item empty">${infoText}</div>`;
265    }
266
267    getSnippet(matches) {
268        let instances = this.getKeywordInstances(matches, false);
269        if (instances.length == 0)
270            return '';
271
272        let maxSnippets = 2;
273        let context = 30;
274    
275        let results = [];
276        for (let i = 0; i < instances.length && i < maxSnippets; i++) {
277            let instance = instances[i];
278            let prefix = instance.text.substring(instance.start - context, instance.start);
279            let suffix = instance.text.substring(instance.end, instance.end + context);
280            let result = '';
281            if (instance.start - context - 1 >= 0) result += '...';
282            if (prefix) result += prefix;
283            result += this.getKeywordHTML(instance.text.substring(instance.start, instance.end));
284            if (suffix) result += suffix;
285            if (instance.end + context + 1 < instance.text.length) result += '...';
286            results.push(result);
287        }
288        return results.join(' ');
289    }
290
291    getTitle(matches, unmatchedTitle) {
292        let instances = this.getKeywordInstances(matches, true);
293        if (instances.length == 0)
294            return unmatchedTitle;
295        // Only consider the first.
296        let instance = instances[0];
297        let prefix = instance.text.substring(0, instance.start);
298        let suffix = instance.text.substring(instance.end);
299        let result = '';
300        if (prefix) result += prefix;
301        result += this.getKeywordHTML(instance.text.substring(instance.start, instance.end));
302        if (suffix) result += suffix;
303        return result;
304    }
305
306    getKeywordHTML(text) {
307        return `<span class="keyword">${text}</span>`;
308    }
309
310    getKeywordInstances(matches, shouldMatchTitle) {
311        let instances = [];
312        if (!matches)
313            return instances;
314        for (const match of matches) {
315            let isTitle = (match.key == this.types.title);
316            if (isTitle != shouldMatchTitle) {
317                continue;
318            }
319            for (const index of match.indices) {
320                // Ignore matches of length 1;
321                if (index[1] - index[0] == 0) continue;
322                instances.push({start: index[0], end: index[1] + 1, text: match.value});
323            }
324        }
325        // Sort by match length, descending.
326        instances.sort((a,b) => (b.end - b.start) - (a.end - a.start));
327        return instances;
328    }
329}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.