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:
parent
ad8104b40f
commit
0a40a22739
|
|
@ -51,6 +51,7 @@ navbar_fixed: true
|
|||
footer_fixed: true
|
||||
search_enabled: true
|
||||
socials_in_search: true
|
||||
bib_search: true
|
||||
|
||||
# Dimensions
|
||||
max_width: 930px
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@
|
|||
<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>
|
||||
|
||||
<!-- 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 -->
|
||||
<script defer src="{{ '/assets/js/jupyter_new_tab.js' | relative_url | bust_file_cache }}"></script>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ nav_order: 2
|
|||
---
|
||||
|
||||
<!-- _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">
|
||||
|
||||
{% bibliography %}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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 };
|
||||
Loading…
Reference in New Issue