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