Files
rclone/docs/static/js/rclone.js
Nick Craig-Wood 839a3b92e0 docs: modernize rclone.org site design
- replace Bootstrap/jQuery with purpose-built CSS and JS
- remove backend icons from navbar and content pages
- replace remaining FontAwesome icons with inline SVGs, remove FontAwesome
- modernize CSS styling for menus, typography, cards, tables, and code blocks
- add copy-to-clipboard buttons on code blocks using SVG icon
- move TOC to left sidebar with responsive overlay drawer
- add sticky header, top scrollbar and first column for wide tables
- add left/right arrow buttons to scrollable tables
- hide homepage logo on mobile
- make wide menus with filter for Commands and Storage Systems
- add dark mode support based on browser preference
- fix CSS/JS cache busting to use build time
2026-04-02 16:32:54 +01:00

536 lines
22 KiB
JavaScript

// rclone.js — replaces bootstrap.js + jquery + popper + custom.js
(function() {
"use strict";
// ===== Navbar collapse toggle =====
document.addEventListener("click", function(e) {
var toggler = e.target.closest(".navbar-toggler");
if (!toggler) return;
var targetId = toggler.getAttribute("data-target");
if (!targetId) return;
var target = document.querySelector(targetId);
if (target) target.classList.toggle("show");
});
// ===== Dropdown toggle =====
document.addEventListener("click", function(e) {
var toggle = e.target.closest("[data-toggle='dropdown']");
// Close all open dropdowns first
var openMenus = document.querySelectorAll(".dropdown-menu.show");
for (var i = 0; i < openMenus.length; i++) {
// Don't close the menu we're about to toggle
if (toggle && openMenus[i].parentNode.contains(toggle)) continue;
openMenus[i].classList.remove("show");
}
if (!toggle) return;
e.preventDefault();
var menu = toggle.nextElementSibling;
if (menu && menu.classList.contains("dropdown-menu")) {
menu.classList.toggle("show");
}
});
// ===== Mega menu: mobile headers + filter input =====
var preScrollMenus = document.querySelectorAll(".dropdown-menu.pre-scrollable");
for (var m = 0; m < preScrollMenus.length; m++) {
(function(menu) {
var toggleBtn = menu.previousElementSibling;
if (!toggleBtn) return;
var label = toggleBtn.textContent.trim().replace(/\s*▾$/, "");
// Mobile close header
var header = document.createElement("div");
header.className = "dropdown-mobile-header";
header.innerHTML = '<span>' + label + '</span><span class="dropdown-mobile-close">&times;</span>';
menu.insertBefore(header, menu.firstChild);
header.addEventListener("click", function() {
menu.classList.remove("show");
});
// Filter input (desktop + mobile)
var filterWrap = document.createElement("div");
filterWrap.className = "dropdown-filter-wrap";
var filterInput = document.createElement("input");
filterInput.className = "dropdown-filter-input";
filterInput.type = "text";
filterInput.placeholder = "Filter\u2026";
filterInput.setAttribute("autocomplete", "off");
filterWrap.appendChild(filterInput);
// Insert after mobile header
header.insertAdjacentElement("afterend", filterWrap);
// Prevent clicks on the filter from closing the dropdown
filterWrap.addEventListener("click", function(e) { e.stopPropagation(); });
// Filtering logic
var items = menu.querySelectorAll(".dropdown-item");
var headings = menu.querySelectorAll(".dropdown-letter-heading");
var dividers = menu.querySelectorAll(".dropdown-divider");
filterInput.addEventListener("input", function() {
var query = filterInput.value.toLowerCase();
// Show/hide items
for (var i = 0; i < items.length; i++) {
if (!query || items[i].textContent.toLowerCase().indexOf(query) !== -1) {
items[i].classList.remove("filter-hidden");
} else {
items[i].classList.add("filter-hidden");
}
}
// Show/hide letter headings: hide if all following items until next heading/divider are hidden
for (var h = 0; h < headings.length; h++) {
var hasVisible = false;
var sibling = headings[h].nextElementSibling;
while (sibling && !sibling.classList.contains("dropdown-letter-heading") && !sibling.classList.contains("dropdown-divider")) {
if (sibling.classList.contains("dropdown-item") && !sibling.classList.contains("filter-hidden")) {
hasVisible = true;
break;
}
sibling = sibling.nextElementSibling;
}
if (hasVisible || !query) {
headings[h].classList.remove("filter-hidden");
} else {
headings[h].classList.add("filter-hidden");
}
}
// Hide dividers if filtering is active
for (var d = 0; d < dividers.length; d++) {
if (query) {
dividers[d].classList.add("filter-hidden");
} else {
dividers[d].classList.remove("filter-hidden");
}
}
});
// Enter: if exactly one visible result, navigate to it
filterInput.addEventListener("keydown", function(e) {
if (e.key === "Enter") {
var visible = [];
for (var i = 0; i < items.length; i++) {
if (!items[i].classList.contains("filter-hidden")) visible.push(items[i]);
}
if (visible.length === 1 && visible[0].href) {
window.location.href = visible[0].href;
}
}
});
// Escape: clear filter, or close dropdown if already clear
filterInput.addEventListener("keydown", function(e) {
if (e.key === "Escape") {
if (filterInput.value) {
filterInput.value = "";
filterInput.dispatchEvent(new Event("input"));
e.stopPropagation();
} else {
menu.classList.remove("show");
}
}
});
// Store reference for auto-focus
menu._filterInput = filterInput;
})(preScrollMenus[m]);
}
// Auto-focus filter input when a pre-scrollable dropdown opens, clear on close
document.addEventListener("click", function(e) {
var toggle = e.target.closest("[data-toggle='dropdown']");
if (!toggle) return;
var menu = toggle.nextElementSibling;
if (!menu || !menu.classList.contains("pre-scrollable") || !menu._filterInput) return;
// Defer to next frame so the .show class has been toggled
setTimeout(function() {
if (menu.classList.contains("show")) {
menu._filterInput.value = "";
menu._filterInput.dispatchEvent(new Event("input"));
menu._filterInput.focus();
}
}, 0);
});
// ===== Header hover links =====
var headings = document.querySelectorAll("h2, h3, h4, h5, h6");
for (var j = 0; j < headings.length; j++) {
var el = headings[j];
var id = el.getAttribute("id");
if (id) {
var a = document.createElement("a");
a.className = "header-link";
a.href = "#" + id;
a.innerHTML = '<svg class="icon"><use href="#icon-link"/></svg>';
el.insertBefore(a, el.firstChild);
}
}
// ===== TOC expand / collapse =====
var colToc = document.querySelector(".col-toc:not(.col-toc-empty)");
var tocSidebar = colToc ? colToc.querySelector(".toc-sidebar") : null;
if (colToc) {
var tocToggle = colToc.querySelector(".toc-toggle");
// Toggle button opens/closes the overlay panel
if (tocToggle) {
tocToggle.addEventListener("click", function(e) {
e.stopPropagation();
colToc.classList.toggle("toc-open");
});
}
// Clicking a TOC link closes the overlay
if (tocSidebar) {
tocSidebar.addEventListener("click", function(e) {
if (e.target.closest("a")) {
colToc.classList.remove("toc-open");
}
});
}
// Clicking outside the panel closes it
document.addEventListener("click", function(e) {
if (!colToc.classList.contains("toc-open")) return;
if (e.target.closest(".toc-sidebar") || e.target.closest(".toc-toggle")) return;
colToc.classList.remove("toc-open");
});
}
// ===== TOC active section highlighting =====
if (tocSidebar) {
var tocLinks = tocSidebar.querySelectorAll("nav#TableOfContents a");
// Tooltip for truncated TOC links
var tocTooltip = document.createElement("div");
tocTooltip.className = "toc-tooltip";
tocSidebar.appendChild(tocTooltip);
for (var i = 0; i < tocLinks.length; i++) {
(function(link) {
link.addEventListener("mouseenter", function() {
if (link.scrollWidth <= link.clientWidth) return;
tocTooltip.textContent = link.textContent;
var linkRect = link.getBoundingClientRect();
var sidebarRect = tocSidebar.getBoundingClientRect();
tocTooltip.style.top = (linkRect.bottom - sidebarRect.top + tocSidebar.scrollTop) + "px";
tocTooltip.classList.add("visible");
});
link.addEventListener("mouseleave", function() {
tocTooltip.classList.remove("visible");
});
})(tocLinks[i]);
}
if (tocLinks.length > 0) {
// Build a map from heading id to TOC link
var tocMap = {};
var observedHeadings = [];
for (var t = 0; t < tocLinks.length; t++) {
var href = tocLinks[t].getAttribute("href");
if (href && href.charAt(0) === "#") {
var targetId = href.substring(1);
var heading = document.getElementById(targetId);
if (heading) {
tocMap[targetId] = tocLinks[t];
observedHeadings.push(heading);
}
}
}
if (observedHeadings.length > 0) {
var currentActive = null;
var suppressObserver = false;
function activateTocLink(link) {
if (currentActive) currentActive.classList.remove("toc-active");
currentActive = link;
currentActive.classList.add("toc-active");
// Keep the active link visible in the sidebar (nearest, not centered)
var sidebarRect = tocSidebar.getBoundingClientRect();
var linkRect = link.getBoundingClientRect();
if (linkRect.top < sidebarRect.top) {
tocSidebar.scrollBy({ top: linkRect.top - sidebarRect.top - 10, behavior: "smooth" });
} else if (linkRect.bottom > sidebarRect.bottom) {
tocSidebar.scrollBy({ top: linkRect.bottom - sidebarRect.bottom + 10, behavior: "smooth" });
}
}
function centerTocLink(link) {
// Centre the link vertically in the sidebar
// (don't use scrollIntoView — it scrolls the whole page)
var sidebarRect = tocSidebar.getBoundingClientRect();
var linkRect = link.getBoundingClientRect();
var offset = linkRect.top - sidebarRect.top - (sidebarRect.height / 2) + (linkRect.height / 2);
tocSidebar.scrollBy({ top: offset, behavior: "smooth" });
}
// Suppress observer during smooth scroll (click or hash navigation)
function suppressAndActivate(link) {
suppressObserver = true;
activateTocLink(link);
// Re-enable after scroll completes
if ("onscrollend" in window) {
document.addEventListener("scrollend", function() {
suppressObserver = false;
}, { once: true });
} else {
setTimeout(function() { suppressObserver = false; }, 1500);
}
}
var observer = new IntersectionObserver(function(entries) {
if (suppressObserver) return;
// Find the topmost visible heading
var topEntry = null;
for (var i = 0; i < entries.length; i++) {
if (entries[i].isIntersecting) {
if (!topEntry || entries[i].boundingClientRect.top < topEntry.boundingClientRect.top) {
topEntry = entries[i];
}
}
}
if (topEntry) {
var id = topEntry.target.getAttribute("id");
if (id && tocMap[id]) {
activateTocLink(tocMap[id]);
}
}
}, { rootMargin: "0px 0px -80% 0px", threshold: 0 });
// Clicking a TOC link: suppress observer and highlight immediately
tocSidebar.addEventListener("click", function(e) {
var link = e.target.closest("a");
if (!link) return;
var href = link.getAttribute("href");
if (href && href.charAt(0) === "#") {
var id = decodeURIComponent(href.substring(1));
if (tocMap[id]) {
suppressAndActivate(tocMap[id]);
}
}
});
// On page load with a #hash, activate and centre immediately
if (window.location.hash) {
var hashId = decodeURIComponent(window.location.hash.substring(1));
if (tocMap[hashId]) {
suppressAndActivate(tocMap[hashId]);
centerTocLink(tocMap[hashId]);
}
}
// Start observer
for (var h = 0; h < observedHeadings.length; h++) {
observer.observe(observedHeadings[h]);
}
}
}
}
// ===== Scrollable tables with sticky header + top scrollbar =====
var tables = document.querySelectorAll(".col-content table");
for (var ti = 0; ti < tables.length; ti++) {
(function(table) {
var parent = table.parentNode;
// Only wrap tables that overflow their container
if (table.scrollWidth <= parent.clientWidth) return;
// Must have a thead to split
var thead = table.querySelector("thead");
if (!thead) return;
// Measure column widths from the rendered table
var firstRow = thead.querySelector("tr");
if (!firstRow) return;
var cells = firstRow.children;
var widths = [];
for (var c = 0; c < cells.length; c++) {
widths.push(cells[c].getBoundingClientRect().width);
}
var tableWidth = table.scrollWidth;
// Helper: create a colgroup with explicit widths
function makeColgroup() {
var cg = document.createElement("colgroup");
for (var i = 0; i < widths.length; i++) {
var col = document.createElement("col");
col.style.width = widths[i] + "px";
cg.appendChild(col);
}
return cg;
}
// Clone table for the sticky header (thead only)
var headerTable = table.cloneNode(true);
var cloneBody = headerTable.querySelector("tbody");
if (cloneBody) cloneBody.remove();
headerTable.insertBefore(makeColgroup(), headerTable.firstChild);
headerTable.style.width = tableWidth + "px";
headerTable.style.minWidth = tableWidth + "px";
// Lock column widths on the original table
table.insertBefore(makeColgroup(), table.firstChild);
table.style.width = tableWidth + "px";
table.style.minWidth = tableWidth + "px";
// Build: .table-scroll-wrap > (.table-scroll-header > inner > headerTable)
// + (.table-scroll-body > table)
var wrap = document.createElement("div");
wrap.className = "table-scroll-wrap";
var headerWrap = document.createElement("div");
headerWrap.className = "table-scroll-header";
var headerInner = document.createElement("div");
headerInner.className = "table-scroll-header-inner";
headerInner.appendChild(headerTable);
headerWrap.appendChild(headerInner);
var bodyWrap = document.createElement("div");
bodyWrap.className = "table-scroll-body";
// Scroll arrow buttons
var arrowsDiv = document.createElement("div");
arrowsDiv.className = "table-scroll-arrows";
var leftBtn = document.createElement("button");
leftBtn.className = "table-scroll-arrow";
leftBtn.setAttribute("aria-label", "Scroll table left");
leftBtn.innerHTML = '<svg aria-hidden="true"><use href="#icon-chevron-left"/></svg>';
var rightBtn = document.createElement("button");
rightBtn.className = "table-scroll-arrow";
rightBtn.setAttribute("aria-label", "Scroll table right");
rightBtn.innerHTML = '<svg aria-hidden="true"><use href="#icon-chevron-right"/></svg>';
arrowsDiv.appendChild(leftBtn);
arrowsDiv.appendChild(rightBtn);
parent.insertBefore(wrap, table);
bodyWrap.appendChild(table);
wrap.appendChild(arrowsDiv);
wrap.appendChild(headerWrap);
wrap.appendChild(bodyWrap);
// Update arrow visibility based on scroll position
function updateArrows() {
var scrollLeft = bodyWrap.scrollLeft;
var maxScroll = bodyWrap.scrollWidth - bodyWrap.clientWidth;
if (scrollLeft > 0) {
leftBtn.classList.add("visible");
} else {
leftBtn.classList.remove("visible");
}
if (scrollLeft < maxScroll - 1) {
rightBtn.classList.add("visible");
} else {
rightBtn.classList.remove("visible");
}
}
updateArrows();
// Build column edge positions, skipping the sticky first column
var colEdges = [];
var edge = 0;
for (var ci = 0; ci < widths.length; ci++) {
edge += widths[ci];
if (ci > 0) colEdges.push(edge);
}
function scrollTo(target) {
bodyWrap.scrollLeft = target;
headerInner.scrollLeft = target;
updateArrows();
}
leftBtn.addEventListener("click", function() {
var pos = bodyWrap.scrollLeft;
for (var ci = colEdges.length - 1; ci >= 0; ci--) {
if (colEdges[ci] < pos - 1) {
scrollTo(colEdges[ci]);
return;
}
}
scrollTo(0);
});
rightBtn.addEventListener("click", function() {
var pos = bodyWrap.scrollLeft;
for (var ci = 0; ci < colEdges.length; ci++) {
if (colEdges[ci] > pos + 1) {
scrollTo(colEdges[ci]);
return;
}
}
});
// Sync horizontal scroll between header and body, update arrows
var syncing = false;
headerInner.addEventListener("scroll", function() {
if (syncing) return;
syncing = true;
bodyWrap.scrollLeft = headerInner.scrollLeft;
updateArrows();
syncing = false;
});
bodyWrap.addEventListener("scroll", function() {
if (syncing) return;
syncing = true;
headerInner.scrollLeft = bodyWrap.scrollLeft;
updateArrows();
syncing = false;
});
})(tables[ti]);
}
// ===== Copy to clipboard =====
var copyIcon = '<svg class="icon" aria-hidden="true"><use href="#icon-copy"/></svg>';
// Inject copy button into every pre block (outside pre, in a wrapper)
var pres = document.querySelectorAll("pre");
for (var p = 0; p < pres.length; p++) {
var wrap = document.createElement("div");
wrap.className = "pre-wrap";
pres[p].parentNode.insertBefore(wrap, pres[p]);
wrap.appendChild(pres[p]);
var btn = document.createElement("button");
btn.className = "copy-btn";
btn.innerHTML = copyIcon;
btn.setAttribute("type", "button");
wrap.appendChild(btn);
}
// Single delegated handler for all copy buttons (.copy-btn)
document.addEventListener("click", function(e) {
var btn = e.target.closest(".copy-btn");
if (!btn) return;
// Determine what to copy
var text;
var pre = btn.closest("pre");
if (pre) {
// Code block: copy the code element text (excludes button)
var code = pre.querySelector("code");
text = (code || pre).textContent;
} else {
// Input field: copy value of the preceding input
var input = btn.previousElementSibling;
if (input && input.value) text = input.value;
}
if (!text) return;
navigator.clipboard.writeText(text).then(function() {
btn.classList.add("copied");
setTimeout(function() {
btn.classList.remove("copied");
}, 2000);
});
});
})();
// ===== Google site search =====
function on_search() {
document.search_form.q.value = document.search_form.words.value + " -site:forum.rclone.org";
return true;
}