wowchemy.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. /*************************************************
  2. * Wowchemy
  3. * https://github.com/wowchemy/wowchemy-hugo-themes
  4. *
  5. * Core JS functions and initialization.
  6. **************************************************/
  7. import mediumZoom from './_vendor/medium-zoom.esm';
  8. import {hugoEnvironment, codeHighlighting, searchEnabled} from '@params';
  9. import {scrollParentToChild} from './wowchemy-utils';
  10. import {
  11. changeThemeModeClick,
  12. initThemeVariation,
  13. renderThemeVariation,
  14. onMediaQueryListEvent,
  15. } from './wowchemy-theming';
  16. console.debug(`Environment: ${hugoEnvironment}`);
  17. /* ---------------------------------------------------------------------------
  18. * Responsive scrolling for URL hashes.
  19. * --------------------------------------------------------------------------- */
  20. // Dynamically get responsive navigation bar height for offsetting Scrollspy.
  21. function getNavBarHeight() {
  22. let navbar = document.getElementById('navbar-main');
  23. let navbarHeight = navbar ? navbar.getBoundingClientRect().height : 0;
  24. console.debug('Navbar height: ' + navbarHeight);
  25. return navbarHeight;
  26. }
  27. /**
  28. * Responsive hash scrolling.
  29. * Check for a URL hash as an anchor.
  30. * If page anchor matches hash, scroll to it responsively considering dynamic height elements.
  31. * If `target` argument omitted (e.g. after event), assume it's the window's hash.
  32. * Default to 0ms animation duration as don't want animation for fixing scrollspy Book page ToC highlighting.
  33. */
  34. function scrollToAnchor(target, duration = 0) {
  35. // If `target` is undefined or HashChangeEvent object, set it to window's hash.
  36. // Decode the hash as browsers can encode non-ASCII characters (e.g. Chinese symbols).
  37. target =
  38. typeof target === 'undefined' || typeof target === 'object' ? decodeURIComponent(window.location.hash) : target;
  39. // If target element exists, scroll to it taking into account fixed navigation bar offset.
  40. if ($(target).length) {
  41. // Escape special chars from IDs, such as colons found in Markdown footnote links.
  42. target = '#' + $.escapeSelector(target.substring(1)); // Previously, `target = target.replace(/:/g, '\\:');`
  43. let elementOffset = Math.ceil($(target).offset().top - getNavBarHeight()); // Round up to highlight right ID!
  44. $('body').addClass('scrolling');
  45. $('html, body').animate(
  46. {
  47. scrollTop: elementOffset,
  48. },
  49. duration,
  50. function () {
  51. $('body').removeClass('scrolling');
  52. },
  53. );
  54. } else {
  55. console.debug('Cannot scroll to target `#' + target + '`. ID not found!');
  56. }
  57. }
  58. // Make Scrollspy responsive.
  59. function fixScrollspy() {
  60. let $body = $('body');
  61. let data = $body.data('bs.scrollspy');
  62. if (data) {
  63. data._config.offset = getNavBarHeight();
  64. $body.data('bs.scrollspy', data);
  65. $body.scrollspy('refresh');
  66. }
  67. }
  68. function removeQueryParamsFromUrl() {
  69. if (window.history.replaceState) {
  70. let urlWithoutSearchParams =
  71. window.location.protocol + '//' + window.location.host + window.location.pathname + window.location.hash;
  72. window.history.replaceState({path: urlWithoutSearchParams}, '', urlWithoutSearchParams);
  73. }
  74. }
  75. // Check for hash change event and fix responsive offset for hash links (e.g. Markdown footnotes).
  76. window.addEventListener('hashchange', scrollToAnchor);
  77. /* ---------------------------------------------------------------------------
  78. * Add smooth scrolling to all links inside the main navbar.
  79. * --------------------------------------------------------------------------- */
  80. $('#navbar-main li.nav-item a.nav-link, .js-scroll').on('click', function (event) {
  81. // Store requested URL hash.
  82. let hash = this.hash;
  83. // If we are on a widget page and the navbar link is to a section on the same page.
  84. if (this.pathname === window.location.pathname && hash && $(hash).length && $('.js-widget-page').length > 0) {
  85. // Prevent default click behavior.
  86. event.preventDefault();
  87. // Use jQuery's animate() method for smooth page scrolling.
  88. // The numerical parameter specifies the time (ms) taken to scroll to the specified hash.
  89. let elementOffset = Math.ceil($(hash).offset().top - getNavBarHeight()); // Round up to highlight right ID!
  90. // Uncomment to debug.
  91. // let scrollTop = $(window).scrollTop();
  92. // let scrollDelta = (elementOffset - scrollTop);
  93. // console.debug('Scroll Delta: ' + scrollDelta);
  94. $('html, body').animate(
  95. {
  96. scrollTop: elementOffset,
  97. },
  98. 800,
  99. );
  100. }
  101. });
  102. /* ---------------------------------------------------------------------------
  103. * Hide mobile collapsable menu on clicking a link.
  104. * --------------------------------------------------------------------------- */
  105. $(document).on('click', '.navbar-collapse.show', function (e) {
  106. //get the <a> element that was clicked, even if the <span> element that is inside the <a> element is e.target
  107. let targetElement = $(e.target).is('a') ? $(e.target) : $(e.target).parent();
  108. if (targetElement.is('a') && targetElement.attr('class') != 'dropdown-toggle') {
  109. $(this).collapse('hide');
  110. }
  111. });
  112. /* ---------------------------------------------------------------------------
  113. * GitHub API.
  114. * --------------------------------------------------------------------------- */
  115. function printLatestRelease(selector, repo) {
  116. if (hugoEnvironment === 'production') {
  117. $.getJSON('https://api.github.com/repos/' + repo + '/tags')
  118. .done(function (json) {
  119. let release = json[0];
  120. $(selector).append(' ' + release.name);
  121. })
  122. .fail(function (jqxhr, textStatus, error) {
  123. let err = textStatus + ', ' + error;
  124. console.log('Request Failed: ' + err);
  125. });
  126. }
  127. }
  128. /* ---------------------------------------------------------------------------
  129. * Toggle search dialog.
  130. * --------------------------------------------------------------------------- */
  131. function toggleSearchDialog() {
  132. if ($('body').hasClass('searching')) {
  133. // Clear search query and hide search modal.
  134. $('[id=search-query]').blur();
  135. $('body').removeClass('searching compensate-for-scrollbar');
  136. // Remove search query params from URL as user has finished searching.
  137. removeQueryParamsFromUrl();
  138. // Prevent fixed positioned elements (e.g. navbar) moving due to scrollbars.
  139. $('#fancybox-style-noscroll').remove();
  140. } else {
  141. // Prevent fixed positioned elements (e.g. navbar) moving due to scrollbars.
  142. if (!$('#fancybox-style-noscroll').length && document.body.scrollHeight > window.innerHeight) {
  143. $('head').append(
  144. '<style id="fancybox-style-noscroll">.compensate-for-scrollbar{margin-right:' +
  145. (window.innerWidth - document.documentElement.clientWidth) +
  146. 'px;}</style>',
  147. );
  148. $('body').addClass('compensate-for-scrollbar');
  149. }
  150. // Show search modal.
  151. $('body').addClass('searching');
  152. $('.search-results').css({opacity: 0, visibility: 'visible'}).animate({opacity: 1}, 200);
  153. let algoliaSearchBox = document.querySelector('.ais-SearchBox-input');
  154. if (algoliaSearchBox) {
  155. algoliaSearchBox.focus();
  156. } else {
  157. $('#search-query').focus();
  158. }
  159. }
  160. }
  161. /* ---------------------------------------------------------------------------
  162. * Fix Hugo's Goldmark output and Mermaid code blocks.
  163. * --------------------------------------------------------------------------- */
  164. /**
  165. * Fix Hugo's Goldmark output.
  166. */
  167. function fixHugoOutput() {
  168. // Fix Goldmark table of contents.
  169. // - Must be performed prior to initializing ScrollSpy.
  170. $('#TableOfContents').addClass('nav flex-column');
  171. $('#TableOfContents li').addClass('nav-item');
  172. $('#TableOfContents li a').addClass('nav-link');
  173. // Fix Goldmark task lists (remove bullet points).
  174. $("input[type='checkbox'][disabled]").parents('ul').addClass('task-list');
  175. // Bootstrap table style is opt-in and Goldmark doesn't add it.
  176. $("table").addClass('.table');
  177. }
  178. // Get an element's siblings.
  179. function getSiblings(elem) {
  180. // Filter out itself.
  181. return Array.prototype.filter.call(elem.parentNode.children, function (sibling) {
  182. return sibling !== elem;
  183. });
  184. }
  185. /* ---------------------------------------------------------------------------
  186. * On document ready.
  187. * --------------------------------------------------------------------------- */
  188. $(document).ready(function () {
  189. fixHugoOutput();
  190. // Render theme variation, including any HLJS and Mermaid themes.
  191. let {isDarkTheme, themeMode} = initThemeVariation();
  192. renderThemeVariation(isDarkTheme, themeMode, true);
  193. // Initialise code highlighting if enabled for this page.
  194. // Note: this block should be processed after the Mermaid code-->div conversion.
  195. if (codeHighlighting) {
  196. hljs.initHighlighting();
  197. }
  198. // Scroll Book page's active menu sidebar link into view.
  199. let child = document.querySelector('.docs-links .active');
  200. let parent = document.querySelector('.docs-links');
  201. if (child && parent) {
  202. scrollParentToChild(parent, child);
  203. }
  204. });
  205. /* ---------------------------------------------------------------------------
  206. * On window loaded.
  207. * --------------------------------------------------------------------------- */
  208. $(window).on('load', function () {
  209. // Re-initialize Scrollspy with dynamic navbar height offset.
  210. fixScrollspy();
  211. // Detect instances of the Portfolio widget.
  212. let isotopeInstances = document.querySelectorAll('.projects-container');
  213. let isotopeInstancesCount = isotopeInstances.length;
  214. // Fix ScrollSpy highlighting previous Book page ToC link for some anchors.
  215. // Check if isotopeInstancesCount>0 as that case performs its own scrollToAnchor.
  216. if (window.location.hash && isotopeInstancesCount === 0) {
  217. scrollToAnchor(decodeURIComponent(window.location.hash), 0);
  218. }
  219. // Scroll Book page's active ToC sidebar link into view.
  220. // Action after calling scrollToAnchor to fix Scrollspy highlighting otherwise wrong link may have active class.
  221. let child = document.querySelector('.docs-toc .nav-link.active');
  222. let parent = document.querySelector('.docs-toc');
  223. if (child && parent) {
  224. scrollParentToChild(parent, child);
  225. }
  226. // Enable images to be zoomed.
  227. let zoomOptions = {};
  228. if (document.body.classList.contains('dark')) {
  229. zoomOptions.background = 'rgba(0,0,0,0.9)';
  230. } else {
  231. zoomOptions.background = 'rgba(255,255,255,0.9)';
  232. }
  233. mediumZoom('[data-zoomable]', zoomOptions);
  234. // Init Isotope Layout Engine for instances of the Portfolio widget.
  235. let isotopeCounter = 0;
  236. isotopeInstances.forEach(function (isotopeInstance, index) {
  237. console.debug(`Loading Isotope instance ${index}`);
  238. // Isotope instance
  239. let iso;
  240. // Get the layout for this Isotope instance
  241. let isoSection = isotopeInstance.closest('section');
  242. let layout = '';
  243. if (isoSection.querySelector('.isotope').classList.contains('js-layout-row')) {
  244. layout = 'fitRows';
  245. } else {
  246. layout = 'masonry';
  247. }
  248. // Get default filter (if any) for this instance
  249. let defaultFilter = isoSection.querySelector('.default-project-filter');
  250. let filterText = '*';
  251. if (defaultFilter !== null) {
  252. filterText = defaultFilter.textContent;
  253. }
  254. console.debug(`Default Isotope filter: ${filterText}`);
  255. // Init Isotope instance once its images have loaded.
  256. imagesLoaded(isotopeInstance, function () {
  257. iso = new Isotope(isotopeInstance, {
  258. itemSelector: '.isotope-item',
  259. layoutMode: layout,
  260. masonry: {
  261. gutter: 20,
  262. },
  263. filter: filterText,
  264. });
  265. // Filter Isotope items when a toolbar filter button is clicked.
  266. let isoFilterButtons = isoSection.querySelectorAll('.project-filters a');
  267. isoFilterButtons.forEach((button) =>
  268. button.addEventListener('click', (e) => {
  269. e.preventDefault();
  270. let selector = button.getAttribute('data-filter');
  271. // Apply filter
  272. console.debug(`Updating Isotope filter to ${selector}`);
  273. iso.arrange({filter: selector});
  274. // Update active toolbar filter button
  275. button.classList.remove('active');
  276. button.classList.add('active');
  277. let buttonSiblings = getSiblings(button);
  278. buttonSiblings.forEach((buttonSibling) => {
  279. buttonSibling.classList.remove('active');
  280. buttonSibling.classList.remove('all');
  281. });
  282. }),
  283. );
  284. // Check if all Isotope instances have loaded.
  285. incrementIsotopeCounter();
  286. });
  287. });
  288. // Hook to perform actions once all Isotope instances have loaded.
  289. function incrementIsotopeCounter() {
  290. isotopeCounter++;
  291. if (isotopeCounter === isotopeInstancesCount) {
  292. console.debug(`All Portfolio Isotope instances loaded.`);
  293. // Once all Isotope instances and their images have loaded, scroll to hash (if set).
  294. // Prevents scrolling to the wrong location due to the dynamic height of Isotope instances.
  295. // Each Isotope instance height is affected by applying filters and loading images.
  296. // Without this logic, the scroll location can appear correct, but actually a few pixels out and hence Scrollspy
  297. // can highlight the wrong nav link.
  298. if (window.location.hash) {
  299. scrollToAnchor(decodeURIComponent(window.location.hash), 0);
  300. }
  301. }
  302. }
  303. // Print latest version of GitHub projects.
  304. let githubReleaseSelector = '.js-github-release';
  305. if ($(githubReleaseSelector).length > 0) {
  306. printLatestRelease(githubReleaseSelector, $(githubReleaseSelector).data('repo'));
  307. }
  308. // Parse Wowchemy keyboard shortcuts.
  309. document.addEventListener('keyup', (event) => {
  310. if (event.code === 'Escape') {
  311. const body = document.body;
  312. if (body.classList.contains('searching')) {
  313. // Close search dialog.
  314. toggleSearchDialog();
  315. }
  316. }
  317. // Use `key` to check for slash. Otherwise, with `code` we need to check for modifiers.
  318. if (event.key === '/') {
  319. let focusedElement =
  320. (document.hasFocus() &&
  321. document.activeElement !== document.body &&
  322. document.activeElement !== document.documentElement &&
  323. document.activeElement) ||
  324. null;
  325. let isInputFocused = focusedElement instanceof HTMLInputElement || focusedElement instanceof HTMLTextAreaElement;
  326. if (searchEnabled && !isInputFocused) {
  327. // Open search dialog.
  328. event.preventDefault();
  329. toggleSearchDialog();
  330. }
  331. }
  332. });
  333. // Search event handler
  334. // Check that built-in search or Algolia enabled.
  335. if (searchEnabled) {
  336. // On search icon click toggle search dialog.
  337. $('.js-search').click(function (e) {
  338. e.preventDefault();
  339. toggleSearchDialog();
  340. });
  341. }
  342. // Init. author notes (tooltips).
  343. $('[data-toggle="tooltip"]').tooltip();
  344. });
  345. // Theme chooser events.
  346. let linkLight = document.querySelector('.js-set-theme-light');
  347. let linkDark = document.querySelector('.js-set-theme-dark');
  348. let linkAuto = document.querySelector('.js-set-theme-auto');
  349. if (linkLight && linkDark && linkAuto) {
  350. linkLight.addEventListener('click', (event) => {
  351. event.preventDefault();
  352. changeThemeModeClick(0);
  353. });
  354. linkDark.addEventListener('click', (event) => {
  355. event.preventDefault();
  356. changeThemeModeClick(1);
  357. });
  358. linkAuto.addEventListener('click', (event) => {
  359. event.preventDefault();
  360. changeThemeModeClick(2);
  361. });
  362. }
  363. // Media Query events.
  364. // Live update of day/night mode on system preferences update (no refresh required).
  365. // Note: since we listen only for *dark* events, we won't detect other scheme changes such as light to no-preference.
  366. const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  367. darkModeMediaQuery.addEventListener('change', (event) => {
  368. onMediaQueryListEvent(event);
  369. });
  370. // Automatic main menu dropdowns on mouse over.
  371. $('body').on('mouseenter mouseleave', '.dropdown', function (e) {
  372. var dropdown = $(e.target).closest('.dropdown');
  373. var menu = $('.dropdown-menu', dropdown);
  374. dropdown.addClass('show');
  375. menu.addClass('show');
  376. setTimeout(function () {
  377. dropdown[dropdown.is(':hover') ? 'addClass' : 'removeClass']('show');
  378. menu[dropdown.is(':hover') ? 'addClass' : 'removeClass']('show');
  379. }, 300);
  380. });
  381. // Call `fixScrollspy` when window is resized.
  382. let resizeTimer;
  383. $(window).resize(function () {
  384. clearTimeout(resizeTimer);
  385. resizeTimer = setTimeout(fixScrollspy, 200);
  386. });