wowchemy.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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. }
  176. // Get an element's siblings.
  177. function getSiblings(elem) {
  178. // Filter out itself.
  179. return Array.prototype.filter.call(elem.parentNode.children, function (sibling) {
  180. return sibling !== elem;
  181. });
  182. }
  183. /* ---------------------------------------------------------------------------
  184. * On document ready.
  185. * --------------------------------------------------------------------------- */
  186. $(document).ready(function () {
  187. fixHugoOutput();
  188. // Render theme variation, including any HLJS and Mermaid themes.
  189. let {isDarkTheme, themeMode} = initThemeVariation();
  190. renderThemeVariation(isDarkTheme, themeMode, true);
  191. // Initialise code highlighting if enabled for this page.
  192. // Note: this block should be processed after the Mermaid code-->div conversion.
  193. if (codeHighlighting) {
  194. hljs.initHighlighting();
  195. }
  196. // Scroll Book page's active menu sidebar link into view.
  197. let child = document.querySelector('.docs-links .active');
  198. let parent = document.querySelector('.docs-links');
  199. if (child && parent) {
  200. scrollParentToChild(parent, child);
  201. }
  202. });
  203. /* ---------------------------------------------------------------------------
  204. * On window loaded.
  205. * --------------------------------------------------------------------------- */
  206. $(window).on('load', function () {
  207. // Re-initialize Scrollspy with dynamic navbar height offset.
  208. fixScrollspy();
  209. // Detect instances of the Portfolio widget.
  210. let isotopeInstances = document.querySelectorAll('.projects-container');
  211. let isotopeInstancesCount = isotopeInstances.length;
  212. // Fix ScrollSpy highlighting previous Book page ToC link for some anchors.
  213. // Check if isotopeInstancesCount>0 as that case performs its own scrollToAnchor.
  214. if (window.location.hash && isotopeInstancesCount === 0) {
  215. scrollToAnchor(decodeURIComponent(window.location.hash), 0);
  216. }
  217. // Scroll Book page's active ToC sidebar link into view.
  218. // Action after calling scrollToAnchor to fix Scrollspy highlighting otherwise wrong link may have active class.
  219. let child = document.querySelector('.docs-toc .nav-link.active');
  220. let parent = document.querySelector('.docs-toc');
  221. if (child && parent) {
  222. scrollParentToChild(parent, child);
  223. }
  224. // Enable images to be zoomed.
  225. let zoomOptions = {};
  226. if (document.body.classList.contains('dark')) {
  227. zoomOptions.background = 'rgba(0,0,0,0.9)';
  228. } else {
  229. zoomOptions.background = 'rgba(255,255,255,0.9)';
  230. }
  231. mediumZoom('[data-zoomable]', zoomOptions);
  232. // Init Isotope Layout Engine for instances of the Portfolio widget.
  233. let isotopeCounter = 0;
  234. isotopeInstances.forEach(function (isotopeInstance, index) {
  235. console.debug(`Loading Isotope instance ${index}`);
  236. // Isotope instance
  237. let iso;
  238. // Get the layout for this Isotope instance
  239. let isoSection = isotopeInstance.closest('section');
  240. let layout = '';
  241. if (isoSection.querySelector('.isotope').classList.contains('js-layout-row')) {
  242. layout = 'fitRows';
  243. } else {
  244. layout = 'masonry';
  245. }
  246. // Get default filter (if any) for this instance
  247. let defaultFilter = isoSection.querySelector('.default-project-filter');
  248. let filterText = '*';
  249. if (defaultFilter !== null) {
  250. filterText = defaultFilter.textContent;
  251. }
  252. console.debug(`Default Isotope filter: ${filterText}`);
  253. // Init Isotope instance once its images have loaded.
  254. imagesLoaded(isotopeInstance, function () {
  255. iso = new Isotope(isotopeInstance, {
  256. itemSelector: '.isotope-item',
  257. layoutMode: layout,
  258. masonry: {
  259. gutter: 20,
  260. },
  261. filter: filterText,
  262. });
  263. // Filter Isotope items when a toolbar filter button is clicked.
  264. let isoFilterButtons = isoSection.querySelectorAll('.project-filters a');
  265. isoFilterButtons.forEach((button) =>
  266. button.addEventListener('click', (e) => {
  267. e.preventDefault();
  268. let selector = button.getAttribute('data-filter');
  269. // Apply filter
  270. console.debug(`Updating Isotope filter to ${selector}`);
  271. iso.arrange({filter: selector});
  272. // Update active toolbar filter button
  273. button.classList.remove('active');
  274. button.classList.add('active');
  275. let buttonSiblings = getSiblings(button);
  276. buttonSiblings.forEach((buttonSibling) => {
  277. buttonSibling.classList.remove('active');
  278. buttonSibling.classList.remove('all');
  279. });
  280. }),
  281. );
  282. // Check if all Isotope instances have loaded.
  283. incrementIsotopeCounter();
  284. });
  285. });
  286. // Hook to perform actions once all Isotope instances have loaded.
  287. function incrementIsotopeCounter() {
  288. isotopeCounter++;
  289. if (isotopeCounter === isotopeInstancesCount) {
  290. console.debug(`All Portfolio Isotope instances loaded.`);
  291. // Once all Isotope instances and their images have loaded, scroll to hash (if set).
  292. // Prevents scrolling to the wrong location due to the dynamic height of Isotope instances.
  293. // Each Isotope instance height is affected by applying filters and loading images.
  294. // Without this logic, the scroll location can appear correct, but actually a few pixels out and hence Scrollspy
  295. // can highlight the wrong nav link.
  296. if (window.location.hash) {
  297. scrollToAnchor(decodeURIComponent(window.location.hash), 0);
  298. }
  299. }
  300. }
  301. // Print latest version of GitHub projects.
  302. let githubReleaseSelector = '.js-github-release';
  303. if ($(githubReleaseSelector).length > 0) {
  304. printLatestRelease(githubReleaseSelector, $(githubReleaseSelector).data('repo'));
  305. }
  306. // Parse Wowchemy keyboard shortcuts.
  307. document.addEventListener('keyup', (event) => {
  308. if (event.code === 'Escape') {
  309. const body = document.body;
  310. if (body.classList.contains('searching')) {
  311. // Close search dialog.
  312. toggleSearchDialog();
  313. }
  314. }
  315. // Use `key` to check for slash. Otherwise, with `code` we need to check for modifiers.
  316. if (event.key === '/') {
  317. let focusedElement =
  318. (document.hasFocus() &&
  319. document.activeElement !== document.body &&
  320. document.activeElement !== document.documentElement &&
  321. document.activeElement) ||
  322. null;
  323. let isInputFocused = focusedElement instanceof HTMLInputElement || focusedElement instanceof HTMLTextAreaElement;
  324. if (searchEnabled && !isInputFocused) {
  325. // Open search dialog.
  326. event.preventDefault();
  327. toggleSearchDialog();
  328. }
  329. }
  330. });
  331. // Search event handler
  332. // Check that built-in search or Algolia enabled.
  333. if (searchEnabled) {
  334. // On search icon click toggle search dialog.
  335. $('.js-search').click(function (e) {
  336. e.preventDefault();
  337. toggleSearchDialog();
  338. });
  339. }
  340. // Init. author notes (tooltips).
  341. $('[data-toggle="tooltip"]').tooltip();
  342. });
  343. // Theme chooser events.
  344. let linkLight = document.querySelector('.js-set-theme-light');
  345. let linkDark = document.querySelector('.js-set-theme-dark');
  346. let linkAuto = document.querySelector('.js-set-theme-auto');
  347. if (linkLight && linkDark && linkAuto) {
  348. linkLight.addEventListener('click', (event) => {
  349. event.preventDefault();
  350. changeThemeModeClick(0);
  351. });
  352. linkDark.addEventListener('click', (event) => {
  353. event.preventDefault();
  354. changeThemeModeClick(1);
  355. });
  356. linkAuto.addEventListener('click', (event) => {
  357. event.preventDefault();
  358. changeThemeModeClick(2);
  359. });
  360. }
  361. // Media Query events.
  362. // Live update of day/night mode on system preferences update (no refresh required).
  363. // Note: since we listen only for *dark* events, we won't detect other scheme changes such as light to no-preference.
  364. const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  365. darkModeMediaQuery.addEventListener('change', (event) => {
  366. onMediaQueryListEvent(event);
  367. });
  368. // Automatic main menu dropdowns on mouse over.
  369. $('body').on('mouseenter mouseleave', '.dropdown', function (e) {
  370. var dropdown = $(e.target).closest('.dropdown');
  371. var menu = $('.dropdown-menu', dropdown);
  372. dropdown.addClass('show');
  373. menu.addClass('show');
  374. setTimeout(function () {
  375. dropdown[dropdown.is(':hover') ? 'addClass' : 'removeClass']('show');
  376. menu[dropdown.is(':hover') ? 'addClass' : 'removeClass']('show');
  377. }, 300);
  378. });
  379. // Call `fixScrollspy` when window is resized.
  380. let resizeTimer;
  381. $(window).resize(function () {
  382. clearTimeout(resizeTimer);
  383. resizeTimer = setTimeout(fixScrollspy, 200);
  384. });