diff --git a/CHANGES.rst b/CHANGES.rst index 6408e046..0385a01a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,6 +6,8 @@ CHANGES Unreleased ---------- - Navigation: Added references to CrateDB MCP and CrateDB Toolkit +- Silky smooth navigation in TOC - gone are the flickering. +- Above enables animated expand/collapse icons. 2026/02/12 0.49.1 ----------------- diff --git a/package.json b/package.json index ba112001..a39b7c0b 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,9 @@ "loader-utils": "^1.4.2", "markdown-it": "^13.0.1", "minimist": "^1.2.7" + }, + "dependencies": { + "swup": "^4.8.2", + "swup-morph-plugin": "^1.3.0" } } diff --git a/src/crate/theme/rtd/crate/static/css/crateio-rtd.css b/src/crate/theme/rtd/crate/static/css/crateio-rtd.css index 7ea7e656..f7b5006a 100644 --- a/src/crate/theme/rtd/crate/static/css/crateio-rtd.css +++ b/src/crate/theme/rtd/crate/static/css/crateio-rtd.css @@ -121,6 +121,8 @@ div.hero.danger { color: var(--color-sidebar-left-link); padding: 4px 0px; display: block; + /* Fix: reserve bold-width space so font-weight changes don't shift layout */ + min-height: calc(4px * 2 + 14px * 1.2); } @media all and (max-width: 540px) { @@ -136,10 +138,7 @@ div.hero.danger { font-weight: 600; } -.bs-docs-sidenav .toctree ul > li.current > a { - margin-bottom: 3px !important; - display: block; -} +/* margin-bottom removed: it caused extra space under the selected TOC entry. */ @media all and (min-width: 768px) { .bs-docs-sidenav ol, diff --git a/src/crate/theme/rtd/crate/static/css/furo-collapsible-toc.scss b/src/crate/theme/rtd/crate/static/css/furo-collapsible-toc.scss index 468190a6..0e075031 100644 --- a/src/crate/theme/rtd/crate/static/css/furo-collapsible-toc.scss +++ b/src/crate/theme/rtd/crate/static/css/furo-collapsible-toc.scss @@ -20,6 +20,16 @@ ul.toctree.nav p.caption { display: inline-block; } +/* + * Animate the expand/collapse icon rotation when a section is toggled. + * Gated on .toc-animations-ready so the transition does not fire during the + * initial page-load state restoration (where JS sets checkbox.checked + * synchronously before the first paint). + */ +.toc-animations-ready .toctree-checkbox ~ label .icon svg { + transition: transform 200ms ease; +} + .bs-docs-sidenav.sidebar-tree { /* Match label height to our link height */ li.has-children > label { diff --git a/src/crate/theme/rtd/crate/static/js/custom.js b/src/crate/theme/rtd/crate/static/js/custom.js index 1458daf1..ac2e3448 100644 --- a/src/crate/theme/rtd/crate/static/js/custom.js +++ b/src/crate/theme/rtd/crate/static/js/custom.js @@ -1,11 +1,75 @@ /** - * This JS file is for additional new JS built over the existing theme - * ...so as to avoid breaking anything unexpectedly + * Custom theme JavaScript. + * + * Handles: + * - Sidebar TOC scroll preservation across Swup navigations and full-page reloads + * - Dark/light theme switching (persisted in localStorage) + * - TOC expand/collapse behaviour + * - Swup initialization */ -var Cookies = require('js-cookie'); +import { initSwup } from './swup'; + +const SIDEBAR_SCROLL_KEY = 'crate-sidebar-scroll'; + +/** + * Save sidebar scroll position to sessionStorage before page unload. + * This enables scroll preservation across full-page reloads (cross-section navigation). + */ +function saveSidebarScrollBeforeUnload() { + const sidebarSticky = document.querySelector('.sidebar-sticky'); + if (sidebarSticky && sidebarSticky.scrollTop > 0) { + try { + sessionStorage.setItem(SIDEBAR_SCROLL_KEY, String(sidebarSticky.scrollTop)); + } catch { + // sessionStorage might not be available + } + } +} + +/** + * Restore sidebar scroll position from sessionStorage after page load. + * Called after DOMContentLoaded to ensure the sidebar is rendered. + */ +function restoreSidebarScrollFromSessionStorage() { + try { + const savedScroll = sessionStorage.getItem(SIDEBAR_SCROLL_KEY); + if (!savedScroll) { + return; + } + const scrollValue = parseInt(savedScroll, 10); + if (isNaN(scrollValue) || scrollValue <= 0) { + return; + } + + // Clear once used (one-shot restore) + sessionStorage.removeItem(SIDEBAR_SCROLL_KEY); + + const sidebarSticky = document.querySelector('.sidebar-sticky'); + if (sidebarSticky) { + sidebarSticky.scrollTop = scrollValue; + } + } catch { + // sessionStorage might not be available + } +} document.addEventListener('DOMContentLoaded', () => { + // Restore sidebar scroll before any other initialization + restoreSidebarScrollFromSessionStorage(); + + // Save sidebar scroll position before navigating away (handles cross-section full reloads) + window.addEventListener('beforeunload', saveSidebarScrollBeforeUnload); + + // Initialize Swup for client-side navigation + // Progressive enhancement: if initialization throws, keep standard + // full-page navigation and continue running other page scripts. + try { + window.swup = initSwup(); + } catch (error) { + console.warn('Swup initialization failed; falling back to full-page navigation:', error); + } + // Function to set the theme function setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); @@ -37,95 +101,30 @@ document.addEventListener('DOMContentLoaded', () => { // Initialize theme on page load initTheme(); - // - // Preserve navigation state (left navbar) across page loads. - // - function saveNavState() { - const checkboxes = document.querySelectorAll('.toctree-checkbox'); - const states = {}; - checkboxes.forEach((checkbox) => { - if (checkbox.id) { - states[checkbox.id] = checkbox.checked; - } - }); - try { - localStorage.setItem('navState', JSON.stringify(states)); - } catch (e) { - // Could be QuotaExceededError or other storage error - console.warn('Could not save navigation state to localStorage:', e); - } - } - - function restoreNavState() { - const savedStates = localStorage.getItem('navState'); - if (savedStates) { - let states; - try { - states = JSON.parse(savedStates); - } catch (e) { - // If parsing fails, clear the corrupted data and do not restore state - localStorage.removeItem('navState'); - return; - } - Object.keys(states).forEach((id) => { - const checkbox = document.getElementById(id); - if (checkbox) { - checkbox.checked = states[id]; - } - }); - } - } - - // Restore state on page load - restoreNavState(); - - let stateChanged = false; - // Ensure the path to the current page is always expanded in the TOC. - // When opening a page directly via URL (not by navigating within the - // site), restoreNavState() may set checkboxes to a previously saved - // state that doesn't include the current page's ancestors as expanded. - // Furo marks these ancestors with the "current" class, so we force - // their checkboxes open. + // This matters both for direct URL loads and non-swup fallbacks. + document.querySelectorAll('.sidebar-tree li.current > .toctree-checkbox').forEach((checkbox) => { - if (!checkbox.checked) { - checkbox.checked = true; - stateChanged = true; - } + checkbox.checked = true; }); // Auto-expand sections marked with data-auto-expand="true" // Used for Database Drivers when viewing a driver project. - // Only auto-expand if user hasn't explicitly set a preference for this checkbox. - const savedStates = localStorage.getItem('navState'); - let userPreferences = {}; - if (savedStates) { - try { - userPreferences = JSON.parse(savedStates); - } catch (e) { - // Ignore parse errors, treat as no preferences - } - } document.querySelectorAll('[data-auto-expand="true"]').forEach((li) => { const checkbox = li.querySelector('.toctree-checkbox'); - if (checkbox && checkbox.id) { - // Only auto-expand if user has no saved preference for this checkbox - if (!(checkbox.id in userPreferences)) { - checkbox.checked = true; - stateChanged = true; - } + if (checkbox) { + checkbox.checked = true; } }); - // Save state if initialization expanded any sections - if (stateChanged) { - saveNavState(); - } - - // Save state when checkboxes change - document.querySelectorAll('.toctree-checkbox').forEach((checkbox) => { - checkbox.addEventListener('change', saveNavState); + // Enable icon rotation animations after initial state is restored. + // Using double-rAF to ensure the browser has painted the initial state + // before we add the transition, avoiding an animated jump on page load. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + document.querySelector('.sidebar-tree')?.classList.add('toc-animations-ready'); + }); }); // Make clicking the link text expand the section and collapse siblings. @@ -151,7 +150,6 @@ document.addEventListener('DOMContentLoaded', () => { checkbox.checked = true; } - saveNavState(); }); }); @@ -175,10 +173,28 @@ document.addEventListener('DOMContentLoaded', () => { }); }); - // Mark Overview as current on root page. The overview content lives in - // index.md but Sphinx only marks toctree entries as "current" when the - // pagename matches. Since the Overview href is rewritten to "#" (self) - // on the root page, we detect that and add the current class. + // Handle clicks on the "current" page link (marked with .current-active class). + // Uses event delegation so it keeps working after Swup navigations update + // the sidebar's current-active class to a different element. + // - href="#": prevent browser's default fragment scroll; re-navigate via Swup instead + // - Any other href: let Swup (or the browser) handle it normally + document.addEventListener('click', (e) => { + const a = e.target.closest('a.current-active'); + if (!a) { + return; + } + if (a.getAttribute('href') === '#') { + e.preventDefault(); + if (window.swup) { + window.swup.navigate(window.location.pathname); + } + } + }, false); + + // Mark current-state classes for Overview-like entries in sidebar tree + // The overview content lives in index.md but Sphinx only marks toctree entries + // as "current" when the pagename matches. Since the Overview href is rewritten + // to "#" (self) on the root page, we detect that and add the current class. document.querySelectorAll('.sidebar-tree .toctree-l1 > a[href="#"]').forEach((a) => { a.classList.add('current'); a.closest('li').classList.add('current'); diff --git a/src/crate/theme/rtd/crate/static/js/swup.js b/src/crate/theme/rtd/crate/static/js/swup.js new file mode 100644 index 00000000..853b62df --- /dev/null +++ b/src/crate/theme/rtd/crate/static/js/swup.js @@ -0,0 +1,366 @@ +/** + * Swup Integration Module + * + * Implements client-side page transitions using Swup to avoid full page + * reloads within a documentation section. Only the main content area + * (main.sb-main) is replaced; the sidebar lives outside it and is + * preserved naturally, keeping the user's scroll position intact. + * + * Cross-section navigation falls back to a full page reload; + * sidebar scroll is bridged across that reload via sessionStorage + * (see custom.js). + * + * See: + * - Swup: https://swup.js.org/ + * - Morph Plugin: https://swup.js.org/plugins/morph-plugin + */ + +import Swup from 'swup'; +import SwupMorphPlugin from 'swup-morph-plugin'; + +const SIDEBAR_SELECTOR = '.sidebar-tree'; +const MAIN_CONTAINER_SELECTOR = 'main.sb-main'; +const SWUP_LINK_SELECTOR = `${MAIN_CONTAINER_SELECTOR} a[href], ${SIDEBAR_SELECTOR} a.reference.internal[href], ${SIDEBAR_SELECTOR} a.current-active[href]`; +const DOWNLOADABLE_FILE_EXTENSIONS = /\.(pdf|zip|exe|dmg|tar|gz)$/i; + +// Monotonic token used to ignore stale async sidebar-sync completions when +// multiple Swup navigations happen in quick succession. +let sidebarSyncRunId = 0; + +/** Reinitialize interactive elements after Swup replaces page content. */ +function reinitializeAfterSwap({ pageData = null, allowFetchFallback = true } = {}) { + // Intentional fire-and-forget: sidebar sync should not block page rendering. + void syncSidebarCurrentStateFromFetchedPage(pageData, allowFetchFallback); + + // Trigger any Sphinx-specific initializations + if (window.Sphinx && typeof window.initSearch === 'function') { + try { + window.initSearch(); + } catch { + // Search initialization might not be available + } + } +} + +/** Extract the sidebar-tree element from a Swup page payload. */ +function getFetchedSidebarFromPageData(pageData) { + if (!pageData) { + return null; + } + + if (pageData.document) { + return pageData.document.querySelector(SIDEBAR_SELECTOR); + } + + if (typeof pageData.html === 'string') { + const parsed = new DOMParser().parseFromString(pageData.html, 'text/html'); + return parsed.querySelector(SIDEBAR_SELECTOR); + } + + return null; +} + +/** Check if an href is empty, a fragment, mailto, or javascript URI. */ +function isNonNavigableHref(href) { + return !href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('javascript:'); +} + +/** Resolve an href to a canonical pathname for comparison (strips .html, trailing slash). */ +function normalizeHrefPath(href, baseUrl) { + if (isNonNavigableHref(href)) { + return null; + } + try { + const url = new URL(href, baseUrl); + return url.pathname + .replace(/\/index\.html$/, '/') + .replace(/\.html$/, '') + .replace(/\/$/, ''); + } catch { + return null; + } +} + +/** + * Resolve an href to a local absolute path, or return the href as-is + * for non-navigable/external URLs. + */ +function resolveLocalHref(href, baseUrl) { + if (isNonNavigableHref(href)) { + return href; + } + try { + const url = new URL(href, baseUrl); + if (url.origin !== window.location.origin) { + return href; + } + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return href; + } +} + +/** Convert all navigable sidebar links to absolute local paths. */ +function absolutizeSidebarLinks(baseUrl = window.location.href) { + const sidebar = document.querySelector(SIDEBAR_SELECTOR); + if (!sidebar) { + return; + } + + sidebar.querySelectorAll('a[href]').forEach((anchor) => { + const href = anchor.getAttribute('href') || ''; + if (!isNonNavigableHref(href)) { + anchor.setAttribute('href', resolveLocalHref(href, baseUrl)); + } + }); +} + +/** + * Sync current-state classes from fetched sidebar to live sidebar by index. + * Both sidebars must have the same number of anchors. + */ +function syncClassesByIndex(liveSidebar, liveAnchors, fetchedAnchors) { + const baseUrl = window.location.href; + + liveAnchors.forEach((liveAnchor, index) => { + const fetchedAnchor = fetchedAnchors[index]; + + // Sync hrefs from fetched sidebar to correct relative paths. + // Skip non-navigable values (e.g. '#') that Sphinx renders for the + // current page's own toctree entry: syncing '#' would overwrite the + // live absolute path and cause the next click on that link to fall + // through to a full-page reload via ignoreVisit. + const fetchedHref = fetchedAnchor.getAttribute('href') || ''; + if (!isNonNavigableHref(fetchedHref)) { + liveAnchor.setAttribute('href', resolveLocalHref(fetchedHref, baseUrl)); + } + + liveAnchor.classList.toggle('current', fetchedAnchor.classList.contains('current')); + + const liveItem = liveAnchor.closest('li'); + const fetchedItem = fetchedAnchor.closest('li'); + if (liveItem && fetchedItem) { + liveItem.classList.toggle('current', fetchedItem.classList.contains('current')); + liveItem.classList.toggle('current-page', fetchedItem.classList.contains('current-page')); + } + }); + + // Ensure only one active leaf link is highlighted. + liveSidebar.querySelectorAll('a.current-active').forEach((el) => { + el.classList.remove('current-active'); + }); + const currentPageAnchors = liveSidebar.querySelectorAll('li.current-page > a[href]'); + if (currentPageAnchors.length > 0) { + currentPageAnchors[currentPageAnchors.length - 1].classList.add('current-active'); + } +} + +/** + * Mark an anchor and its ancestor