wowchemy.js 22 KB

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