mirror of
				https://github.com/matrix-org/synapse.git
				synced 2025-11-04 02:01:03 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			484 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			484 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
"use strict";
 | 
						|
window.search = window.search || {};
 | 
						|
(function search(search) {
 | 
						|
    // Search functionality
 | 
						|
    //
 | 
						|
    // You can use !hasFocus() to prevent keyhandling in your key
 | 
						|
    // event handlers while the user is typing their search.
 | 
						|
 | 
						|
    if (!Mark || !elasticlunr) {
 | 
						|
        return;
 | 
						|
    }
 | 
						|
 | 
						|
    //IE 11 Compatibility from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith
 | 
						|
    if (!String.prototype.startsWith) {
 | 
						|
        String.prototype.startsWith = function(search, pos) {
 | 
						|
            return this.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search;
 | 
						|
        };
 | 
						|
    }
 | 
						|
 | 
						|
    var search_wrap = document.getElementById('search-wrapper'),
 | 
						|
        searchbar = document.getElementById('searchbar'),
 | 
						|
        searchbar_outer = document.getElementById('searchbar-outer'),
 | 
						|
        searchresults = document.getElementById('searchresults'),
 | 
						|
        searchresults_outer = document.getElementById('searchresults-outer'),
 | 
						|
        searchresults_header = document.getElementById('searchresults-header'),
 | 
						|
        searchicon = document.getElementById('search-toggle'),
 | 
						|
        content = document.getElementById('content'),
 | 
						|
 | 
						|
        searchindex = null,
 | 
						|
        doc_urls = [],
 | 
						|
        results_options = {
 | 
						|
            teaser_word_count: 30,
 | 
						|
            limit_results: 30,
 | 
						|
        },
 | 
						|
        search_options = {
 | 
						|
            bool: "AND",
 | 
						|
            expand: true,
 | 
						|
            fields: {
 | 
						|
                title: {boost: 1},
 | 
						|
                body: {boost: 1},
 | 
						|
                breadcrumbs: {boost: 0}
 | 
						|
            }
 | 
						|
        },
 | 
						|
        mark_exclude = [],
 | 
						|
        marker = new Mark(content),
 | 
						|
        current_searchterm = "",
 | 
						|
        URL_SEARCH_PARAM = 'search',
 | 
						|
        URL_MARK_PARAM = 'highlight',
 | 
						|
        teaser_count = 0,
 | 
						|
 | 
						|
        SEARCH_HOTKEY_KEYCODE = 83,
 | 
						|
        ESCAPE_KEYCODE = 27,
 | 
						|
        DOWN_KEYCODE = 40,
 | 
						|
        UP_KEYCODE = 38,
 | 
						|
        SELECT_KEYCODE = 13;
 | 
						|
 | 
						|
    function hasFocus() {
 | 
						|
        return searchbar === document.activeElement;
 | 
						|
    }
 | 
						|
 | 
						|
    function removeChildren(elem) {
 | 
						|
        while (elem.firstChild) {
 | 
						|
            elem.removeChild(elem.firstChild);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    // Helper to parse a url into its building blocks.
 | 
						|
    function parseURL(url) {
 | 
						|
        var a =  document.createElement('a');
 | 
						|
        a.href = url;
 | 
						|
        return {
 | 
						|
            source: url,
 | 
						|
            protocol: a.protocol.replace(':',''),
 | 
						|
            host: a.hostname,
 | 
						|
            port: a.port,
 | 
						|
            params: (function(){
 | 
						|
                var ret = {};
 | 
						|
                var seg = a.search.replace(/^\?/,'').split('&');
 | 
						|
                var len = seg.length, i = 0, s;
 | 
						|
                for (;i<len;i++) {
 | 
						|
                    if (!seg[i]) { continue; }
 | 
						|
                    s = seg[i].split('=');
 | 
						|
                    ret[s[0]] = s[1];
 | 
						|
                }
 | 
						|
                return ret;
 | 
						|
            })(),
 | 
						|
            file: (a.pathname.match(/\/([^/?#]+)$/i) || [,''])[1],
 | 
						|
            hash: a.hash.replace('#',''),
 | 
						|
            path: a.pathname.replace(/^([^/])/,'/$1')
 | 
						|
        };
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Helper to recreate a url string from its building blocks.
 | 
						|
    function renderURL(urlobject) {
 | 
						|
        var url = urlobject.protocol + "://" + urlobject.host;
 | 
						|
        if (urlobject.port != "") {
 | 
						|
            url += ":" + urlobject.port;
 | 
						|
        }
 | 
						|
        url += urlobject.path;
 | 
						|
        var joiner = "?";
 | 
						|
        for(var prop in urlobject.params) {
 | 
						|
            if(urlobject.params.hasOwnProperty(prop)) {
 | 
						|
                url += joiner + prop + "=" + urlobject.params[prop];
 | 
						|
                joiner = "&";
 | 
						|
            }
 | 
						|
        }
 | 
						|
        if (urlobject.hash != "") {
 | 
						|
            url += "#" + urlobject.hash;
 | 
						|
        }
 | 
						|
        return url;
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Helper to escape html special chars for displaying the teasers
 | 
						|
    var escapeHTML = (function() {
 | 
						|
        var MAP = {
 | 
						|
            '&': '&',
 | 
						|
            '<': '<',
 | 
						|
            '>': '>',
 | 
						|
            '"': '"',
 | 
						|
            "'": '''
 | 
						|
        };
 | 
						|
        var repl = function(c) { return MAP[c]; };
 | 
						|
        return function(s) {
 | 
						|
            return s.replace(/[&<>'"]/g, repl);
 | 
						|
        };
 | 
						|
    })();
 | 
						|
    
 | 
						|
    function formatSearchMetric(count, searchterm) {
 | 
						|
        if (count == 1) {
 | 
						|
            return count + " search result for '" + searchterm + "':";
 | 
						|
        } else if (count == 0) {
 | 
						|
            return "No search results for '" + searchterm + "'.";
 | 
						|
        } else {
 | 
						|
            return count + " search results for '" + searchterm + "':";
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    function formatSearchResult(result, searchterms) {
 | 
						|
        var teaser = makeTeaser(escapeHTML(result.doc.body), searchterms);
 | 
						|
        teaser_count++;
 | 
						|
 | 
						|
        // The ?URL_MARK_PARAM= parameter belongs inbetween the page and the #heading-anchor
 | 
						|
        var url = doc_urls[result.ref].split("#");
 | 
						|
        if (url.length == 1) { // no anchor found
 | 
						|
            url.push("");
 | 
						|
        }
 | 
						|
 | 
						|
        // encodeURIComponent escapes all chars that could allow an XSS except
 | 
						|
        // for '. Due to that we also manually replace ' with its url-encoded
 | 
						|
        // representation (%27).
 | 
						|
        var searchterms = encodeURIComponent(searchterms.join(" ")).replace(/\'/g, "%27");
 | 
						|
 | 
						|
        return '<a href="' + path_to_root + url[0] + '?' + URL_MARK_PARAM + '=' + searchterms + '#' + url[1]
 | 
						|
            + '" aria-details="teaser_' + teaser_count + '">' + result.doc.breadcrumbs + '</a>'
 | 
						|
            + '<span class="teaser" id="teaser_' + teaser_count + '" aria-label="Search Result Teaser">' 
 | 
						|
            + teaser + '</span>';
 | 
						|
    }
 | 
						|
    
 | 
						|
    function makeTeaser(body, searchterms) {
 | 
						|
        // The strategy is as follows:
 | 
						|
        // First, assign a value to each word in the document:
 | 
						|
        //  Words that correspond to search terms (stemmer aware): 40
 | 
						|
        //  Normal words: 2
 | 
						|
        //  First word in a sentence: 8
 | 
						|
        // Then use a sliding window with a constant number of words and count the
 | 
						|
        // sum of the values of the words within the window. Then use the window that got the
 | 
						|
        // maximum sum. If there are multiple maximas, then get the last one.
 | 
						|
        // Enclose the terms in <em>.
 | 
						|
        var stemmed_searchterms = searchterms.map(function(w) {
 | 
						|
            return elasticlunr.stemmer(w.toLowerCase());
 | 
						|
        });
 | 
						|
        var searchterm_weight = 40;
 | 
						|
        var weighted = []; // contains elements of ["word", weight, index_in_document]
 | 
						|
        // split in sentences, then words
 | 
						|
        var sentences = body.toLowerCase().split('. ');
 | 
						|
        var index = 0;
 | 
						|
        var value = 0;
 | 
						|
        var searchterm_found = false;
 | 
						|
        for (var sentenceindex in sentences) {
 | 
						|
            var words = sentences[sentenceindex].split(' ');
 | 
						|
            value = 8;
 | 
						|
            for (var wordindex in words) {
 | 
						|
                var word = words[wordindex];
 | 
						|
                if (word.length > 0) {
 | 
						|
                    for (var searchtermindex in stemmed_searchterms) {
 | 
						|
                        if (elasticlunr.stemmer(word).startsWith(stemmed_searchterms[searchtermindex])) {
 | 
						|
                            value = searchterm_weight;
 | 
						|
                            searchterm_found = true;
 | 
						|
                        }
 | 
						|
                    };
 | 
						|
                    weighted.push([word, value, index]);
 | 
						|
                    value = 2;
 | 
						|
                }
 | 
						|
                index += word.length;
 | 
						|
                index += 1; // ' ' or '.' if last word in sentence
 | 
						|
            };
 | 
						|
            index += 1; // because we split at a two-char boundary '. '
 | 
						|
        };
 | 
						|
 | 
						|
        if (weighted.length == 0) {
 | 
						|
            return body;
 | 
						|
        }
 | 
						|
 | 
						|
        var window_weight = [];
 | 
						|
        var window_size = Math.min(weighted.length, results_options.teaser_word_count);
 | 
						|
 | 
						|
        var cur_sum = 0;
 | 
						|
        for (var wordindex = 0; wordindex < window_size; wordindex++) {
 | 
						|
            cur_sum += weighted[wordindex][1];
 | 
						|
        };
 | 
						|
        window_weight.push(cur_sum);
 | 
						|
        for (var wordindex = 0; wordindex < weighted.length - window_size; wordindex++) {
 | 
						|
            cur_sum -= weighted[wordindex][1];
 | 
						|
            cur_sum += weighted[wordindex + window_size][1];
 | 
						|
            window_weight.push(cur_sum);
 | 
						|
        };
 | 
						|
 | 
						|
        if (searchterm_found) {
 | 
						|
            var max_sum = 0;
 | 
						|
            var max_sum_window_index = 0;
 | 
						|
            // backwards
 | 
						|
            for (var i = window_weight.length - 1; i >= 0; i--) {
 | 
						|
                if (window_weight[i] > max_sum) {
 | 
						|
                    max_sum = window_weight[i];
 | 
						|
                    max_sum_window_index = i;
 | 
						|
                }
 | 
						|
            };
 | 
						|
        } else {
 | 
						|
            max_sum_window_index = 0;
 | 
						|
        }
 | 
						|
 | 
						|
        // add <em/> around searchterms
 | 
						|
        var teaser_split = [];
 | 
						|
        var index = weighted[max_sum_window_index][2];
 | 
						|
        for (var i = max_sum_window_index; i < max_sum_window_index+window_size; i++) {
 | 
						|
            var word = weighted[i];
 | 
						|
            if (index < word[2]) {
 | 
						|
                // missing text from index to start of `word`
 | 
						|
                teaser_split.push(body.substring(index, word[2]));
 | 
						|
                index = word[2];
 | 
						|
            }
 | 
						|
            if (word[1] == searchterm_weight) {
 | 
						|
                teaser_split.push("<em>")
 | 
						|
            }
 | 
						|
            index = word[2] + word[0].length;
 | 
						|
            teaser_split.push(body.substring(word[2], index));
 | 
						|
            if (word[1] == searchterm_weight) {
 | 
						|
                teaser_split.push("</em>")
 | 
						|
            }
 | 
						|
        };
 | 
						|
 | 
						|
        return teaser_split.join('');
 | 
						|
    }
 | 
						|
 | 
						|
    function init(config) {
 | 
						|
        results_options = config.results_options;
 | 
						|
        search_options = config.search_options;
 | 
						|
        searchbar_outer = config.searchbar_outer;
 | 
						|
        doc_urls = config.doc_urls;
 | 
						|
        searchindex = elasticlunr.Index.load(config.index);
 | 
						|
 | 
						|
        // Set up events
 | 
						|
        searchicon.addEventListener('click', function(e) { searchIconClickHandler(); }, false);
 | 
						|
        searchbar.addEventListener('keyup', function(e) { searchbarKeyUpHandler(); }, false);
 | 
						|
        document.addEventListener('keydown', function(e) { globalKeyHandler(e); }, false);
 | 
						|
        // If the user uses the browser buttons, do the same as if a reload happened
 | 
						|
        window.onpopstate = function(e) { doSearchOrMarkFromUrl(); };
 | 
						|
        // Suppress "submit" events so the page doesn't reload when the user presses Enter
 | 
						|
        document.addEventListener('submit', function(e) { e.preventDefault(); }, false);
 | 
						|
 | 
						|
        // If reloaded, do the search or mark again, depending on the current url parameters
 | 
						|
        doSearchOrMarkFromUrl();
 | 
						|
    }
 | 
						|
    
 | 
						|
    function unfocusSearchbar() {
 | 
						|
        // hacky, but just focusing a div only works once
 | 
						|
        var tmp = document.createElement('input');
 | 
						|
        tmp.setAttribute('style', 'position: absolute; opacity: 0;');
 | 
						|
        searchicon.appendChild(tmp);
 | 
						|
        tmp.focus();
 | 
						|
        tmp.remove();
 | 
						|
    }
 | 
						|
    
 | 
						|
    // On reload or browser history backwards/forwards events, parse the url and do search or mark
 | 
						|
    function doSearchOrMarkFromUrl() {
 | 
						|
        // Check current URL for search request
 | 
						|
        var url = parseURL(window.location.href);
 | 
						|
        if (url.params.hasOwnProperty(URL_SEARCH_PARAM)
 | 
						|
            && url.params[URL_SEARCH_PARAM] != "") {
 | 
						|
            showSearch(true);
 | 
						|
            searchbar.value = decodeURIComponent(
 | 
						|
                (url.params[URL_SEARCH_PARAM]+'').replace(/\+/g, '%20'));
 | 
						|
            searchbarKeyUpHandler(); // -> doSearch()
 | 
						|
        } else {
 | 
						|
            showSearch(false);
 | 
						|
        }
 | 
						|
 | 
						|
        if (url.params.hasOwnProperty(URL_MARK_PARAM)) {
 | 
						|
            var words = decodeURIComponent(url.params[URL_MARK_PARAM]).split(' ');
 | 
						|
            marker.mark(words, {
 | 
						|
                exclude: mark_exclude
 | 
						|
            });
 | 
						|
 | 
						|
            var markers = document.querySelectorAll("mark");
 | 
						|
            function hide() {
 | 
						|
                for (var i = 0; i < markers.length; i++) {
 | 
						|
                    markers[i].classList.add("fade-out");
 | 
						|
                    window.setTimeout(function(e) { marker.unmark(); }, 300);
 | 
						|
                }
 | 
						|
            }
 | 
						|
            for (var i = 0; i < markers.length; i++) {
 | 
						|
                markers[i].addEventListener('click', hide);
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Eventhandler for keyevents on `document`
 | 
						|
    function globalKeyHandler(e) {
 | 
						|
        if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.target.type === 'textarea' || e.target.type === 'text') { return; }
 | 
						|
 | 
						|
        if (e.keyCode === ESCAPE_KEYCODE) {
 | 
						|
            e.preventDefault();
 | 
						|
            searchbar.classList.remove("active");
 | 
						|
            setSearchUrlParameters("",
 | 
						|
                (searchbar.value.trim() !== "") ? "push" : "replace");
 | 
						|
            if (hasFocus()) {
 | 
						|
                unfocusSearchbar();
 | 
						|
            }
 | 
						|
            showSearch(false);
 | 
						|
            marker.unmark();
 | 
						|
        } else if (!hasFocus() && e.keyCode === SEARCH_HOTKEY_KEYCODE) {
 | 
						|
            e.preventDefault();
 | 
						|
            showSearch(true);
 | 
						|
            window.scrollTo(0, 0);
 | 
						|
            searchbar.select();
 | 
						|
        } else if (hasFocus() && e.keyCode === DOWN_KEYCODE) {
 | 
						|
            e.preventDefault();
 | 
						|
            unfocusSearchbar();
 | 
						|
            searchresults.firstElementChild.classList.add("focus");
 | 
						|
        } else if (!hasFocus() && (e.keyCode === DOWN_KEYCODE
 | 
						|
                                || e.keyCode === UP_KEYCODE
 | 
						|
                                || e.keyCode === SELECT_KEYCODE)) {
 | 
						|
            // not `:focus` because browser does annoying scrolling
 | 
						|
            var focused = searchresults.querySelector("li.focus");
 | 
						|
            if (!focused) return;
 | 
						|
            e.preventDefault();
 | 
						|
            if (e.keyCode === DOWN_KEYCODE) {
 | 
						|
                var next = focused.nextElementSibling;
 | 
						|
                if (next) {
 | 
						|
                    focused.classList.remove("focus");
 | 
						|
                    next.classList.add("focus");
 | 
						|
                }
 | 
						|
            } else if (e.keyCode === UP_KEYCODE) {
 | 
						|
                focused.classList.remove("focus");
 | 
						|
                var prev = focused.previousElementSibling;
 | 
						|
                if (prev) {
 | 
						|
                    prev.classList.add("focus");
 | 
						|
                } else {
 | 
						|
                    searchbar.select();
 | 
						|
                }
 | 
						|
            } else { // SELECT_KEYCODE
 | 
						|
                window.location.assign(focused.querySelector('a'));
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    function showSearch(yes) {
 | 
						|
        if (yes) {
 | 
						|
            search_wrap.classList.remove('hidden');
 | 
						|
            searchicon.setAttribute('aria-expanded', 'true');
 | 
						|
        } else {
 | 
						|
            search_wrap.classList.add('hidden');
 | 
						|
            searchicon.setAttribute('aria-expanded', 'false');
 | 
						|
            var results = searchresults.children;
 | 
						|
            for (var i = 0; i < results.length; i++) {
 | 
						|
                results[i].classList.remove("focus");
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    function showResults(yes) {
 | 
						|
        if (yes) {
 | 
						|
            searchresults_outer.classList.remove('hidden');
 | 
						|
        } else {
 | 
						|
            searchresults_outer.classList.add('hidden');
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    // Eventhandler for search icon
 | 
						|
    function searchIconClickHandler() {
 | 
						|
        if (search_wrap.classList.contains('hidden')) {
 | 
						|
            showSearch(true);
 | 
						|
            window.scrollTo(0, 0);
 | 
						|
            searchbar.select();
 | 
						|
        } else {
 | 
						|
            showSearch(false);
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Eventhandler for keyevents while the searchbar is focused
 | 
						|
    function searchbarKeyUpHandler() {
 | 
						|
        var searchterm = searchbar.value.trim();
 | 
						|
        if (searchterm != "") {
 | 
						|
            searchbar.classList.add("active");
 | 
						|
            doSearch(searchterm);
 | 
						|
        } else {
 | 
						|
            searchbar.classList.remove("active");
 | 
						|
            showResults(false);
 | 
						|
            removeChildren(searchresults);
 | 
						|
        }
 | 
						|
 | 
						|
        setSearchUrlParameters(searchterm, "push_if_new_search_else_replace");
 | 
						|
 | 
						|
        // Remove marks
 | 
						|
        marker.unmark();
 | 
						|
    }
 | 
						|
    
 | 
						|
    // Update current url with ?URL_SEARCH_PARAM= parameter, remove ?URL_MARK_PARAM and #heading-anchor .
 | 
						|
    // `action` can be one of "push", "replace", "push_if_new_search_else_replace"
 | 
						|
    // and replaces or pushes a new browser history item.
 | 
						|
    // "push_if_new_search_else_replace" pushes if there is no `?URL_SEARCH_PARAM=abc` yet.
 | 
						|
    function setSearchUrlParameters(searchterm, action) {
 | 
						|
        var url = parseURL(window.location.href);
 | 
						|
        var first_search = ! url.params.hasOwnProperty(URL_SEARCH_PARAM);
 | 
						|
        if (searchterm != "" || action == "push_if_new_search_else_replace") {
 | 
						|
            url.params[URL_SEARCH_PARAM] = searchterm;
 | 
						|
            delete url.params[URL_MARK_PARAM];
 | 
						|
            url.hash = "";
 | 
						|
        } else {
 | 
						|
            delete url.params[URL_MARK_PARAM];
 | 
						|
            delete url.params[URL_SEARCH_PARAM];
 | 
						|
        }
 | 
						|
        // A new search will also add a new history item, so the user can go back
 | 
						|
        // to the page prior to searching. A updated search term will only replace
 | 
						|
        // the url.
 | 
						|
        if (action == "push" || (action == "push_if_new_search_else_replace" && first_search) ) {
 | 
						|
            history.pushState({}, document.title, renderURL(url));
 | 
						|
        } else if (action == "replace" || (action == "push_if_new_search_else_replace" && !first_search) ) {
 | 
						|
            history.replaceState({}, document.title, renderURL(url));
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    function doSearch(searchterm) {
 | 
						|
 | 
						|
        // Don't search the same twice
 | 
						|
        if (current_searchterm == searchterm) { return; }
 | 
						|
        else { current_searchterm = searchterm; }
 | 
						|
 | 
						|
        if (searchindex == null) { return; }
 | 
						|
 | 
						|
        // Do the actual search
 | 
						|
        var results = searchindex.search(searchterm, search_options);
 | 
						|
        var resultcount = Math.min(results.length, results_options.limit_results);
 | 
						|
 | 
						|
        // Display search metrics
 | 
						|
        searchresults_header.innerText = formatSearchMetric(resultcount, searchterm);
 | 
						|
 | 
						|
        // Clear and insert results
 | 
						|
        var searchterms  = searchterm.split(' ');
 | 
						|
        removeChildren(searchresults);
 | 
						|
        for(var i = 0; i < resultcount ; i++){
 | 
						|
            var resultElem = document.createElement('li');
 | 
						|
            resultElem.innerHTML = formatSearchResult(results[i], searchterms);
 | 
						|
            searchresults.appendChild(resultElem);
 | 
						|
        }
 | 
						|
 | 
						|
        // Display results
 | 
						|
        showResults(true);
 | 
						|
    }
 | 
						|
 | 
						|
    fetch(path_to_root + 'searchindex.json')
 | 
						|
        .then(response => response.json())
 | 
						|
        .then(json => init(json))        
 | 
						|
        .catch(error => { // Try to load searchindex.js if fetch failed
 | 
						|
            var script = document.createElement('script');
 | 
						|
            script.src = path_to_root + 'searchindex.js';
 | 
						|
            script.onload = () => init(window.search);
 | 
						|
            document.head.appendChild(script);
 | 
						|
        });
 | 
						|
 | 
						|
    // Exported functions
 | 
						|
    search.hasFocus = hasFocus;
 | 
						|
})(window.search);
 |