UNPKG

tocbot

Version:

Generate a table of contents based on the heading structure of a html document.

320 lines (285 loc) 9.06 kB
/* eslint no-var: off */ /** * Tocbot * Tocbot creates a table of contents based on HTML headings on a page, * this allows users to easily jump to different sections of the document. * Tocbot was inspired by tocify (http://gregfranko.com/jquery.tocify.js/). * The main differences are that it works natively without any need for jquery or jquery UI). * * @author Tim Scanlin */ import BuildHtml from "./build-html.js" import defaultOptions from "./default-options.js" import ParseContent from "./parse-content.js" import initSmoothScrolling from "./scroll-smooth/index.js" import updateTocScroll from "./update-toc-scroll.js" // For testing purposes. export let _options = {} // Object to store current options. export let _buildHtml export let _parseContent export let _headingsArray export let _scrollListener let clickListener /** * Initialize tocbot. * @param {object} customOptions */ export function init(customOptions) { // Merge defaults with user options. // Set to options variable at the top. let hasInitialized = false _options = extend(defaultOptions, customOptions || {}) // Init smooth scroll if enabled (default). if (_options.scrollSmooth) { _options.duration = _options.scrollSmoothDuration _options.offset = _options.scrollSmoothOffset initSmoothScrolling(_options) } // Pass options to these modules. _buildHtml = BuildHtml(_options) _parseContent = ParseContent(_options) // Destroy it if it exists first. destroy() const contentElement = getContentElement(_options) if (contentElement === null) { return } const tocElement = getTocElement(_options) if (tocElement === null) { return } // Get headings array. _headingsArray = _parseContent.selectHeadings( contentElement, _options.headingSelector, ) // Return if no headings are found. if (_headingsArray === null) { return } // Build nested headings array. const nestedHeadingsObj = _parseContent.nestHeadingsArray(_headingsArray) const nestedHeadings = nestedHeadingsObj.nest // Render. if (!_options.skipRendering) { _buildHtml.render(tocElement, nestedHeadings) } else { // No need to attach listeners if skipRendering is true, this was causing errors. return this } // Update Sidebar and bind listeners. let isClick = false // choose timeout by _options const scrollHandlerTimeout = _options.scrollHandlerTimeout || _options.throttleTimeout // compatible with legacy configurations // choose debounce or throttle // default use debounce when delay is less than 333ms // the reason is ios browser has a limit : can't use history.pushState() more than 100 times per 30 seconds const scrollHandler = (fn, delay) => getScrollHandler(fn, delay, _options.scrollHandlerType) _scrollListener = scrollHandler((e) => { _buildHtml.updateToc(_headingsArray, e) // Only do this update for normal scrolls and not during clicks. !_options.disableTocScrollSync && !isClick && updateTocScroll(_options) if (_options.enableUrlHashUpdateOnScroll && hasInitialized) { const enableUpdatingHash = _buildHtml.getCurrentlyHighlighting() enableUpdatingHash && _buildHtml.updateUrlHashForHeader(_headingsArray) } const isTop = e?.target?.scrollingElement?.scrollTop === 0 if ((e && (e.eventPhase === 0 || e.currentTarget === null)) || isTop) { _buildHtml.updateToc(_headingsArray) _options.scrollEndCallback?.(e) } }, scrollHandlerTimeout) // Fire it initially to setup the page. if (!hasInitialized) { _scrollListener() hasInitialized = true } // Fire scroll listener on hash change to trigger highlighting changes too. window.onhashchange = window.onscrollend = (e) => { _scrollListener(e) } if ( _options.scrollContainer && document.querySelector(_options.scrollContainer) ) { document .querySelector(_options.scrollContainer) .addEventListener("scroll", _scrollListener, false) document .querySelector(_options.scrollContainer) .addEventListener("resize", _scrollListener, false) } else { document.addEventListener("scroll", _scrollListener, false) document.addEventListener("resize", _scrollListener, false) } // Bind click listeners to disable animation. let timeout = null clickListener = throttle((event) => { isClick = true if (_options.scrollSmooth) { _buildHtml.disableTocAnimation(event) } _buildHtml.updateToc(_headingsArray, event) // Timeout to re-enable the animation. timeout && clearTimeout(timeout) timeout = setTimeout(() => { _buildHtml.enableTocAnimation() }, _options.scrollSmoothDuration) // Set is click w/ a bit of delay so that animations can finish // and we don't disturb the user while they click the toc. setTimeout(() => { isClick = false }, _options.scrollSmoothDuration + 100) }, _options.throttleTimeout) if ( _options.scrollContainer && document.querySelector(_options.scrollContainer) ) { document .querySelector(_options.scrollContainer) .addEventListener("click", clickListener, false) } else { document.addEventListener("click", clickListener, false) } } /** * Destroy tocbot. */ export function destroy() { const tocElement = getTocElement(_options) if (tocElement === null) { return } if (!_options.skipRendering) { // Clear HTML. if (tocElement) { tocElement.innerHTML = "" } } // Remove event listeners. if ( _options.scrollContainer && document.querySelector(_options.scrollContainer) ) { document .querySelector(_options.scrollContainer) .removeEventListener("scroll", _scrollListener, false) document .querySelector(_options.scrollContainer) .removeEventListener("resize", _scrollListener, false) if (_buildHtml) { document .querySelector(_options.scrollContainer) .removeEventListener("click", clickListener, false) } } else { document.removeEventListener("scroll", _scrollListener, false) document.removeEventListener("resize", _scrollListener, false) if (_buildHtml) { document.removeEventListener("click", clickListener, false) } } } /** * Refresh tocbot. */ export function refresh(customOptions) { destroy() init(customOptions || _options) } // From: https://github.com/Raynos/xtend const hasOwnProp = Object.prototype.hasOwnProperty function extend(...args) { const target = {} for (let i = 0; i < args.length; i++) { const source = args[i] for (const key in source) { if (hasOwnProp.call(source, key)) { target[key] = source[key] } } } return target } // From: https://remysharp.com/2010/07/21/throttling-function-calls function throttle(fn, threshold, scope) { threshold || (threshold = 250) let last let deferTimer return function (...args) { const context = scope || this const now = +new Date() if (last && now < last + threshold) { // hold on to it clearTimeout(deferTimer) deferTimer = setTimeout(() => { last = now fn.apply(context, args) }, threshold) } else { last = now fn.apply(context, args) } } } /** * Creates a debounced function that delays invoking `func` until after `wait` milliseconds * have elapsed since the last time the debounced function was invoked. * * @param {Function} func - The function to debounce. * @param {number} wait - The number of milliseconds to delay. * @returns {Function} - Returns the new debounced function. */ function debounce(func, wait) { let timeout return (...args) => { clearTimeout(timeout) timeout = setTimeout(() => func.apply(this, args), wait) } } /** * Creates a scroll handler with specified timeout strategy * @param {number} timeout - Delay duration in milliseconds * @param {'debounce'|'throttle'|'auto'} type - Strategy type for scroll handling * @returns {Function} Configured scroll handler function */ function getScrollHandler(func, timeout, type = "auto") { switch (type) { case "debounce": return debounce(func, timeout) case "throttle": return throttle(func, timeout) default: return timeout < 334 ? debounce(func, timeout) : throttle(func, timeout) } } function getContentElement(options) { try { return ( options.contentElement || document.querySelector(options.contentSelector) ) } catch (e) { console.warn(`Contents element not found: ${options.contentSelector}`) // eslint-disable-line return null } } function getTocElement(options) { try { return options.tocElement || document.querySelector(options.tocSelector) } catch (e) { console.warn(`TOC element not found: ${options.tocSelector}`) // eslint-disable-line return null } } // Add default export for easier use. const tocbot = { _options, _buildHtml, _parseContent, init, destroy, refresh, } export default tocbot