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**
<img width="935" alt="screenshot"
src="https://github.com/alshedivat/al-folio/assets/1998723/447003e2-c623-4de9-b2c5-2357117a7743">

Looking for feedback.
This commit is contained in:
CheariX 2024-07-08 21:51:22 +02:00 committed by GitHub
parent ad8104b40f
commit 0a40a22739
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 196 additions and 1 deletions

View File

@ -51,6 +51,7 @@ navbar_fixed: true
footer_fixed: true footer_fixed: true
search_enabled: true search_enabled: true
socials_in_search: true socials_in_search: true
bib_search: true
# Dimensions # Dimensions
max_width: 930px max_width: 930px

View File

@ -37,6 +37,11 @@
<script defer src="{{ '/assets/js/common.js' | relative_url | bust_file_cache }}"></script> <script defer src="{{ '/assets/js/common.js' | relative_url | bust_file_cache }}"></script>
<script defer src="{{ '/assets/js/copy_code.js' | relative_url | bust_file_cache }}" type="text/javascript"></script> <script defer src="{{ '/assets/js/copy_code.js' | relative_url | bust_file_cache }}" type="text/javascript"></script>
<!-- Bibsearch Feature -->
{% if site.search_enabled %}
<script src="{{ '/assets/js/bibsearch.js' | relative_url | bust_file_cache }}" type="module"></script>
{% endif %}
<!-- Jupyter Open External Links New Tab --> <!-- Jupyter Open External Links New Tab -->
<script defer src="{{ '/assets/js/jupyter_new_tab.js' | relative_url | bust_file_cache }}"></script> <script defer src="{{ '/assets/js/jupyter_new_tab.js' | relative_url | bust_file_cache }}"></script>

View File

@ -8,6 +8,11 @@ nav_order: 2
--- ---
<!-- _pages/publications.md --> <!-- _pages/publications.md -->
{% if site.search_enabled %}
<input type="text" id="bibsearch" spellcheck="false" autocomplete="off" class="search bibsearch-form-input" placeholder="Type to filter">
{% endif %}
<div class="publications"> <div class="publications">
{% bibliography %} {% bibliography %}

View File

@ -1149,7 +1149,8 @@ ninja-keys::part(ninja-input-wrapper) {
width: 100%; width: 100%;
} }
.newsletter-form-input { .newsletter-form-input,
.bibsearch-form-input {
color: var(--global-newsletter-text-color); color: var(--global-newsletter-text-color);
background: var(--global-newsletter-bg-color); background: var(--global-newsletter-bg-color);
border: 1px solid var(--global-newsletter-text-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); border-bottom-color: var(--global-divider-color);
} }
} }
// highlight-search-term
::highlight(search) {
background-color: var(--global-theme-color);
color: var(--global-text-color);
}

67
assets/js/bibsearch.js Normal file
View File

@ -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
});

View File

@ -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 };