diff --git a/src/qiskit_sphinx_theme/__init__.py b/src/qiskit_sphinx_theme/__init__.py index f2dbff0b..773a283f 100644 --- a/src/qiskit_sphinx_theme/__init__.py +++ b/src/qiskit_sphinx_theme/__init__.py @@ -71,13 +71,25 @@ def activate_themes(app: sphinx.application.Sphinx, config: sphinx.config.Config if config.html_theme == "qiskit": # We set a low priority so that our Qiskit CSS file overrides Furo. app.add_css_file("styles/furo.css", 100) - app.add_js_file("scripts/furo.js") + app.add_js_file("scripts/qiskit-sphinx-theme.js") else: # Sphinx 6 stopped including jQuery by default. Our Pytorch theme depend on jQuery, # so activate it for our users automatically. app.setup_extension("sphinxcontrib.jquery") +def remove_furo_js( + app: sphinx.application.Sphinx, + pagename: str, + templatename: str, + context: dict, + doctree: sphinx.addnodes.document, +) -> None: + context["script_files"] = [ + js_file for js_file in context["script_files"] if js_file != "_static/scripts/furo.js" + ] + + # See https://www.sphinx-doc.org/en/master/development/theming.html def setup(app: sphinx.application.Sphinx) -> dict[str, bool]: # Used to generate URL references. Expected to be e.g. `ecosystem/finance`. @@ -94,7 +106,8 @@ def setup(app: sphinx.application.Sphinx) -> dict[str, bool]: app.add_html_theme("qiskit_sphinx_theme", _get_theme_absolute_path("pytorch")) app.add_html_theme("qiskit", _get_theme_absolute_path("theme/qiskit-sphinx-theme")) - app.connect("html-page-context", remove_thebe_if_not_needed) app.connect("config-inited", activate_themes) + app.connect("html-page-context", remove_furo_js, priority=600) + app.connect("html-page-context", remove_thebe_if_not_needed) return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/src/qiskit_sphinx_theme/assets/scripts/gumshoe-patched.js b/src/qiskit_sphinx_theme/assets/scripts/gumshoe-patched.js new file mode 100644 index 00000000..0642937e --- /dev/null +++ b/src/qiskit_sphinx_theme/assets/scripts/gumshoe-patched.js @@ -0,0 +1,467 @@ +// Vendored exactly from Furo's `assets/scripts/gumshoe-patched.js`. +/*! + * gumshoejs v5.1.2 (patched by @pradyunsg) + * A simple, framework-agnostic scrollspy script. + * (c) 2019 Chris Ferdinandi + * MIT License + * http://github.com/cferdinandi/gumshoe + */ + +(function (root, factory) { + if (typeof define === "function" && define.amd) { + define([], function () { + return factory(root); + }); + } else if (typeof exports === "object") { + module.exports = factory(root); + } else { + root.Gumshoe = factory(root); + } +})( + typeof global !== "undefined" + ? global + : typeof window !== "undefined" + ? window + : this, + function (window) { + "use strict"; + + // + // Defaults + // + + var defaults = { + // Active classes + navClass: "active", + contentClass: "active", + + // Nested navigation + nested: false, + nestedClass: "active", + + // Offset & reflow + offset: 0, + reflow: false, + + // Event support + events: true, + }; + + // + // Methods + // + + /** + * Merge two or more objects together. + * @param {Object} objects The objects to merge together + * @returns {Object} Merged values of defaults and options + */ + var extend = function () { + var merged = {}; + Array.prototype.forEach.call(arguments, function (obj) { + for (var key in obj) { + if (!obj.hasOwnProperty(key)) return; + merged[key] = obj[key]; + } + }); + return merged; + }; + + /** + * Emit a custom event + * @param {String} type The event type + * @param {Node} elem The element to attach the event to + * @param {Object} detail Any details to pass along with the event + */ + var emitEvent = function (type, elem, detail) { + // Make sure events are enabled + if (!detail.settings.events) return; + + // Create a new event + var event = new CustomEvent(type, { + bubbles: true, + cancelable: true, + detail: detail, + }); + + // Dispatch the event + elem.dispatchEvent(event); + }; + + /** + * Get an element's distance from the top of the Document. + * @param {Node} elem The element + * @return {Number} Distance from the top in pixels + */ + var getOffsetTop = function (elem) { + var location = 0; + if (elem.offsetParent) { + while (elem) { + location += elem.offsetTop; + elem = elem.offsetParent; + } + } + return location >= 0 ? location : 0; + }; + + /** + * Sort content from first to last in the DOM + * @param {Array} contents The content areas + */ + var sortContents = function (contents) { + if (contents) { + contents.sort(function (item1, item2) { + var offset1 = getOffsetTop(item1.content); + var offset2 = getOffsetTop(item2.content); + if (offset1 < offset2) return -1; + return 1; + }); + } + }; + + /** + * Get the offset to use for calculating position + * @param {Object} settings The settings for this instantiation + * @return {Float} The number of pixels to offset the calculations + */ + var getOffset = function (settings) { + // if the offset is a function run it + if (typeof settings.offset === "function") { + return parseFloat(settings.offset()); + } + + // Otherwise, return it as-is + return parseFloat(settings.offset); + }; + + /** + * Get the document element's height + * @private + * @returns {Number} + */ + var getDocumentHeight = function () { + return Math.max( + document.body.scrollHeight, + document.documentElement.scrollHeight, + document.body.offsetHeight, + document.documentElement.offsetHeight, + document.body.clientHeight, + document.documentElement.clientHeight, + ); + }; + + /** + * Determine if an element is in view + * @param {Node} elem The element + * @param {Object} settings The settings for this instantiation + * @param {Boolean} bottom If true, check if element is above bottom of viewport instead + * @return {Boolean} Returns true if element is in the viewport + */ + var isInView = function (elem, settings, bottom) { + var bounds = elem.getBoundingClientRect(); + var offset = getOffset(settings); + if (bottom) { + return ( + parseInt(bounds.bottom, 10) < + (window.innerHeight || document.documentElement.clientHeight) + ); + } + return parseInt(bounds.top, 10) <= offset; + }; + + /** + * Check if at the bottom of the viewport + * @return {Boolean} If true, page is at the bottom of the viewport + */ + var isAtBottom = function () { + if ( + Math.ceil(window.innerHeight + window.pageYOffset) >= + getDocumentHeight() + ) + return true; + return false; + }; + + /** + * Check if the last item should be used (even if not at the top of the page) + * @param {Object} item The last item + * @param {Object} settings The settings for this instantiation + * @return {Boolean} If true, use the last item + */ + var useLastItem = function (item, settings) { + if (isAtBottom() && isInView(item.content, settings, true)) return true; + return false; + }; + + /** + * Get the active content + * @param {Array} contents The content areas + * @param {Object} settings The settings for this instantiation + * @return {Object} The content area and matching navigation link + */ + var getActive = function (contents, settings) { + var last = contents[contents.length - 1]; + if (useLastItem(last, settings)) return last; + for (var i = contents.length - 1; i >= 0; i--) { + if (isInView(contents[i].content, settings)) return contents[i]; + } + }; + + /** + * Deactivate parent navs in a nested navigation + * @param {Node} nav The starting navigation element + * @param {Object} settings The settings for this instantiation + */ + var deactivateNested = function (nav, settings) { + // If nesting isn't activated, bail + if (!settings.nested || !nav.parentNode) return; + + // Get the parent navigation + var li = nav.parentNode.closest("li"); + if (!li) return; + + // Remove the active class + li.classList.remove(settings.nestedClass); + + // Apply recursively to any parent navigation elements + deactivateNested(li, settings); + }; + + /** + * Deactivate a nav and content area + * @param {Object} items The nav item and content to deactivate + * @param {Object} settings The settings for this instantiation + */ + var deactivate = function (items, settings) { + // Make sure there are items to deactivate + if (!items) return; + + // Get the parent list item + var li = items.nav.closest("li"); + if (!li) return; + + // Remove the active class from the nav and content + li.classList.remove(settings.navClass); + items.content.classList.remove(settings.contentClass); + + // Deactivate any parent navs in a nested navigation + deactivateNested(li, settings); + + // Emit a custom event + emitEvent("gumshoeDeactivate", li, { + link: items.nav, + content: items.content, + settings: settings, + }); + }; + + /** + * Activate parent navs in a nested navigation + * @param {Node} nav The starting navigation element + * @param {Object} settings The settings for this instantiation + */ + var activateNested = function (nav, settings) { + // If nesting isn't activated, bail + if (!settings.nested) return; + + // Get the parent navigation + var li = nav.parentNode.closest("li"); + if (!li) return; + + // Add the active class + li.classList.add(settings.nestedClass); + + // Apply recursively to any parent navigation elements + activateNested(li, settings); + }; + + /** + * Activate a nav and content area + * @param {Object} items The nav item and content to activate + * @param {Object} settings The settings for this instantiation + */ + var activate = function (items, settings) { + // Make sure there are items to activate + if (!items) return; + + // Get the parent list item + var li = items.nav.closest("li"); + if (!li) return; + + // Add the active class to the nav and content + li.classList.add(settings.navClass); + items.content.classList.add(settings.contentClass); + + // Activate any parent navs in a nested navigation + activateNested(li, settings); + + // Emit a custom event + emitEvent("gumshoeActivate", li, { + link: items.nav, + content: items.content, + settings: settings, + }); + }; + + /** + * Create the Constructor object + * @param {String} selector The selector to use for navigation items + * @param {Object} options User options and settings + */ + var Constructor = function (selector, options) { + // + // Variables + // + + var publicAPIs = {}; + var navItems, contents, current, timeout, settings; + + // + // Methods + // + + /** + * Set variables from DOM elements + */ + publicAPIs.setup = function () { + // Get all nav items + navItems = document.querySelectorAll(selector); + + // Create contents array + contents = []; + + // Loop through each item, get it's matching content, and push to the array + Array.prototype.forEach.call(navItems, function (item) { + // Get the content for the nav item + var content = document.getElementById( + decodeURIComponent(item.hash.substr(1)), + ); + if (!content) return; + + // Push to the contents array + contents.push({ + nav: item, + content: content, + }); + }); + + // Sort contents by the order they appear in the DOM + sortContents(contents); + }; + + /** + * Detect which content is currently active + */ + publicAPIs.detect = function () { + // Get the active content + var active = getActive(contents, settings); + + // if there's no active content, deactivate and bail + if (!active) { + if (current) { + deactivate(current, settings); + current = null; + } + return; + } + + // If the active content is the one currently active, do nothing + if (current && active.content === current.content) return; + + // Deactivate the current content and activate the new content + deactivate(current, settings); + activate(active, settings); + + // Update the currently active content + current = active; + }; + + /** + * Detect the active content on scroll + * Debounced for performance + */ + var scrollHandler = function (event) { + // If there's a timer, cancel it + if (timeout) { + window.cancelAnimationFrame(timeout); + } + + // Setup debounce callback + timeout = window.requestAnimationFrame(publicAPIs.detect); + }; + + /** + * Update content sorting on resize + * Debounced for performance + */ + var resizeHandler = function (event) { + // If there's a timer, cancel it + if (timeout) { + window.cancelAnimationFrame(timeout); + } + + // Setup debounce callback + timeout = window.requestAnimationFrame(function () { + sortContents(contents); + publicAPIs.detect(); + }); + }; + + /** + * Destroy the current instantiation + */ + publicAPIs.destroy = function () { + // Undo DOM changes + if (current) { + deactivate(current, settings); + } + + // Remove event listeners + window.removeEventListener("scroll", scrollHandler, false); + if (settings.reflow) { + window.removeEventListener("resize", resizeHandler, false); + } + + // Reset variables + contents = null; + navItems = null; + current = null; + timeout = null; + settings = null; + }; + + /** + * Initialize the current instantiation + */ + var init = function () { + // Merge user options into defaults + settings = extend(defaults, options || {}); + + // Setup variables based on the current DOM + publicAPIs.setup(); + + // Find the currently active content + publicAPIs.detect(); + + // Setup event listeners + window.addEventListener("scroll", scrollHandler, false); + if (settings.reflow) { + window.addEventListener("resize", resizeHandler, false); + } + }; + + // + // Initialize and return the public APIs + // + + init(); + return publicAPIs; + }; + + // + // Return the Constructor + // + + return Constructor; + }, +); diff --git a/src/qiskit_sphinx_theme/assets/scripts/qiskit-sphinx-theme.js b/src/qiskit_sphinx_theme/assets/scripts/qiskit-sphinx-theme.js index cf6e54c2..ff9bffa8 100644 --- a/src/qiskit_sphinx_theme/assets/scripts/qiskit-sphinx-theme.js +++ b/src/qiskit_sphinx_theme/assets/scripts/qiskit-sphinx-theme.js @@ -1,4 +1,181 @@ -// Empty file required by sphinx-theme-builder. -// -// This will allow us to fix Furo's JavaScript code to workaround -// https://github.com/Qiskit/qiskit_sphinx_theme/issues/368. +// This file is vendored from Furo's `assets/scripts/furo.js`. When adding custom Qiskit code, +// surround it with `QISKIT CHANGE: start` and `QISKIT CHANGE: end` comments. + +import Gumshoe from "./gumshoe-patched.js"; + +//////////////////////////////////////////////////////////////////////////////// +// Scroll Handling +//////////////////////////////////////////////////////////////////////////////// +var tocScroll = null; +var header = null; +var lastScrollTop = window.pageYOffset || document.documentElement.scrollTop; +const GO_TO_TOP_OFFSET = 64; + +function scrollHandlerForHeader() { + if (Math.floor(header.getBoundingClientRect().top) == 0) { + header.classList.add("scrolled"); + } else { + header.classList.remove("scrolled"); + } +} + +function scrollHandlerForBackToTop(positionY) { + if (positionY < GO_TO_TOP_OFFSET) { + document.documentElement.classList.remove("show-back-to-top"); + } else { + if (positionY < lastScrollTop) { + document.documentElement.classList.add("show-back-to-top"); + } else if (positionY > lastScrollTop) { + document.documentElement.classList.remove("show-back-to-top"); + } + } + lastScrollTop = positionY; +} + +function scrollHandlerForTOC(positionY) { + if (tocScroll === null) { + return; + } + + // top of page. + if (positionY == 0) { + tocScroll.scrollTo(0, 0); + } else if ( + // bottom of page. + Math.ceil(positionY) >= + Math.floor(document.documentElement.scrollHeight - window.innerHeight) + ) { + tocScroll.scrollTo(0, tocScroll.scrollHeight); + } else { + // somewhere in the middle. + const current = document.querySelector(".scroll-current"); + if (current == null) { + return; + } + + // https://github.com/pypa/pip/issues/9159 This breaks scroll behaviours. + // // scroll the currently "active" heading in toc, into view. + // const rect = current.getBoundingClientRect(); + // if (0 > rect.top) { + // current.scrollIntoView(true); // the argument is "alignTop" + // } else if (rect.bottom > window.innerHeight) { + // current.scrollIntoView(false); + // } + } +} + +function scrollHandler(positionY) { + scrollHandlerForHeader(); + scrollHandlerForBackToTop(positionY); + scrollHandlerForTOC(positionY); +} + +//////////////////////////////////////////////////////////////////////////////// +// Theme Toggle +//////////////////////////////////////////////////////////////////////////////// +function setTheme(mode) { + if (mode !== "light" && mode !== "dark" && mode !== "auto") { + console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); + mode = "auto"; + } + + document.body.dataset.theme = mode; + localStorage.setItem("theme", mode); + console.log(`Changed to ${mode} mode.`); +} + +function cycleThemeOnce() { + const currentTheme = localStorage.getItem("theme") || "auto"; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + + if (prefersDark) { + // Auto (dark) -> Light -> Dark + if (currentTheme === "auto") { + setTheme("light"); + } else if (currentTheme == "light") { + setTheme("dark"); + } else { + setTheme("auto"); + } + } else { + // Auto (light) -> Dark -> Light + if (currentTheme === "auto") { + setTheme("dark"); + } else if (currentTheme == "dark") { + setTheme("light"); + } else { + setTheme("auto"); + } + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Setup +//////////////////////////////////////////////////////////////////////////////// +function setupScrollHandler() { + // Taken from https://developer.mozilla.org/en-US/docs/Web/API/Document/scroll_event + let last_known_scroll_position = 0; + let ticking = false; + + window.addEventListener("scroll", function (e) { + last_known_scroll_position = window.scrollY; + + if (!ticking) { + window.requestAnimationFrame(function () { + scrollHandler(last_known_scroll_position); + ticking = false; + }); + + ticking = true; + } + }); + window.scroll(); +} + +function setupScrollSpy() { + if (tocScroll === null) { + return; + } + + // Scrollspy -- highlight table on contents, based on scroll + new Gumshoe(".toc-tree a", { + reflow: true, + recursive: true, + navClass: "scroll-current", + offset: () => { + let rem = parseFloat(getComputedStyle(document.documentElement).fontSize); + // QISKIT CHANGE: start. Add 3.5rem for the Qiskit top nav bar. + // See _top-nav-bar.scss for where the value comes from. + return header.getBoundingClientRect().height + (0.5 * rem) + 1 + (3.5 * rem); + // QISKIT CHANGE: end. + }, + }); +} + +function setupTheme() { + // Attach event handlers for toggling themes + const buttons = document.getElementsByClassName("theme-toggle"); + Array.from(buttons).forEach((btn) => { + btn.addEventListener("click", cycleThemeOnce); + }); +} + +function setup() { + setupTheme(); + setupScrollHandler(); + setupScrollSpy(); +} + +//////////////////////////////////////////////////////////////////////////////// +// Main entrypoint +//////////////////////////////////////////////////////////////////////////////// +function main() { + document.body.parentNode.classList.remove("no-js"); + + header = document.querySelector("header"); + tocScroll = document.querySelector(".toc-scroll"); + + setup(); +} + +document.addEventListener("DOMContentLoaded", main); diff --git a/src/qiskit_sphinx_theme/assets/styles/_top-nav-bar.scss b/src/qiskit_sphinx_theme/assets/styles/_top-nav-bar.scss index ffb15674..e71096ee 100644 --- a/src/qiskit_sphinx_theme/assets/styles/_top-nav-bar.scss +++ b/src/qiskit_sphinx_theme/assets/styles/_top-nav-bar.scss @@ -1,5 +1,8 @@ // This value is duplicated from `top-nav-bar.js`. Its definition of the variable is not // exposed globally. Keep in sync. +// +// Also keep in sync with `qiskit-sphinx-theme.js`, which adds the top nav bar +// in the function `setupScrollSpy`. $top-nav-bar-height: 3.5rem; // Disable dark mode until qiskit.org has it: https://github.com/Qiskit/qiskit.org/issues/2310 @@ -58,10 +61,3 @@ div.header-left svg use { scroll-margin-top: calc(0.8rem + var(--header-height) + $top-nav-bar-height); } } - -// Turn off highlighting of the current page until we can properly solve -// https://github.com/Qiskit/qiskit_sphinx_theme/issues/368. -.toc-tree li.scroll-current > .reference { - color: var(--color-toc-item-text); - font-weight: unset; -} diff --git a/tests/js/snapshots.test.js-snapshots/right-side-bar-is-not-broken-by-our-page-layout-1-linux.png b/tests/js/snapshots.test.js-snapshots/right-side-bar-is-not-broken-by-our-page-layout-1-linux.png index af7b2ff1..31fb8611 100644 Binary files a/tests/js/snapshots.test.js-snapshots/right-side-bar-is-not-broken-by-our-page-layout-1-linux.png and b/tests/js/snapshots.test.js-snapshots/right-side-bar-is-not-broken-by-our-page-layout-1-linux.png differ