Init
This commit is contained in:
22
themes/hextra/assets/js/core/back-to-top.js
Normal file
22
themes/hextra/assets/js/core/back-to-top.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// Back to top button
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const backToTop = document.querySelector("#backToTop");
|
||||
if (backToTop) {
|
||||
document.addEventListener("scroll", (e) => {
|
||||
if (window.scrollY > 300) {
|
||||
backToTop.classList.remove("hx:opacity-0");
|
||||
} else {
|
||||
backToTop.classList.add("hx:opacity-0");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function scrollUp() {
|
||||
window.scroll({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
15
themes/hextra/assets/js/core/banner.js
Normal file
15
themes/hextra/assets/js/core/banner.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// {{- if site.Params.banner }}
|
||||
(function () {
|
||||
const banner = document.querySelector(".hextra-banner")
|
||||
document.documentElement.style.setProperty("--hextra-banner-height", banner.clientHeight+"px");
|
||||
|
||||
const closeBtn = banner.querySelector(".hextra-banner-close-button");
|
||||
|
||||
closeBtn.addEventListener("click", () => {
|
||||
document.documentElement.classList.add("hextra-banner-hidden");
|
||||
document.documentElement.style.setProperty("--hextra-banner-height", "0px");
|
||||
|
||||
localStorage.setItem('{{ site.Params.banner.key | default `banner-closed` }}', "0");
|
||||
});
|
||||
})();
|
||||
// {{- end -}}
|
||||
66
themes/hextra/assets/js/core/code-copy.js
Normal file
66
themes/hextra/assets/js/core/code-copy.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copy button for code blocks
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const getCopyIcon = () => {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.innerHTML = `
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
`;
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
return svg;
|
||||
}
|
||||
|
||||
const getSuccessIcon = () => {
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.innerHTML = `
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
`;
|
||||
svg.setAttribute('fill', 'none');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('stroke', 'currentColor');
|
||||
svg.setAttribute('stroke-width', '2');
|
||||
return svg;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.hextra-code-copy-btn').forEach(function (button) {
|
||||
// Add copy and success icons
|
||||
button.querySelector('.hextra-copy-icon')?.appendChild(getCopyIcon());
|
||||
button.querySelector('.hextra-success-icon')?.appendChild(getSuccessIcon());
|
||||
|
||||
// Add click event listener for copy button
|
||||
button.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
// Get the code target
|
||||
const target = button.parentElement.previousElementSibling;
|
||||
let codeElement;
|
||||
if (target.tagName === 'CODE') {
|
||||
codeElement = target;
|
||||
} else {
|
||||
// Select the last code element in case line numbers are present
|
||||
const codeElements = target.querySelectorAll('code');
|
||||
codeElement = codeElements[codeElements.length - 1];
|
||||
}
|
||||
if (codeElement) {
|
||||
let code = codeElement.innerText;
|
||||
// Replace double newlines with single newlines in the innerText
|
||||
// as each line inside <span> has trailing newline '\n'
|
||||
if ("lang" in codeElement.dataset) {
|
||||
code = code.replace(/\n\n/g, '\n');
|
||||
}
|
||||
navigator.clipboard.writeText(code).then(function () {
|
||||
button.classList.add('copied');
|
||||
setTimeout(function () {
|
||||
button.classList.remove('copied');
|
||||
}, 1000);
|
||||
}).catch(function (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
});
|
||||
} else {
|
||||
console.error('Target element not found');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
22
themes/hextra/assets/js/core/favicon.js
Normal file
22
themes/hextra/assets/js/core/favicon.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// {{ $faviconDarkExists := fileExists (path.Join "static" "favicon-dark.svg") }}
|
||||
(function () {
|
||||
const faviconEl = document.getElementById("favicon-svg");
|
||||
const faviconDarkExists = "{{ $faviconDarkExists }}" === "true";
|
||||
|
||||
if (faviconEl && faviconDarkExists) {
|
||||
const lightFavicon = '{{ "favicon.svg" | relURL }}';
|
||||
const darkFavicon = '{{ "favicon-dark.svg" | relURL }}';
|
||||
|
||||
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
function updateFavicon(e) {
|
||||
faviconEl.href = e.matches ? darkFavicon : lightFavicon;
|
||||
}
|
||||
|
||||
// Set favicon on load
|
||||
updateFavicon(darkModeQuery);
|
||||
|
||||
// Listen for system preference changes
|
||||
darkModeQuery.addEventListener("change", updateFavicon);
|
||||
}
|
||||
})();
|
||||
13
themes/hextra/assets/js/core/filetree.js
Normal file
13
themes/hextra/assets/js/core/filetree.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Script for filetree shortcode collapsing/expanding folders used in the theme
|
||||
// ======================================================================
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const folders = document.querySelectorAll(".hextra-filetree-folder");
|
||||
folders.forEach(function (folder) {
|
||||
folder.addEventListener("click", function () {
|
||||
Array.from(folder.children).forEach(function (el) {
|
||||
el.dataset.state = el.dataset.state === "open" ? "closed" : "open";
|
||||
});
|
||||
folder.nextElementSibling.dataset.state = folder.nextElementSibling.dataset.state === "open" ? "closed" : "open";
|
||||
});
|
||||
});
|
||||
});
|
||||
26
themes/hextra/assets/js/core/lang.js
Normal file
26
themes/hextra/assets/js/core/lang.js
Normal file
@@ -0,0 +1,26 @@
|
||||
(function () {
|
||||
const languageSwitchers = document.querySelectorAll('.hextra-language-switcher');
|
||||
|
||||
languageSwitchers.forEach((switcher) => {
|
||||
switcher.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
switcher.dataset.state = switcher.dataset.state === 'open' ? 'closed' : 'open';
|
||||
|
||||
toggleMenu(switcher);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => languageSwitchers.forEach(resizeMenu))
|
||||
|
||||
// Dismiss language switcher when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.hextra-language-switcher') === null) {
|
||||
languageSwitchers.forEach((switcher) => {
|
||||
switcher.dataset.state = 'closed';
|
||||
const optionsElement = switcher.nextElementSibling;
|
||||
optionsElement.classList.add('hx:hidden');
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
40
themes/hextra/assets/js/core/menu.js
Normal file
40
themes/hextra/assets/js/core/menu.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// Hamburger menu for mobile navigation
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const menu = document.querySelector('.hextra-hamburger-menu');
|
||||
const sidebarContainer = document.querySelector('.hextra-sidebar-container');
|
||||
|
||||
function toggleMenu() {
|
||||
// Toggle the hamburger menu
|
||||
menu.querySelector('svg').classList.toggle('open');
|
||||
|
||||
// When the menu is open, we want to show the navigation sidebar
|
||||
sidebarContainer.classList.toggle('hx:max-md:[transform:translate3d(0,-100%,0)]');
|
||||
sidebarContainer.classList.toggle('hx:max-md:[transform:translate3d(0,0,0)]');
|
||||
|
||||
// When the menu is open, we want to prevent the body from scrolling
|
||||
document.body.classList.toggle('hx:overflow-hidden');
|
||||
document.body.classList.toggle('hx:md:overflow-auto');
|
||||
}
|
||||
|
||||
menu.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
toggleMenu();
|
||||
});
|
||||
|
||||
// Select all anchor tags in the sidebar container
|
||||
const sidebarLinks = sidebarContainer.querySelectorAll('a');
|
||||
|
||||
// Add click event listener to each anchor tag
|
||||
sidebarLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
// Check if the href attribute contains a hash symbol (links to a heading)
|
||||
if (link.getAttribute('href') && link.getAttribute('href').startsWith('#')) {
|
||||
// Only dismiss overlay on mobile view
|
||||
if (window.innerWidth < 768) {
|
||||
toggleMenu();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
62
themes/hextra/assets/js/core/nav-menu.js
Normal file
62
themes/hextra/assets/js/core/nav-menu.js
Normal file
@@ -0,0 +1,62 @@
|
||||
(function () {
|
||||
const hiddenClass = "hx:hidden";
|
||||
const dropdownToggles = document.querySelectorAll(".hextra-nav-menu-toggle");
|
||||
|
||||
dropdownToggles.forEach((toggle) => {
|
||||
toggle.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Close all other dropdowns first
|
||||
dropdownToggles.forEach((otherToggle) => {
|
||||
if (otherToggle !== toggle) {
|
||||
otherToggle.dataset.state = "closed";
|
||||
const otherMenuItems = otherToggle.nextElementSibling;
|
||||
otherMenuItems.classList.add(hiddenClass);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle current dropdown
|
||||
const isOpen = toggle.dataset.state === "open";
|
||||
toggle.dataset.state = isOpen ? "closed" : "open";
|
||||
const menuItemsElement = toggle.nextElementSibling;
|
||||
|
||||
if (!isOpen) {
|
||||
// Position dropdown centered with toggle
|
||||
menuItemsElement.style.position = "absolute";
|
||||
menuItemsElement.style.top = "100%";
|
||||
menuItemsElement.style.left = "50%";
|
||||
menuItemsElement.style.transform = "translateX(-50%)";
|
||||
menuItemsElement.style.zIndex = "1000";
|
||||
|
||||
// Show dropdown
|
||||
menuItemsElement.classList.remove(hiddenClass);
|
||||
} else {
|
||||
// Hide dropdown
|
||||
menuItemsElement.classList.add(hiddenClass);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Dismiss dropdown when clicking outside
|
||||
document.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".hextra-nav-menu-toggle") === null) {
|
||||
dropdownToggles.forEach((toggle) => {
|
||||
toggle.dataset.state = "closed";
|
||||
const menuItemsElement = toggle.nextElementSibling;
|
||||
menuItemsElement.classList.add(hiddenClass);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdowns on escape key
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
dropdownToggles.forEach((toggle) => {
|
||||
toggle.dataset.state = "closed";
|
||||
const menuItemsElement = toggle.nextElementSibling;
|
||||
menuItemsElement.classList.add(hiddenClass);
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
36
themes/hextra/assets/js/core/sidebar.js
Normal file
36
themes/hextra/assets/js/core/sidebar.js
Normal file
@@ -0,0 +1,36 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
scrollToActiveItem();
|
||||
enableCollapsibles();
|
||||
});
|
||||
|
||||
function enableCollapsibles() {
|
||||
const buttons = document.querySelectorAll(".hextra-sidebar-collapsible-button");
|
||||
buttons.forEach(function (button) {
|
||||
button.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
const list = button.parentElement.parentElement;
|
||||
if (list) {
|
||||
list.classList.toggle("open")
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToActiveItem() {
|
||||
const sidebarScrollbar = document.querySelector("aside.hextra-sidebar-container > .hextra-scrollbar");
|
||||
const activeItems = document.querySelectorAll(".hextra-sidebar-active-item");
|
||||
const visibleActiveItem = Array.from(activeItems).find(function (activeItem) {
|
||||
return activeItem.getBoundingClientRect().height > 0;
|
||||
});
|
||||
|
||||
if (!visibleActiveItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
const yOffset = visibleActiveItem.clientHeight;
|
||||
const yDistance = visibleActiveItem.getBoundingClientRect().top - sidebarScrollbar.getBoundingClientRect().top;
|
||||
sidebarScrollbar.scrollTo({
|
||||
behavior: "instant",
|
||||
top: yDistance - yOffset
|
||||
});
|
||||
}
|
||||
52
themes/hextra/assets/js/core/switcher-menu.js
Normal file
52
themes/hextra/assets/js/core/switcher-menu.js
Normal file
@@ -0,0 +1,52 @@
|
||||
function computeMenuTranslation(switcher, optionsElement) {
|
||||
// Calculate the position of a language options element.
|
||||
const switcherRect = switcher.getBoundingClientRect();
|
||||
|
||||
// Must be called before optionsElement.clientWidth.
|
||||
optionsElement.style.minWidth = `${Math.max(switcherRect.width, 50)}px`;
|
||||
|
||||
const isOnTop = switcher.dataset.location === 'top';
|
||||
const isOnBottom = switcher.dataset.location === 'bottom';
|
||||
const isOnBottomRight = switcher.dataset.location === 'bottom-right';
|
||||
const isRTL = document.documentElement.dir === 'rtl'
|
||||
|
||||
// Stuck on the left side of the switcher.
|
||||
let x = switcherRect.left;
|
||||
|
||||
if (isOnTop && !isRTL || isOnBottom && isRTL || isOnBottomRight && !isRTL) {
|
||||
// Stuck on the right side of the switcher.
|
||||
x = switcherRect.right - optionsElement.clientWidth;
|
||||
}
|
||||
|
||||
// Stuck on the top of the switcher.
|
||||
let y = switcherRect.top - window.innerHeight - 10;
|
||||
|
||||
if (isOnTop) {
|
||||
// Stuck on the bottom of the switcher.
|
||||
y = switcherRect.top - window.innerHeight + optionsElement.clientHeight + switcher.clientHeight + 4;
|
||||
}
|
||||
|
||||
return { x: x, y: y };
|
||||
}
|
||||
|
||||
function toggleMenu(switcher) {
|
||||
const optionsElement = switcher.nextElementSibling;
|
||||
|
||||
optionsElement.classList.toggle('hx:hidden');
|
||||
|
||||
// Calculate the position of a language options element.
|
||||
const translate = computeMenuTranslation(switcher, optionsElement);
|
||||
|
||||
optionsElement.style.transform = `translate3d(${translate.x}px, ${translate.y}px, 0)`;
|
||||
}
|
||||
|
||||
function resizeMenu(switcher) {
|
||||
const optionsElement = switcher.nextElementSibling;
|
||||
|
||||
if (optionsElement.classList.contains('hx:hidden')) return;
|
||||
|
||||
// Calculate the position of a language options element.
|
||||
const translate = computeMenuTranslation(switcher, optionsElement);
|
||||
|
||||
optionsElement.style.transform = `translate3d(${translate.x}px, ${translate.y}px, 0)`;
|
||||
}
|
||||
57
themes/hextra/assets/js/core/tabs.js
Normal file
57
themes/hextra/assets/js/core/tabs.js
Normal file
@@ -0,0 +1,57 @@
|
||||
(function () {
|
||||
function updateGroup(container, index) {
|
||||
const tabs = Array.from(container.querySelectorAll('.hextra-tabs-toggle'));
|
||||
tabs.forEach((tab, i) => {
|
||||
tab.dataset.state = i === index ? 'selected' : '';
|
||||
if (i === index) {
|
||||
tab.setAttribute('aria-selected', 'true');
|
||||
tab.tabIndex = 0;
|
||||
} else {
|
||||
tab.removeAttribute('aria-selected');
|
||||
tab.removeAttribute('tabindex');
|
||||
}
|
||||
});
|
||||
const panelsContainer = container.parentElement.nextElementSibling;
|
||||
if (!panelsContainer) return;
|
||||
Array.from(panelsContainer.children).forEach((panel, i) => {
|
||||
panel.dataset.state = i === index ? 'selected' : '';
|
||||
if (i === index) {
|
||||
panel.tabIndex = 0;
|
||||
} else {
|
||||
panel.removeAttribute('tabindex');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const syncGroups = document.querySelectorAll('[data-tab-group]');
|
||||
|
||||
syncGroups.forEach((group) => {
|
||||
const key = encodeURIComponent(group.dataset.tabGroup);
|
||||
const saved = localStorage.getItem('hextra-tab-' + key);
|
||||
if (saved !== null) {
|
||||
updateGroup(group, parseInt(saved, 10));
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.hextra-tabs-toggle').forEach((button) => {
|
||||
button.addEventListener('click', function (e) {
|
||||
const container = e.target.parentElement;
|
||||
const index = Array.from(container.querySelectorAll('.hextra-tabs-toggle')).indexOf(
|
||||
e.target
|
||||
);
|
||||
|
||||
if (container.dataset.tabGroup) {
|
||||
// Sync behavior: update all tab groups with the same name
|
||||
const tabGroupValue = container.dataset.tabGroup;
|
||||
const key = encodeURIComponent(tabGroupValue);
|
||||
document
|
||||
.querySelectorAll('[data-tab-group="' + tabGroupValue + '"]')
|
||||
.forEach((grp) => updateGroup(grp, index));
|
||||
localStorage.setItem('hextra-tab-' + key, index.toString());
|
||||
} else {
|
||||
// Non-sync behavior: update only this specific tab group
|
||||
updateGroup(container, index);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
61
themes/hextra/assets/js/core/theme.js
Normal file
61
themes/hextra/assets/js/core/theme.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// Light / Dark theme toggle
|
||||
(function () {
|
||||
const defaultTheme = '{{ site.Params.theme.default | default `system`}}'
|
||||
const themes = ["light", "dark"];
|
||||
|
||||
const themeToggleButtons = document.querySelectorAll(".hextra-theme-toggle");
|
||||
const themeToggleOptions = document.querySelectorAll(".hextra-theme-toggle-options p");
|
||||
|
||||
function applyTheme(theme) {
|
||||
theme = themes.includes(theme) ? theme : "system";
|
||||
|
||||
themeToggleButtons.forEach((btn) => btn.parentElement.dataset.theme = theme );
|
||||
|
||||
localStorage.setItem("color-theme", theme);
|
||||
}
|
||||
|
||||
function switchTheme(theme) {
|
||||
setTheme(theme);
|
||||
applyTheme(theme);
|
||||
}
|
||||
|
||||
const colorTheme = "color-theme" in localStorage ? localStorage.getItem("color-theme") : defaultTheme;
|
||||
switchTheme(colorTheme);
|
||||
|
||||
// Add click event handler to the menu items.
|
||||
themeToggleOptions.forEach((option) => {
|
||||
option.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
switchTheme(option.dataset.item);
|
||||
})
|
||||
})
|
||||
|
||||
// Add click event handler to the buttons
|
||||
themeToggleButtons.forEach((toggler) => {
|
||||
toggler.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
toggleMenu(toggler);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => themeToggleButtons.forEach(resizeMenu))
|
||||
|
||||
// Dismiss the menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.hextra-theme-toggle') === null) {
|
||||
themeToggleButtons.forEach((toggler) => {
|
||||
toggler.dataset.state = 'closed';
|
||||
toggler.nextElementSibling.classList.add('hx:hidden');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||||
if (localStorage.getItem("color-theme") === "system") {
|
||||
setTheme("system");
|
||||
}
|
||||
});
|
||||
})();
|
||||
93
themes/hextra/assets/js/core/toc-scroll.js
Normal file
93
themes/hextra/assets/js/core/toc-scroll.js
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* TOC Scroll - Highlights active TOC links based on visible headings
|
||||
*
|
||||
* Uses Intersection Observer to track heading visibility and applies
|
||||
* 'hextra-toc-active' class to corresponding TOC links. Selects the
|
||||
* topmost heading when multiple are visible.
|
||||
*
|
||||
* Requires: .hextra-toc element, matching heading IDs, toc.css styles
|
||||
*/
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const toc = document.querySelector(".hextra-toc");
|
||||
if (!toc) return;
|
||||
|
||||
const tocLinks = toc.querySelectorAll('a[href^="#"]');
|
||||
if (tocLinks.length === 0) return;
|
||||
|
||||
const headingIds = Array.from(tocLinks).map((link) => link.getAttribute("href").substring(1));
|
||||
|
||||
const headings = headingIds.map((id) => document.getElementById(decodeURIComponent(id))).filter(Boolean);
|
||||
if (headings.length === 0) return;
|
||||
|
||||
let currentActiveLink = null;
|
||||
let isHashNavigation = false;
|
||||
|
||||
// Create intersection observer
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
// Skip observer updates during hash navigation
|
||||
if (isHashNavigation) return;
|
||||
|
||||
const visibleHeadings = entries.filter((entry) => entry.isIntersecting).map((entry) => entry.target);
|
||||
|
||||
if (visibleHeadings.length === 0) return;
|
||||
|
||||
// Find the heading closest to the top of the viewport
|
||||
const topMostHeading = visibleHeadings.reduce((closest, heading) => {
|
||||
const headingTop = heading.getBoundingClientRect().top;
|
||||
const closestTop = closest.getBoundingClientRect().top;
|
||||
return Math.abs(headingTop) < Math.abs(closestTop) ? heading : closest;
|
||||
});
|
||||
|
||||
// Encode the id and make it lowercase to match the TOC link
|
||||
const targetId = encodeURIComponent(topMostHeading.id).toLowerCase();
|
||||
const targetLink = toc.querySelector(`a[href="#${targetId}"]`);
|
||||
|
||||
if (targetLink && targetLink !== currentActiveLink) {
|
||||
// Remove active class from previous link
|
||||
if (currentActiveLink) {
|
||||
currentActiveLink.classList.remove("hextra-toc-active");
|
||||
}
|
||||
|
||||
// Add active class to current link
|
||||
targetLink.classList.add("hextra-toc-active");
|
||||
currentActiveLink = targetLink;
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: "-20px 0px -60% 0px", // Adjust sensitivity
|
||||
threshold: [0, 0.1, 0.5, 1],
|
||||
}
|
||||
);
|
||||
|
||||
// Observe all headings
|
||||
headings.forEach((heading) => observer.observe(heading));
|
||||
|
||||
// Handle direct navigation to page with hash
|
||||
function handleHashNavigation() {
|
||||
const hash = window.location.hash; // already url encoded
|
||||
if (hash) {
|
||||
const targetLink = toc.querySelector(`a[href="${hash}"]`);
|
||||
if (targetLink) {
|
||||
// Disable observer temporarily during hash navigation
|
||||
isHashNavigation = true;
|
||||
|
||||
if (currentActiveLink) {
|
||||
currentActiveLink.classList.remove("hextra-toc-active");
|
||||
}
|
||||
targetLink.classList.add("hextra-toc-active");
|
||||
currentActiveLink = targetLink;
|
||||
|
||||
// Re-enable observer after scroll settles
|
||||
setTimeout(() => { isHashNavigation = false; }, 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle hash changes navigation
|
||||
window.addEventListener("hashchange", handleHashNavigation);
|
||||
|
||||
// Handle initial load
|
||||
setTimeout(handleHashNavigation, 100);
|
||||
});
|
||||
443
themes/hextra/assets/js/flexsearch.js
Normal file
443
themes/hextra/assets/js/flexsearch.js
Normal file
@@ -0,0 +1,443 @@
|
||||
// Search functionality using FlexSearch.
|
||||
|
||||
// Change shortcut key to cmd+k on Mac, iPad or iPhone.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (/iPad|iPhone|Macintosh/.test(navigator.userAgent)) {
|
||||
// select the kbd element under the .hextra-search-wrapper class
|
||||
const keys = document.querySelectorAll(".hextra-search-wrapper kbd");
|
||||
keys.forEach(key => {
|
||||
key.innerHTML = '<span class="hx:text-xs">⌘</span>K';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Render the search data as JSON.
|
||||
// {{ $searchDataFile := printf "%s.search-data.json" .Language.Lang }}
|
||||
// {{ $searchData := resources.Get "json/search-data.json" | resources.ExecuteAsTemplate $searchDataFile . }}
|
||||
// {{ if hugo.IsProduction }}
|
||||
// {{ $searchData := $searchData | minify | fingerprint }}
|
||||
// {{ end }}
|
||||
// {{ $noResultsFound := (T "noResultsFound") | default "No results found." }}
|
||||
|
||||
(function () {
|
||||
const searchDataURL = '{{ $searchData.RelPermalink }}';
|
||||
|
||||
const inputElements = document.querySelectorAll('.hextra-search-input');
|
||||
for (const el of inputElements) {
|
||||
el.addEventListener('focus', init);
|
||||
el.addEventListener('keyup', search);
|
||||
el.addEventListener('keydown', handleKeyDown);
|
||||
el.addEventListener('input', handleInputChange);
|
||||
}
|
||||
|
||||
const shortcutElements = document.querySelectorAll('.hextra-search-wrapper kbd');
|
||||
|
||||
function setShortcutElementsOpacity(opacity) {
|
||||
shortcutElements.forEach(el => {
|
||||
el.style.opacity = opacity;
|
||||
});
|
||||
}
|
||||
|
||||
function handleInputChange(e) {
|
||||
const opacity = e.target.value.length > 0 ? 0 : 100;
|
||||
setShortcutElementsOpacity(opacity);
|
||||
}
|
||||
|
||||
// Get the search wrapper, input, and results elements.
|
||||
function getActiveSearchElement() {
|
||||
const inputs = Array.from(document.querySelectorAll('.hextra-search-wrapper')).filter(el => el.clientHeight > 0);
|
||||
if (inputs.length === 1) {
|
||||
return {
|
||||
wrapper: inputs[0],
|
||||
inputElement: inputs[0].querySelector('.hextra-search-input'),
|
||||
resultsElement: inputs[0].querySelector('.hextra-search-results')
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const INPUTS = ['input', 'select', 'button', 'textarea']
|
||||
|
||||
// Focus the search input when pressing ctrl+k/cmd+k or /.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
const { inputElement } = getActiveSearchElement();
|
||||
if (!inputElement) return;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
const tagName = activeElement && activeElement.tagName;
|
||||
if (
|
||||
inputElement === activeElement ||
|
||||
!tagName ||
|
||||
INPUTS.includes(tagName) ||
|
||||
(activeElement && activeElement.isContentEditable))
|
||||
return;
|
||||
|
||||
if (
|
||||
e.key === '/' ||
|
||||
(e.key === 'k' &&
|
||||
(e.metaKey /* for Mac */ || /* for non-Mac */ e.ctrlKey))
|
||||
) {
|
||||
e.preventDefault();
|
||||
inputElement.focus();
|
||||
} else if (e.key === 'Escape' && inputElement.value) {
|
||||
inputElement.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Dismiss the search results when clicking outside the search box.
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
const { inputElement, resultsElement } = getActiveSearchElement();
|
||||
if (!inputElement || !resultsElement) return;
|
||||
if (
|
||||
e.target !== inputElement &&
|
||||
e.target !== resultsElement &&
|
||||
!resultsElement.contains(e.target)
|
||||
) {
|
||||
setShortcutElementsOpacity(100);
|
||||
hideSearchResults();
|
||||
}
|
||||
});
|
||||
|
||||
// Get the currently active result and its index.
|
||||
function getActiveResult() {
|
||||
const { resultsElement } = getActiveSearchElement();
|
||||
if (!resultsElement) return { result: undefined, index: -1 };
|
||||
|
||||
const result = resultsElement.querySelector('.hextra-search-active');
|
||||
if (!result) return { result: undefined, index: -1 };
|
||||
|
||||
const index = parseInt(result.dataset.index, 10);
|
||||
return { result, index };
|
||||
}
|
||||
|
||||
// Set the active result by index.
|
||||
function setActiveResult(index) {
|
||||
const { resultsElement } = getActiveSearchElement();
|
||||
if (!resultsElement) return;
|
||||
|
||||
const { result: activeResult } = getActiveResult();
|
||||
activeResult && activeResult.classList.remove('hextra-search-active');
|
||||
const result = resultsElement.querySelector(`[data-index="${index}"]`);
|
||||
if (result) {
|
||||
result.classList.add('hextra-search-active');
|
||||
result.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Get the number of search results from the DOM.
|
||||
function getResultsLength() {
|
||||
const { resultsElement } = getActiveSearchElement();
|
||||
if (!resultsElement) return 0;
|
||||
return resultsElement.dataset.count;
|
||||
}
|
||||
|
||||
// Finish the search by hiding the results and clearing the input.
|
||||
function finishSearch() {
|
||||
const { inputElement } = getActiveSearchElement();
|
||||
if (!inputElement) return;
|
||||
hideSearchResults();
|
||||
inputElement.value = '';
|
||||
inputElement.blur();
|
||||
}
|
||||
|
||||
function hideSearchResults() {
|
||||
const { resultsElement } = getActiveSearchElement();
|
||||
if (!resultsElement) return;
|
||||
resultsElement.classList.add('hx:hidden');
|
||||
}
|
||||
|
||||
// Handle keyboard events.
|
||||
function handleKeyDown(e) {
|
||||
const { inputElement } = getActiveSearchElement();
|
||||
if (!inputElement) return;
|
||||
|
||||
const resultsLength = getResultsLength();
|
||||
const { result: activeResult, index: activeIndex } = getActiveResult();
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (activeIndex > 0) setActiveResult(activeIndex - 1);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
if (activeIndex + 1 < resultsLength) setActiveResult(activeIndex + 1);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (activeResult) {
|
||||
activeResult.click();
|
||||
}
|
||||
finishSearch();
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
hideSearchResults();
|
||||
// Clear the input when pressing escape
|
||||
inputElement.value = '';
|
||||
inputElement.dispatchEvent(new Event('input'));
|
||||
// Remove focus from the input
|
||||
inputElement.blur();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Initializes the search.
|
||||
function init(e) {
|
||||
e.target.removeEventListener('focus', init);
|
||||
if (!(window.pageIndex && window.sectionIndex)) {
|
||||
preloadIndex();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preloads the search index by fetching data and adding it to the FlexSearch index.
|
||||
* @returns {Promise<void>} A promise that resolves when the index is preloaded.
|
||||
*/
|
||||
async function preloadIndex() {
|
||||
const tokenize = '{{- site.Params.search.flexsearch.tokenize | default "forward" -}}';
|
||||
|
||||
// https://github.com/TryGhost/Ghost/pull/21148
|
||||
const regex = new RegExp(
|
||||
`[\u{4E00}-\u{9FFF}\u{3040}-\u{309F}\u{30A0}-\u{30FF}\u{AC00}-\u{D7A3}\u{3400}-\u{4DBF}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B73F}\u{2B740}-\u{2B81F}\u{2B820}-\u{2CEAF}\u{2CEB0}-\u{2EBEF}\u{30000}-\u{3134F}\u{31350}-\u{323AF}\u{2EBF0}-\u{2EE5F}\u{F900}-\u{FAFF}\u{2F800}-\u{2FA1F}]|[0-9A-Za-zа-я\u00C0-\u017F\u0400-\u04FF\u0600-\u06FF\u0980-\u09FF\u1E00-\u1EFF\u0590-\u05FF]+`,
|
||||
'mug'
|
||||
);
|
||||
const encode = (str) => { return ('' + str).toLowerCase().match(regex) ?? []; }
|
||||
|
||||
window.pageIndex = new FlexSearch.Document({
|
||||
tokenize,
|
||||
encode,
|
||||
cache: 100,
|
||||
document: {
|
||||
id: 'id',
|
||||
store: ['title', 'crumb'],
|
||||
index: "content"
|
||||
}
|
||||
});
|
||||
|
||||
window.sectionIndex = new FlexSearch.Document({
|
||||
tokenize,
|
||||
encode,
|
||||
cache: 100,
|
||||
document: {
|
||||
id: 'id',
|
||||
store: ['title', 'content', 'url', 'display', 'crumb'],
|
||||
index: "content",
|
||||
tag: [{
|
||||
field: "pageId"
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
const resp = await fetch(searchDataURL);
|
||||
const data = await resp.json();
|
||||
let pageId = 0;
|
||||
for (const route in data) {
|
||||
let pageContent = '';
|
||||
++pageId;
|
||||
const urlParts = route.split('/').filter(x => x != "" && !x.startsWith('#'));
|
||||
|
||||
let crumb = '';
|
||||
let searchUrl = '/';
|
||||
for (let i = 0; i < urlParts.length; i++) {
|
||||
const urlPart = urlParts[i];
|
||||
searchUrl += urlPart + '/'
|
||||
|
||||
const crumbData = data[searchUrl];
|
||||
if (!crumbData) {
|
||||
console.warn('Excluded page', searchUrl, '- will not be included for search result breadcrumb for', route);
|
||||
continue;
|
||||
}
|
||||
|
||||
let title = data[searchUrl].title;
|
||||
if (title == "_index") {
|
||||
title = urlPart.split("-").map(x => x).join(" ");
|
||||
}
|
||||
crumb += title;
|
||||
|
||||
if (i < urlParts.length - 1) {
|
||||
crumb += ' > ';
|
||||
}
|
||||
}
|
||||
|
||||
for (const heading in data[route].data) {
|
||||
const [hash, text] = heading.split('#');
|
||||
const url = route.trimEnd('/') + (hash ? '#' + hash : '');
|
||||
const title = text || data[route].title;
|
||||
|
||||
const content = data[route].data[heading] || '';
|
||||
const paragraphs = content.split('\n').filter(Boolean);
|
||||
|
||||
sectionIndex.add({
|
||||
id: url,
|
||||
url,
|
||||
title,
|
||||
crumb,
|
||||
pageId: `page_${pageId}`,
|
||||
content: title,
|
||||
...(paragraphs[0] && { display: paragraphs[0] })
|
||||
});
|
||||
|
||||
for (let i = 0; i < paragraphs.length; i++) {
|
||||
sectionIndex.add({
|
||||
id: `${url}_${i}`,
|
||||
url,
|
||||
title,
|
||||
crumb,
|
||||
pageId: `page_${pageId}`,
|
||||
content: paragraphs[i]
|
||||
});
|
||||
}
|
||||
|
||||
pageContent += ` ${title} ${content}`;
|
||||
}
|
||||
|
||||
window.pageIndex.add({
|
||||
id: pageId,
|
||||
title: data[route].title,
|
||||
crumb,
|
||||
content: pageContent
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a search based on the provided query and displays the results.
|
||||
* @param {Event} e - The event object.
|
||||
*/
|
||||
function search(e) {
|
||||
const query = e.target.value;
|
||||
if (!e.target.value) {
|
||||
hideSearchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
const { resultsElement } = getActiveSearchElement();
|
||||
while (resultsElement.firstChild) {
|
||||
resultsElement.removeChild(resultsElement.firstChild);
|
||||
}
|
||||
resultsElement.classList.remove('hx:hidden');
|
||||
|
||||
// Configurable search limits with sensible defaults
|
||||
const maxPageResults = parseInt('{{- site.Params.search.flexsearch.maxPageResults | default 20 -}}', 10);
|
||||
const maxSectionResults = parseInt('{{- site.Params.search.flexsearch.maxSectionResults | default 10 -}}', 10);
|
||||
const pageResults = window.pageIndex.search(query, maxPageResults, { enrich: true, suggest: true })[0]?.result || [];
|
||||
|
||||
const results = [];
|
||||
const pageTitleMatches = {};
|
||||
|
||||
for (let i = 0; i < pageResults.length; i++) {
|
||||
const result = pageResults[i];
|
||||
pageTitleMatches[i] = 0;
|
||||
|
||||
const sectionResults = window.sectionIndex.search(query,
|
||||
{ enrich: true, suggest: true, tag: { 'pageId': `page_${result.id}` } })[0]?.result || [];
|
||||
let isFirstItemOfPage = true
|
||||
const occurred = {}
|
||||
|
||||
const nResults = Math.min(sectionResults.length, maxSectionResults);
|
||||
for (let j = 0; j < nResults; j++) {
|
||||
const { doc } = sectionResults[j]
|
||||
const isMatchingTitle = doc.display !== undefined
|
||||
if (isMatchingTitle) {
|
||||
pageTitleMatches[i]++
|
||||
}
|
||||
const { url, title } = doc
|
||||
const content = doc.display || doc.content
|
||||
|
||||
if (occurred[url + '@' + content]) continue
|
||||
occurred[url + '@' + content] = true
|
||||
results.push({
|
||||
_page_rk: i,
|
||||
_section_rk: j,
|
||||
route: url,
|
||||
prefix: isFirstItemOfPage ? result.doc.crumb : undefined,
|
||||
children: { title, content }
|
||||
})
|
||||
isFirstItemOfPage = false
|
||||
}
|
||||
}
|
||||
const sortedResults = results
|
||||
.sort((a, b) => {
|
||||
// Sort by number of matches in the title.
|
||||
if (a._page_rk === b._page_rk) {
|
||||
return a._section_rk - b._section_rk
|
||||
}
|
||||
if (pageTitleMatches[a._page_rk] !== pageTitleMatches[b._page_rk]) {
|
||||
return pageTitleMatches[b._page_rk] - pageTitleMatches[a._page_rk]
|
||||
}
|
||||
return a._page_rk - b._page_rk
|
||||
})
|
||||
.map(res => ({
|
||||
id: `${res._page_rk}_${res._section_rk}`,
|
||||
route: res.route,
|
||||
prefix: res.prefix,
|
||||
children: res.children
|
||||
}));
|
||||
displayResults(sortedResults, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the search results on the page.
|
||||
*
|
||||
* @param {Array} results - The array of search results.
|
||||
* @param {string} query - The search query.
|
||||
*/
|
||||
function displayResults(results, query) {
|
||||
const { resultsElement } = getActiveSearchElement();
|
||||
if (!resultsElement) return;
|
||||
|
||||
if (!results.length) {
|
||||
resultsElement.innerHTML = `<span class="hextra-search-no-result">{{ $noResultsFound | safeHTML }}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Highlight the query in the result text.
|
||||
function highlightMatches(text, query) {
|
||||
const escapedQuery = query.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||
const regex = new RegExp(escapedQuery, 'gi');
|
||||
return text.replace(regex, (match) => `<span class="hextra-search-match">${match}</span>`);
|
||||
}
|
||||
|
||||
// Create a DOM element from the HTML string.
|
||||
function createElement(str) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = str.trim();
|
||||
return div.firstChild;
|
||||
}
|
||||
|
||||
function handleMouseMove(e) {
|
||||
const target = e.target.closest('a');
|
||||
if (target) {
|
||||
const active = resultsElement.querySelector('a.hextra-search-active');
|
||||
if (active) {
|
||||
active.classList.remove('hextra-search-active');
|
||||
}
|
||||
target.classList.add('hextra-search-active');
|
||||
}
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
if (result.prefix) {
|
||||
fragment.appendChild(createElement(`
|
||||
<div class="hextra-search-prefix">${result.prefix}</div>`));
|
||||
}
|
||||
let li = createElement(`
|
||||
<li>
|
||||
<a data-index="${i}" href="${result.route}" class=${i === 0 ? "hextra-search-active" : ""}>
|
||||
<div class="hextra-search-title">`+ highlightMatches(result.children.title, query) + `</div>` +
|
||||
(result.children.content ?
|
||||
`<div class="hextra-search-excerpt">` + highlightMatches(result.children.content, query) + `</div>` : '') + `
|
||||
</a>
|
||||
</li>`);
|
||||
li.addEventListener('mousemove', handleMouseMove);
|
||||
li.addEventListener('keydown', handleKeyDown);
|
||||
li.querySelector('a').addEventListener('click', finishSearch);
|
||||
fragment.appendChild(li);
|
||||
}
|
||||
resultsElement.appendChild(fragment);
|
||||
resultsElement.dataset.count = results.length;
|
||||
}
|
||||
})();
|
||||
6
themes/hextra/assets/js/head/banner.js
Normal file
6
themes/hextra/assets/js/head/banner.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// The section must not be in the banner.js (body) file because it can create a quick flash.
|
||||
|
||||
if (localStorage.getItem('{{ site.Params.banner.key | default `banner-closed` }}')) {
|
||||
document.documentElement.style.setProperty("--hextra-banner-height", "0px");
|
||||
document.documentElement.classList.add("hextra-banner-hidden");
|
||||
}
|
||||
14
themes/hextra/assets/js/head/theme.js
Normal file
14
themes/hextra/assets/js/head/theme.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// The section must not be in the theme.js (body) file because it can create a quick flash (switch between light and dark).
|
||||
|
||||
function setTheme(theme) {
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
|
||||
if (theme !== "light" && theme !== "dark") {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
document.documentElement.classList.add(theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
setTheme("color-theme" in localStorage ? localStorage.getItem("color-theme") : '{{ site.Params.theme.default | default `system`}}')
|
||||
Reference in New Issue
Block a user