wowchemy.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. /*************************************************
  2. * Wowchemy
  3. * https://github.com/wowchemy/wowchemy-hugo-modules
  4. *
  5. * Core JS functions and initialization.
  6. **************************************************/
  7. import {hugoEnvironment} from '@params';
  8. import {fixMermaid} from './wowchemy-utils';
  9. import {
  10. changeThemeModeClick,
  11. initThemeVariation,
  12. renderThemeVariation,
  13. onMediaQueryListEvent,
  14. } from './wowchemy-theming';
  15. const searchEnabled = (typeof search_config !== 'undefined');
  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 = $('#navbar-main');
  23. let navbar_offset = $navbar.outerHeight();
  24. console.debug('Navbar height: ' + navbar_offset);
  25. return navbar_offset;
  26. }
  27. /**
  28. * Responsive hash scrolling.
  29. * Check for a URL hash as an anchor.
  30. * If it exists on current page, scroll to it responsively.
  31. * If `target` argument omitted (e.g. after event), assume it's the window's hash.
  32. */
  33. function scrollToAnchor(target, duration = 600) {
  34. // If `target` is undefined or HashChangeEvent object, set it to window's hash.
  35. // Decode the hash as browsers can encode non-ASCII characters (e.g. Chinese symbols).
  36. target = (typeof target === 'undefined' || typeof target === 'object') ? decodeURIComponent(window.location.hash) : target;
  37. // If target element exists, scroll to it taking into account fixed navigation bar offset.
  38. if ($(target).length) {
  39. // Escape special chars from IDs, such as colons found in Markdown footnote links.
  40. target = '#' + $.escapeSelector(target.substring(1)); // Previously, `target = target.replace(/:/g, '\\:');`
  41. let elementOffset = Math.ceil($(target).offset().top - getNavBarHeight()); // Round up to highlight right ID!
  42. $('body').addClass('scrolling');
  43. $('html, body').animate({
  44. scrollTop: elementOffset
  45. }, duration, function () {
  46. $('body').removeClass('scrolling');
  47. });
  48. } else {
  49. console.debug('Cannot scroll to target `#' + target + '`. ID not found!');
  50. }
  51. }
  52. // Make Scrollspy responsive.
  53. function fixScrollspy() {
  54. let $body = $('body');
  55. let data = $body.data('bs.scrollspy');
  56. if (data) {
  57. data._config.offset = getNavBarHeight();
  58. $body.data('bs.scrollspy', data);
  59. $body.scrollspy('refresh');
  60. }
  61. }
  62. function removeQueryParamsFromUrl() {
  63. if (window.history.replaceState) {
  64. let urlWithoutSearchParams = window.location.protocol + "//" + window.location.host + window.location.pathname + window.location.hash;
  65. window.history.replaceState({path: urlWithoutSearchParams}, '', urlWithoutSearchParams);
  66. }
  67. }
  68. // Check for hash change event and fix responsive offset for hash links (e.g. Markdown footnotes).
  69. window.addEventListener("hashchange", scrollToAnchor);
  70. /* ---------------------------------------------------------------------------
  71. * Add smooth scrolling to all links inside the main navbar.
  72. * --------------------------------------------------------------------------- */
  73. $('#navbar-main li.nav-item a.nav-link, .js-scroll').on('click', function (event) {
  74. // Store requested URL hash.
  75. let hash = this.hash;
  76. // If we are on a widget page and the navbar link is to a section on the same page.
  77. if (this.pathname === window.location.pathname && hash && $(hash).length && ($(".js-widget-page").length > 0)) {
  78. // Prevent default click behavior.
  79. event.preventDefault();
  80. // Use jQuery's animate() method for smooth page scrolling.
  81. // The numerical parameter specifies the time (ms) taken to scroll to the specified hash.
  82. let elementOffset = Math.ceil($(hash).offset().top - getNavBarHeight()); // Round up to highlight right ID!
  83. // Uncomment to debug.
  84. // let scrollTop = $(window).scrollTop();
  85. // let scrollDelta = (elementOffset - scrollTop);
  86. // console.debug('Scroll Delta: ' + scrollDelta);
  87. $('html, body').animate({
  88. scrollTop: elementOffset
  89. }, 800);
  90. }
  91. });
  92. /* ---------------------------------------------------------------------------
  93. * Hide mobile collapsable menu on clicking a link.
  94. * --------------------------------------------------------------------------- */
  95. $(document).on('click', '.navbar-collapse.show', function (e) {
  96. //get the <a> element that was clicked, even if the <span> element that is inside the <a> element is e.target
  97. let targetElement = $(e.target).is('a') ? $(e.target) : $(e.target).parent();
  98. if (targetElement.is('a') && targetElement.attr('class') != 'dropdown-toggle') {
  99. $(this).collapse('hide');
  100. }
  101. });
  102. /* ---------------------------------------------------------------------------
  103. * Filter publications.
  104. * --------------------------------------------------------------------------- */
  105. // Active publication filters.
  106. let pubFilters = {};
  107. // Search term.
  108. let searchRegex;
  109. // Filter values (concatenated).
  110. let filterValues;
  111. // Publication container.
  112. let $grid_pubs = $('#container-publications');
  113. // Initialise Isotope publication layout if required.
  114. if ($grid_pubs.length) {
  115. $grid_pubs.isotope({
  116. itemSelector: '.isotope-item',
  117. percentPosition: true,
  118. masonry: {
  119. // Use Bootstrap compatible grid layout.
  120. columnWidth: '.grid-sizer'
  121. },
  122. filter: function () {
  123. let $this = $(this);
  124. let searchResults = searchRegex ? $this.text().match(searchRegex) : true;
  125. let filterResults = filterValues ? $this.is(filterValues) : true;
  126. return searchResults && filterResults;
  127. }
  128. });
  129. // Filter by search term.
  130. let $quickSearch = $('.filter-search').keyup(debounce(function () {
  131. searchRegex = new RegExp($quickSearch.val(), 'gi');
  132. $grid_pubs.isotope();
  133. }));
  134. $('.pub-filters').on('change', function () {
  135. let $this = $(this);
  136. // Get group key.
  137. let filterGroup = $this[0].getAttribute('data-filter-group');
  138. // Set filter for group.
  139. pubFilters[filterGroup] = this.value;
  140. // Combine filters.
  141. filterValues = concatValues(pubFilters);
  142. // Activate filters.
  143. $grid_pubs.isotope();
  144. // If filtering by publication type, update the URL hash to enable direct linking to results.
  145. if (filterGroup === "pubtype") {
  146. // Set hash URL to current filter.
  147. let url = $(this).val();
  148. if (url.substr(0, 9) === '.pubtype-') {
  149. window.location.hash = url.substr(9);
  150. } else {
  151. window.location.hash = '';
  152. }
  153. }
  154. });
  155. }
  156. // Debounce input to prevent spamming filter requests.
  157. function debounce(fn, threshold) {
  158. let timeout;
  159. threshold = threshold || 100;
  160. return function debounced() {
  161. clearTimeout(timeout);
  162. let args = arguments;
  163. let _this = this;
  164. function delayed() {
  165. fn.apply(_this, args);
  166. }
  167. timeout = setTimeout(delayed, threshold);
  168. };
  169. }
  170. // Flatten object by concatenating values.
  171. function concatValues(obj) {
  172. let value = '';
  173. for (let prop in obj) {
  174. value += obj[prop];
  175. }
  176. return value;
  177. }
  178. // Filter publications according to hash in URL.
  179. function filter_publications() {
  180. // Check for Isotope publication layout.
  181. if (!$grid_pubs.length)
  182. return
  183. let urlHash = window.location.hash.replace('#', '');
  184. let filterValue = '*';
  185. // Check if hash is numeric.
  186. if (urlHash != '' && !isNaN(urlHash)) {
  187. filterValue = '.pubtype-' + urlHash;
  188. }
  189. // Set filter.
  190. let filterGroup = 'pubtype';
  191. pubFilters[filterGroup] = filterValue;
  192. filterValues = concatValues(pubFilters);
  193. // Activate filters.
  194. $grid_pubs.isotope();
  195. // Set selected option.
  196. $('.pubtype-select').val(filterValue);
  197. }
  198. /* ---------------------------------------------------------------------------
  199. * Google Maps or OpenStreetMap via Leaflet.
  200. * --------------------------------------------------------------------------- */
  201. function initMap() {
  202. if ($('#map').length) {
  203. let map_provider = $('#map-provider').val();
  204. let lat = $('#map-lat').val();
  205. let lng = $('#map-lng').val();
  206. let zoom = parseInt($('#map-zoom').val());
  207. let address = $('#map-dir').val();
  208. let api_key = $('#map-api-key').val();
  209. if (map_provider == 1) {
  210. let map = new GMaps({
  211. div: '#map',
  212. lat: lat,
  213. lng: lng,
  214. zoom: zoom,
  215. zoomControl: true,
  216. zoomControlOpt: {
  217. style: 'SMALL',
  218. position: 'TOP_LEFT'
  219. },
  220. streetViewControl: false,
  221. mapTypeControl: false,
  222. gestureHandling: "cooperative",
  223. });
  224. map.addMarker({
  225. lat: lat,
  226. lng: lng,
  227. click: function (e) {
  228. let url = 'https://www.google.com/maps/place/' + encodeURIComponent(address) + '/@' + lat + ',' + lng + '/';
  229. window.open(url, '_blank')
  230. },
  231. title: address
  232. })
  233. } else {
  234. let map = new L.map('map').setView([lat, lng], zoom);
  235. if (map_provider == 3 && api_key.length) {
  236. L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}', {
  237. attribution: 'Map data &copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="http://mapbox.com">Mapbox</a>',
  238. tileSize: 512,
  239. maxZoom: 18,
  240. zoomOffset: -1,
  241. id: 'mapbox/streets-v11',
  242. accessToken: api_key
  243. }).addTo(map);
  244. } else {
  245. L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  246. maxZoom: 19,
  247. attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
  248. }).addTo(map);
  249. }
  250. let marker = L.marker([lat, lng]).addTo(map);
  251. let url = lat + ',' + lng + '#map=' + zoom + '/' + lat + '/' + lng + '&layers=N';
  252. marker.bindPopup(address + '<p><a href="https://www.openstreetmap.org/directions?engine=osrm_car&route=' + url + '">Routing via OpenStreetMap</a></p>');
  253. }
  254. }
  255. }
  256. /* ---------------------------------------------------------------------------
  257. * GitHub API.
  258. * --------------------------------------------------------------------------- */
  259. function printLatestRelease(selector, repo) {
  260. if (hugoEnvironment === 'production') {
  261. $.getJSON('https://api.github.com/repos/' + repo + '/tags').done(function (json) {
  262. let release = json[0];
  263. $(selector).append(' ' + release.name);
  264. }).fail(function (jqxhr, textStatus, error) {
  265. let err = textStatus + ", " + error;
  266. console.log("Request Failed: " + err);
  267. });
  268. }
  269. }
  270. /* ---------------------------------------------------------------------------
  271. * Toggle search dialog.
  272. * --------------------------------------------------------------------------- */
  273. function toggleSearchDialog() {
  274. if ($('body').hasClass('searching')) {
  275. // Clear search query and hide search modal.
  276. $('[id=search-query]').blur();
  277. $('body').removeClass('searching compensate-for-scrollbar');
  278. // Remove search query params from URL as user has finished searching.
  279. removeQueryParamsFromUrl();
  280. // Prevent fixed positioned elements (e.g. navbar) moving due to scrollbars.
  281. $('#fancybox-style-noscroll').remove();
  282. } else {
  283. // Prevent fixed positioned elements (e.g. navbar) moving due to scrollbars.
  284. if (!$('#fancybox-style-noscroll').length && document.body.scrollHeight > window.innerHeight) {
  285. $('head').append(
  286. '<style id="fancybox-style-noscroll">.compensate-for-scrollbar{margin-right:' +
  287. (window.innerWidth - document.documentElement.clientWidth) +
  288. 'px;}</style>'
  289. );
  290. $('body').addClass('compensate-for-scrollbar');
  291. }
  292. // Show search modal.
  293. $('body').addClass('searching');
  294. $('.search-results').css({opacity: 0, visibility: 'visible'}).animate({opacity: 1}, 200);
  295. $('#search-query').focus();
  296. }
  297. }
  298. /* ---------------------------------------------------------------------------
  299. * Normalize Bootstrap Carousel Slide Heights.
  300. * --------------------------------------------------------------------------- */
  301. function normalizeCarouselSlideHeights() {
  302. $('.carousel').each(function () {
  303. // Get carousel slides.
  304. let items = $('.carousel-item', this);
  305. // Reset all slide heights.
  306. items.css('min-height', 0);
  307. // Normalize all slide heights.
  308. let maxHeight = Math.max.apply(null, items.map(function () {
  309. return $(this).outerHeight()
  310. }).get());
  311. items.css('min-height', maxHeight + 'px');
  312. })
  313. }
  314. /* ---------------------------------------------------------------------------
  315. * Fix Hugo's Goldmark output and Mermaid code blocks.
  316. * --------------------------------------------------------------------------- */
  317. /**
  318. * Fix Hugo's Goldmark output.
  319. */
  320. function fixHugoOutput() {
  321. // Fix Goldmark table of contents.
  322. // - Must be performed prior to initializing ScrollSpy.
  323. $('#TableOfContents').addClass('nav flex-column');
  324. $('#TableOfContents li').addClass('nav-item');
  325. $('#TableOfContents li a').addClass('nav-link');
  326. // Fix Goldmark task lists (remove bullet points).
  327. $("input[type='checkbox'][disabled]").parents('ul').addClass('task-list');
  328. }
  329. // Get an element's siblings.
  330. function getSiblings(elem) {
  331. // Filter out itself.
  332. return Array.prototype.filter.call(elem.parentNode.children, function (sibling) {
  333. return sibling !== elem;
  334. });
  335. }
  336. /* ---------------------------------------------------------------------------
  337. * On document ready.
  338. * --------------------------------------------------------------------------- */
  339. $(document).ready(function () {
  340. fixHugoOutput();
  341. fixMermaid();
  342. // Initialise code highlighting if enabled for this page.
  343. // Note: this block should be processed after the Mermaid code-->div conversion.
  344. if (code_highlighting) {
  345. hljs.initHighlighting();
  346. }
  347. // Render theme variation, including any HLJS and Mermaid themes.
  348. let {isDarkTheme, themeMode} = initThemeVariation();
  349. renderThemeVariation(isDarkTheme, themeMode, true);
  350. });
  351. /* ---------------------------------------------------------------------------
  352. * On window loaded.
  353. * --------------------------------------------------------------------------- */
  354. $(window).on('load', function () {
  355. // Init Isotope Layout Engine for instances of the Portfolio widget.
  356. let isotopeInstances = document.querySelectorAll('.projects-container');
  357. let isotopeInstancesCount = isotopeInstances.length;
  358. let isotopeCounter = 0;
  359. isotopeInstances.forEach(function (isotopeInstance, index) {
  360. console.debug(`Loading Isotope instance ${index}`);
  361. // Isotope instance
  362. let iso;
  363. // Get the layout for this Isotope instance
  364. let isoSection = isotopeInstance.closest('section');
  365. let layout = '';
  366. if (isoSection.querySelector('.isotope').classList.contains('js-layout-row')) {
  367. layout = 'fitRows';
  368. } else {
  369. layout = 'masonry';
  370. }
  371. // Get default filter (if any) for this instance
  372. let defaultFilter = isoSection.querySelector('.default-project-filter');
  373. let filterText = '*';
  374. if (defaultFilter !== null) {
  375. filterText = defaultFilter.textContent;
  376. }
  377. console.debug(`Default Isotope filter: ${filterText}`);
  378. // Init Isotope instance once its images have loaded.
  379. imagesLoaded(isotopeInstance, function () {
  380. iso = new Isotope(isotopeInstance, {
  381. itemSelector: '.isotope-item',
  382. layoutMode: layout,
  383. masonry: {
  384. gutter: 20
  385. },
  386. filter: filterText
  387. });
  388. // Filter Isotope items when a toolbar filter button is clicked.
  389. let isoFilterButtons = isoSection.querySelectorAll('.project-filters a');
  390. isoFilterButtons.forEach(button => button.addEventListener('click', (e) => {
  391. e.preventDefault();
  392. let selector = button.getAttribute('data-filter');
  393. // Apply filter
  394. console.debug(`Updating Isotope filter to ${selector}`);
  395. iso.arrange({filter: selector});
  396. // Update active toolbar filter button
  397. button.classList.remove('active');
  398. button.classList.add('active');
  399. let buttonSiblings = getSiblings(button);
  400. buttonSiblings.forEach(buttonSibling => {
  401. buttonSibling.classList.remove('active');
  402. buttonSibling.classList.remove('all');
  403. });
  404. }));
  405. // Check if all Isotope instances have loaded.
  406. incrementIsotopeCounter();
  407. });
  408. });
  409. // Hook to perform actions once all Isotope instances have loaded.
  410. function incrementIsotopeCounter() {
  411. isotopeCounter++;
  412. if (isotopeCounter === isotopeInstancesCount) {
  413. console.debug(`All Portfolio Isotope instances loaded.`);
  414. // Once all Isotope instances and their images have loaded, scroll to hash (if set).
  415. // Prevents scrolling to the wrong location due to the dynamic height of Isotope instances.
  416. // Each Isotope instance height is affected by applying filters and loading images.
  417. // Without this logic, the scroll location can appear correct, but actually a few pixels out and hence Scrollspy
  418. // can highlight the wrong nav link.
  419. if (window.location.hash) {
  420. scrollToAnchor(decodeURIComponent(window.location.hash), 0);
  421. }
  422. }
  423. }
  424. // Enable publication filter for publication index page.
  425. if ($('.pub-filters-select')) {
  426. filter_publications();
  427. // Useful for changing hash manually (e.g. in development):
  428. // window.addEventListener('hashchange', filter_publications, false);
  429. }
  430. // Load citation modal on 'Cite' click.
  431. $('.js-cite-modal').click(function (e) {
  432. e.preventDefault();
  433. let filename = $(this).attr('data-filename');
  434. let modal = $('#modal');
  435. modal.find('.modal-body code').load(filename, function (response, status, xhr) {
  436. if (status == 'error') {
  437. let msg = "Error: ";
  438. $('#modal-error').html(msg + xhr.status + " " + xhr.statusText);
  439. } else {
  440. $('.js-download-cite').attr('href', filename);
  441. }
  442. });
  443. modal.modal('show');
  444. });
  445. // Copy citation text on 'Copy' click.
  446. $('.js-copy-cite').click(function (e) {
  447. e.preventDefault();
  448. // Get selection.
  449. let range = document.createRange();
  450. let code_node = document.querySelector('#modal .modal-body');
  451. range.selectNode(code_node);
  452. window.getSelection().addRange(range);
  453. try {
  454. // Execute the copy command.
  455. document.execCommand('copy');
  456. } catch (e) {
  457. console.log('Error: citation copy failed.');
  458. }
  459. // Remove selection.
  460. window.getSelection().removeRange(range);
  461. });
  462. // Initialise Google Maps if necessary.
  463. initMap();
  464. // Print latest version of GitHub projects.
  465. let githubReleaseSelector = '.js-github-release';
  466. if ($(githubReleaseSelector).length > 0) {
  467. printLatestRelease(githubReleaseSelector, $(githubReleaseSelector).data('repo'));
  468. }
  469. // Parse Wowchemy keyboard shortcuts.
  470. document.addEventListener('keyup', (event) => {
  471. if (event.code === "Escape") {
  472. const body = document.body;
  473. if (body.classList.contains('searching')) {
  474. // Close search dialog.
  475. toggleSearchDialog();
  476. }
  477. }
  478. // Use `key` to check for slash. Otherwise, with `code` we need to check for modifiers.
  479. if (event.key === "/" ) {
  480. let focusedElement = (
  481. document.hasFocus() &&
  482. document.activeElement !== document.body &&
  483. document.activeElement !== document.documentElement &&
  484. document.activeElement
  485. ) || null;
  486. let isInputFocused = focusedElement instanceof HTMLInputElement || focusedElement instanceof HTMLTextAreaElement;
  487. if (searchEnabled && !isInputFocused) {
  488. // Open search dialog.
  489. event.preventDefault();
  490. toggleSearchDialog();
  491. }
  492. }
  493. });
  494. // Search event handler
  495. // Check that built-in search or Algolia enabled.
  496. if (searchEnabled) {
  497. // On search icon click toggle search dialog.
  498. $('.js-search').click(function (e) {
  499. e.preventDefault();
  500. toggleSearchDialog();
  501. });
  502. }
  503. // Init. author notes (tooltips).
  504. $('[data-toggle="tooltip"]').tooltip();
  505. // Re-initialize Scrollspy with dynamic navbar height offset.
  506. fixScrollspy();
  507. });
  508. // Theme chooser events.
  509. let linkLight = document.querySelector('.js-set-theme-light');
  510. let linkDark = document.querySelector('.js-set-theme-dark');
  511. let linkAuto = document.querySelector('.js-set-theme-auto');
  512. if (linkLight && linkDark && linkAuto) {
  513. linkLight.addEventListener('click', event => {
  514. event.preventDefault();
  515. changeThemeModeClick(0);
  516. });
  517. linkDark.addEventListener('click', event => {
  518. event.preventDefault();
  519. changeThemeModeClick(1);
  520. });
  521. linkAuto.addEventListener('click', event => {
  522. event.preventDefault();
  523. changeThemeModeClick(2);
  524. });
  525. }
  526. // Media Query events.
  527. // Live update of day/night mode on system preferences update (no refresh required).
  528. // Note: since we listen only for *dark* events, we won't detect other scheme changes such as light to no-preference.
  529. const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  530. darkModeMediaQuery.addEventListener("change", (event) => {
  531. onMediaQueryListEvent(event);
  532. });
  533. // Normalize Bootstrap carousel slide heights for Slider widget instances.
  534. window.addEventListener('load', normalizeCarouselSlideHeights);
  535. window.addEventListener('resize', normalizeCarouselSlideHeights);
  536. window.addEventListener('orientationchange', normalizeCarouselSlideHeights);
  537. // Automatic main menu dropdowns on mouse over.
  538. $('body').on('mouseenter mouseleave', '.dropdown', function (e) {
  539. var dropdown = $(e.target).closest('.dropdown');
  540. var menu = $('.dropdown-menu', dropdown);
  541. dropdown.addClass('show');
  542. menu.addClass('show');
  543. setTimeout(function () {
  544. dropdown[dropdown.is(':hover') ? 'addClass' : 'removeClass']('show');
  545. menu[dropdown.is(':hover') ? 'addClass' : 'removeClass']('show');
  546. }, 300);
  547. });
  548. // Call `fixScrollspy` when window is resized.
  549. let resizeTimer;
  550. $(window).resize(function () {
  551. clearTimeout(resizeTimer);
  552. resizeTimer = setTimeout(fixScrollspy, 200);
  553. });