From 0a40a227391e72db1c48fcdd8e78617ecaf6be1e Mon Sep 17 00:00:00 2001 From: CheariX Date: Mon, 8 Jul 2024 21:51:22 +0200 Subject: [PATCH] feat: simple filtering / searching on bibliography (#2523) This PR adds a simple filter/search functionality to the bibliography. It can be used in two ways: 1. Simply enter a search term in the input box. 2. Send a search term via the `location.hash`, e.g., https://alshedivat.github.io/al-folio/publications/#mechanics **Notes:** - The search box is optional. It can be simply removed if anyone does not like it. - Searching via `hash` works without the search box. My idea is to use this functionality to index all BibTeX entries via the `ctrl-k` search and link them via their BibTeX key. - Searching via `hash` could also be used to set static links on the current page, e.g., to filter specific co-authors, venues, etc. - I don't know much about the design of the input field. I simply reused the newsletter box style. - Entering a search term in the box does exact matching. No fuzzy search, no AND/OR logic. I kept it very simple. Maybe anyone else wants to improve it in the future. - The search looks in all data in the BibTeX entry that is parsed via `bib.liquid`. E.g., it is possible to search for BibTeX keys, titles, authors, years, venues, abstracts, or whatever `bib.liquid` prints. - I used a 300ms delay before starting to search on the input box. - Entering search terms in the box does not update the location hash (things could get complex otherwise due to automatically updating each other...) - If the filter does not find any match in a specific year, the year is also made invisible. **Screenshot** screenshot Looking for feedback. --- _config.yml | 1 + _includes/scripts/misc.liquid | 5 ++ _pages/publications.md | 5 ++ _sass/_base.scss | 9 ++- assets/js/bibsearch.js | 67 ++++++++++++++++++ assets/js/highlight-search-term.js | 110 +++++++++++++++++++++++++++++ 6 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 assets/js/bibsearch.js create mode 100644 assets/js/highlight-search-term.js diff --git a/_config.yml b/_config.yml index a6efd65..ee7be6d 100644 --- a/_config.yml +++ b/_config.yml @@ -51,6 +51,7 @@ navbar_fixed: true footer_fixed: true search_enabled: true socials_in_search: true +bib_search: true # Dimensions max_width: 930px diff --git a/_includes/scripts/misc.liquid b/_includes/scripts/misc.liquid index 56d68f5..497f0ee 100644 --- a/_includes/scripts/misc.liquid +++ b/_includes/scripts/misc.liquid @@ -37,6 +37,11 @@ + +{% if site.search_enabled %} + +{% endif %} + diff --git a/_pages/publications.md b/_pages/publications.md index d7c0ee1..485c723 100644 --- a/_pages/publications.md +++ b/_pages/publications.md @@ -8,6 +8,11 @@ nav_order: 2 --- + +{% if site.search_enabled %} + +{% endif %} +
{% bibliography %} diff --git a/_sass/_base.scss b/_sass/_base.scss index 90bdeb0..1cc3372 100644 --- a/_sass/_base.scss +++ b/_sass/_base.scss @@ -1149,7 +1149,8 @@ ninja-keys::part(ninja-input-wrapper) { width: 100%; } -.newsletter-form-input { +.newsletter-form-input, +.bibsearch-form-input { color: var(--global-newsletter-text-color); background: var(--global-newsletter-bg-color); border: 1px solid var(--global-newsletter-text-color); @@ -1287,3 +1288,9 @@ ninja-keys::part(ninja-input-wrapper) { border-bottom-color: var(--global-divider-color); } } + +// highlight-search-term +::highlight(search) { + background-color: var(--global-theme-color); + color: var(--global-text-color); +} diff --git a/assets/js/bibsearch.js b/assets/js/bibsearch.js new file mode 100644 index 0000000..8bc50d8 --- /dev/null +++ b/assets/js/bibsearch.js @@ -0,0 +1,67 @@ +import { highlightSearchTerm } from "./highlight-search-term.js"; + +document.addEventListener("DOMContentLoaded", function () { + // actual bibsearch logic + const filterItems = (searchTerm) => { + document.querySelectorAll(".bibliography, .unloaded").forEach((element) => element.classList.remove("unloaded")); + + // highlight-search-term + if (CSS.highlights) { + const nonMatchingElements = highlightSearchTerm({ search: searchTerm, selector: ".bibliography > li" }); + nonMatchingElements.forEach((element) => { + element.classList.add("unloaded"); + }); + } else { + // Simply add unloaded class to all non-matching items if Browser does not support CSS highlights + document.querySelectorAll(".bibliography > li").forEach((element, index) => { + const text = element.innerText.toLowerCase(); + if (text.indexOf(searchTerm) == -1) { + element.classList.add("unloaded"); + } + }); + } + + document.querySelectorAll("h2.bibliography").forEach(function (element) { + let iterator = element.nextElementSibling; // get next sibling element after h2, which can be h3 or ol + let hideFirstGroupingElement = true; + // iterate until next group element (h2), which is already selected by the querySelectorAll(-).forEach(-) + while (iterator && iterator.tagName !== "H2") { + if (iterator.tagName === "OL") { + const ol = iterator; + const unloadedSiblings = ol.querySelectorAll(":scope > li.unloaded"); + const totalSiblings = ol.querySelectorAll(":scope > li"); + + if (unloadedSiblings.length === totalSiblings.length) { + ol.previousElementSibling.classList.add("unloaded"); // Add the '.unloaded' class to the previous grouping element (e.g. year) + ol.classList.add("unloaded"); // Add the '.unloaded' class to the OL itself + } else { + hideFirstGroupingElement = false; // there is at least some visible entry, don't hide the first grouping element + } + } + iterator = iterator.nextElementSibling; + } + // Add unloaded class to first grouping element (e.g. year) if no item left in this group + if (hideFirstGroupingElement) { + element.classList.add("unloaded"); + } + }); + }; + + const updateInputField = () => { + const hashValue = decodeURIComponent(window.location.hash.substring(1)); // Remove the '#' character + document.getElementById("bibsearch").value = hashValue; + filterItems(hashValue); + }; + + // Sensitive search. Only start searching if there's been no input for 300 ms + let timeoutId; + document.getElementById("bibsearch").addEventListener("input", function () { + clearTimeout(timeoutId); // Clear the previous timeout + const searchTerm = this.value.toLowerCase(); + timeoutId = setTimeout(filterItems(searchTerm), 300); + }); + + window.addEventListener("hashchange", updateInputField); // Update the filter when the hash changes + + updateInputField(); // Update filter when page loads +}); diff --git a/assets/js/highlight-search-term.js b/assets/js/highlight-search-term.js new file mode 100644 index 0000000..d74a22c --- /dev/null +++ b/assets/js/highlight-search-term.js @@ -0,0 +1,110 @@ +/** + * This file is a modified version of: + * https://github.com/marmelab/highlight-search-term/blob/main/src/index.js + * - We return the `nonMatchingElements` + * - We fixed a bug: `getRangesForSearchTermInElement` got the `node.parentElement`, which is not working if there are multiple text nodes in one element. + * + * highlight-search-term is published under MIT License. + * + * MIT License + * + * Copyright (c) 2024 marmelab + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Highlight search term in the selected elements + * + * @example + * import { highlightSearchTerm } from "highlight-search-term"; + * const search = document.getElementById("search"); + * search.addEventListener("input", () => { + * highlightSearchTerm({ search: search.value, selector: ".content" }); + * }); + */ +const highlightSearchTerm = ({ search, selector, customHighlightName = "search" }) => { + if (!selector) { + throw new Error("The selector argument is required"); + } + + if (!CSS.highlights) return; // disable feature on Firefox as it does not support CSS Custom Highlight API + + // remove previous highlight + CSS.highlights.delete(customHighlightName); + if (!search) { + // nothing to highlight + return; + } + // find all text nodes containing the search term + const ranges = []; + const nonMatchingElements = []; + const elements = document.querySelectorAll(selector); + Array.from(elements).map((element) => { + let match = false; + getTextNodesInElementContainingText(element, search).forEach((node) => { + // Modified variant of highlight-search-term + // We return the non-matching elements in addition. + const rangesForSearch = getRangesForSearchTermInNode(node, search); + ranges.push(...rangesForSearch); + if (rangesForSearch.length > 0) { + match = true; + } + }); + if (!match) { + nonMatchingElements.push(element); + } + }); + if (ranges.length === 0) return nonMatchingElements; // modified: return `nonMatchingElements` + // create a CSS highlight that can be styled with the ::highlight(search) pseudo-element + const highlight = new Highlight(...ranges); + CSS.highlights.set(customHighlightName, highlight); + return nonMatchingElements; // modified: return `nonMatchingElements` +}; + +const getTextNodesInElementContainingText = (element, text) => { + const nodes = []; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { + if (node.textContent && node.textContent.toLowerCase().includes(text)) { + nodes.push(node); + } + } + return nodes; +}; + +// Fix: We changed this function to work on the node directly, rather than on its parent element. +const getRangesForSearchTermInNode = (node, search) => { + const ranges = []; + const text = (node.textContent ? node.textContent.toLowerCase() : "") || ""; + + let start = 0; + let index; + while ((index = text.indexOf(search, start)) >= 0) { + const range = new Range(); + range.setStart(node, index); + range.setEnd(node, index + search.length); + ranges.push(range); + start = index + search.length; + } + return ranges; +}; + +export { highlightSearchTerm };