jf-roku/docs/api/scripts/third-party/tocbot.js
2023-11-11 13:41:20 +00:00

672 lines
23 KiB
JavaScript

/* eslint no-var: off */
var defaultOptions = {
ignoreSelector: '.js-toc-ignore',
linkClass: 'toc-link',
extraLinkClasses: '',
activeLinkClass: 'is-active-link',
listClass: 'toc-list',
extraListClasses: '',
isCollapsedClass: 'is-collapsed',
collapsibleClass: 'is-collapsible',
listItemClass: 'toc-list-item',
activeListItemClass: 'is-active-li',
collapseDepth: 0,
scrollSmooth: true,
scrollSmoothDuration: 420,
scrollSmoothOffset: 0,
scrollEndCallback: function (e) { },
throttleTimeout: 50,
positionFixedSelector: null,
positionFixedClass: 'is-position-fixed',
fixedSidebarOffset: 'auto',
includeHtml: false,
includeTitleTags: false,
orderedList: true,
scrollContainer: null,
skipRendering: false,
headingLabelCallback: false,
ignoreHiddenElements: false,
headingObjectCallback: null,
basePath: '',
disableTocScrollSync: false
}
function ParseContent(options) {
var reduce = [].reduce
/**
* Get the last item in an array and return a reference to it.
* @param {Array} array
* @return {Object}
*/
function getLastItem(array) {
return array[array.length - 1]
}
/**
* Get heading level for a heading dom node.
* @param {HTMLElement} heading
* @return {Number}
*/
function getHeadingLevel(heading) {
return +heading.nodeName.toUpperCase().replace('H', '')
}
/**
* Get important properties from a heading element and store in a plain object.
* @param {HTMLElement} heading
* @return {Object}
*/
function getHeadingObject(heading) {
// each node is processed twice by this method because nestHeadingsArray() and addNode() calls it
// first time heading is real DOM node element, second time it is obj
// that is causing problem so I am processing only original DOM node
if (!(heading instanceof window.HTMLElement)) return heading
if (options.ignoreHiddenElements && (!heading.offsetHeight || !heading.offsetParent)) {
return null
}
const headingLabel = heading.getAttribute('data-heading-label') ||
(options.headingLabelCallback ? String(options.headingLabelCallback(heading.textContent)) : heading.textContent.trim())
var obj = {
id: heading.id,
children: [],
nodeName: heading.nodeName,
headingLevel: getHeadingLevel(heading),
textContent: headingLabel
}
if (options.includeHtml) {
obj.childNodes = heading.childNodes
}
if (options.headingObjectCallback) {
return options.headingObjectCallback(obj, heading)
}
return obj
}
/**
* Add a node to the nested array.
* @param {Object} node
* @param {Array} nest
* @return {Array}
*/
function addNode(node, nest) {
var obj = getHeadingObject(node)
var level = obj.headingLevel
var array = nest
var lastItem = getLastItem(array)
var lastItemLevel = lastItem
? lastItem.headingLevel
: 0
var counter = level - lastItemLevel
while (counter > 0) {
lastItem = getLastItem(array)
// Handle case where there are multiple h5+ in a row.
if (lastItem && level === lastItem.headingLevel) {
break
} else if (lastItem && lastItem.children !== undefined) {
array = lastItem.children
}
counter--
}
if (level >= options.collapseDepth) {
obj.isCollapsed = true
}
array.push(obj)
return array
}
/**
* Select headings in content area, exclude any selector in options.ignoreSelector
* @param {HTMLElement} contentElement
* @param {Array} headingSelector
* @return {Array}
*/
function selectHeadings(contentElement, headingSelector) {
var selectors = headingSelector
if (options.ignoreSelector) {
selectors = headingSelector.split(',')
.map(function mapSelectors(selector) {
return selector.trim() + ':not(' + options.ignoreSelector + ')'
})
}
try {
return contentElement.querySelectorAll(selectors)
} catch (e) {
console.warn('Headers not found with selector: ' + selectors); // eslint-disable-line
return null
}
}
/**
* Nest headings array into nested arrays with 'children' property.
* @param {Array} headingsArray
* @return {Object}
*/
function nestHeadingsArray(headingsArray) {
return reduce.call(headingsArray, function reducer(prev, curr) {
var currentHeading = getHeadingObject(curr)
if (currentHeading) {
addNode(currentHeading, prev.nest)
}
return prev
}, {
nest: []
})
}
return {
nestHeadingsArray: nestHeadingsArray,
selectHeadings: selectHeadings
}
}
function BuildHtml(options) {
var forEach = [].forEach
var some = [].some
var body = document.body
var tocElement
var mainContainer = document.querySelector(options.contentSelector)
var currentlyHighlighting = true
var SPACE_CHAR = ' '
/**
* Create link and list elements.
* @param {Object} d
* @param {HTMLElement} container
* @return {HTMLElement}
*/
function createEl(d, container) {
var link = container.appendChild(createLink(d))
if (d.children.length) {
var list = createList(d.isCollapsed)
d.children.forEach(function (child) {
createEl(child, list)
})
link.appendChild(list)
}
}
/**
* Render nested heading array data into a given element.
* @param {HTMLElement} parent Optional. If provided updates the {@see tocElement} to match.
* @param {Array} data
* @return {HTMLElement}
*/
function render(parent, data) {
var collapsed = false
var container = createList(collapsed)
data.forEach(function (d) {
createEl(d, container)
})
// Return if no TOC element is provided or known.
tocElement = parent || tocElement
if (tocElement === null) {
return
}
// Remove existing child if it exists.
if (tocElement.firstChild) {
tocElement.removeChild(tocElement.firstChild)
}
// Just return the parent and don't append the list if no links are found.
if (data.length === 0) {
return tocElement
}
// Append the Elements that have been created
return tocElement.appendChild(container)
}
/**
* Create link element.
* @param {Object} data
* @return {HTMLElement}
*/
function createLink(data) {
var item = document.createElement('li')
var a = document.createElement('a')
if (options.listItemClass) {
item.setAttribute('class', options.listItemClass)
}
if (options.onClick) {
a.onclick = options.onClick
}
if (options.includeTitleTags) {
a.setAttribute('title', data.textContent)
}
if (options.includeHtml && data.childNodes.length) {
forEach.call(data.childNodes, function (node) {
a.appendChild(node.cloneNode(true))
})
} else {
// Default behavior.
a.textContent = data.textContent
}
a.setAttribute('href', options.basePath + '#' + data.id)
a.setAttribute('class', options.linkClass +
SPACE_CHAR + 'node-name--' + data.nodeName +
SPACE_CHAR + options.extraLinkClasses)
item.appendChild(a)
return item
}
/**
* Create list element.
* @param {Boolean} isCollapsed
* @return {HTMLElement}
*/
function createList(isCollapsed) {
var listElement = (options.orderedList) ? 'ol' : 'ul'
var list = document.createElement(listElement)
var classes = options.listClass +
SPACE_CHAR + options.extraListClasses
if (isCollapsed) {
classes += SPACE_CHAR + options.collapsibleClass
classes += SPACE_CHAR + options.isCollapsedClass
}
list.setAttribute('class', classes)
return list
}
/**
* Update fixed sidebar class.
* @return {HTMLElement}
*/
function updateFixedSidebarClass() {
if (options.scrollContainer && document.querySelector(options.scrollContainer)) {
var top
top = document.querySelector(options.scrollContainer).scrollTop
} else {
top = document.documentElement.scrollTop || body.scrollTop
}
var posFixedEl = document.querySelector(options.positionFixedSelector)
if (options.fixedSidebarOffset === 'auto') {
options.fixedSidebarOffset = tocElement.offsetTop
}
if (top > options.fixedSidebarOffset) {
if (posFixedEl.className.indexOf(options.positionFixedClass) === -1) {
posFixedEl.className += SPACE_CHAR + options.positionFixedClass
}
} else {
posFixedEl.className = posFixedEl.className.split(SPACE_CHAR + options.positionFixedClass).join('')
}
}
/**
* Get top position of heading
* @param {HTMLElement} obj
* @return {int} position
*/
function getHeadingTopPos(obj) {
var position = 0
if (obj !== null) {
position = obj.offsetTop
if (options.hasInnerContainers) { position += getHeadingTopPos(obj.offsetParent) }
}
return position
}
function updateListActiveElement(topHeader) {
var forEach = [].forEach
var tocLinks = tocElement
.querySelectorAll('.' + options.linkClass)
forEach.call(tocLinks, function (tocLink) {
tocLink.className = tocLink.className.split(SPACE_CHAR + options.activeLinkClass).join('')
})
var tocLis = tocElement
.querySelectorAll('.' + options.listItemClass)
forEach.call(tocLis, function (tocLi) {
tocLi.className = tocLi.className.split(SPACE_CHAR + options.activeListItemClass).join('')
})
// Add the active class to the active tocLink.
var activeTocLink = tocElement
.querySelector('.' + options.linkClass +
'.node-name--' + topHeader.nodeName +
'[href="' + options.basePath + '#' + topHeader.id.replace(/([ #;&,.+*~':"!^$[\]()=>|/@])/g, '\\$1') + '"]')
if (activeTocLink && activeTocLink.className.indexOf(options.activeLinkClass) === -1) {
activeTocLink.className += SPACE_CHAR + options.activeLinkClass
}
var li = activeTocLink && activeTocLink.parentNode
if (li && li.className.indexOf(options.activeListItemClass) === -1) {
li.className += SPACE_CHAR + options.activeListItemClass
}
var tocLists = tocElement
.querySelectorAll('.' + options.listClass + '.' + options.collapsibleClass)
// Collapse the other collapsible lists.
forEach.call(tocLists, function (list) {
if (list.className.indexOf(options.isCollapsedClass) === -1) {
list.className += SPACE_CHAR + options.isCollapsedClass
}
})
// Expand the active link's collapsible list and its sibling if applicable.
if (activeTocLink && activeTocLink.nextSibling && activeTocLink.nextSibling.className.indexOf(options.isCollapsedClass) !== -1) {
activeTocLink.nextSibling.className = activeTocLink.nextSibling.className.split(SPACE_CHAR + options.isCollapsedClass).join('')
}
removeCollapsedFromParents(activeTocLink && activeTocLink.parentNode.parentNode)
}
/**
* Update TOC highlighting and collpased groupings.
*/
function updateToc(headingsArray) {
// If a fixed content container was set
if (options.scrollContainer && document.querySelector(options.scrollContainer)) {
var top
top = document.querySelector(options.scrollContainer).scrollTop
} else {
top = document.documentElement.scrollTop || body.scrollTop
}
// Add fixed class at offset
if (options.positionFixedSelector) {
updateFixedSidebarClass()
}
// Get the top most heading currently visible on the page so we know what to highlight.
var headings = headingsArray
var topHeader
// Using some instead of each so that we can escape early.
if (currentlyHighlighting &&
tocElement !== null &&
headings.length > 0) {
some.call(headings, function (heading, i) {
var modifiedTopOffset = top + 10
if (mainContainer) {
modifiedTopOffset += mainContainer.clientHeight * (mainContainer.scrollTop) / (mainContainer.scrollHeight - mainContainer.clientHeight)
}
if (getHeadingTopPos(heading) > modifiedTopOffset) {
// Don't allow negative index value.
var index = (i === 0) ? i : i - 1
topHeader = headings[index]
return true
} else if (i === headings.length - 1) {
// This allows scrolling for the last heading on the page.
topHeader = headings[headings.length - 1]
return true
}
})
// Remove the active class from the other tocLinks.
updateListActiveElement(topHeader)
}
}
/**
* Remove collpased class from parent elements.
* @param {HTMLElement} element
* @return {HTMLElement}
*/
function removeCollapsedFromParents(element) {
if (element && element.className.indexOf(options.collapsibleClass) !== -1 && element.className.indexOf(options.isCollapsedClass) !== -1) {
element.className = element.className.split(SPACE_CHAR + options.isCollapsedClass).join('')
return removeCollapsedFromParents(element.parentNode.parentNode)
}
return element
}
/**
* Disable TOC Animation when a link is clicked.
* @param {Event} event
*/
function disableTocAnimation(event) {
var target = event.target || event.srcElement
if (typeof target.className !== 'string' || target.className.indexOf(options.linkClass) === -1) {
return
}
// Bind to tocLink clicks to temporarily disable highlighting
// while smoothScroll is animating.
currentlyHighlighting = false
}
/**
* Enable TOC Animation.
*/
function enableTocAnimation() {
currentlyHighlighting = true
}
return {
enableTocAnimation: enableTocAnimation,
disableTocAnimation: disableTocAnimation,
render: render,
updateToc: updateToc,
updateListActiveElement: updateListActiveElement
}
}
function updateTocScroll(options) {
var toc = options.tocElement || document.querySelector(options.tocSelector)
if (toc && toc.scrollHeight > toc.clientHeight) {
var activeItem = toc.querySelector('.' + options.activeListItemClass)
if (activeItem) {
var topOffset = toc.getBoundingClientRect().top
toc.scrollTop = activeItem.offsetTop - topOffset
}
}
}
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory(root))
} else if (typeof exports === 'object') {
module.exports = factory(root)
} else {
root.tocbot = factory(root)
}
})(typeof global !== 'undefined' ? global : this.window || this.global, function (root) {
'use strict'
var options = {}
var tocbot = {}
var buildHtml
var parseContent
// Just return if its not a browser.
var supports = !!root && !!root.document && !!root.document.querySelector && !!root.addEventListener // Feature test
if (typeof window === 'undefined' && !supports) {
return
}
var headingsArray
// From: https://github.com/Raynos/xtend
var hasOwnProperty = Object.prototype.hasOwnProperty
function extend() {
var target = {}
for (var i = 0; i < arguments.length; i++) {
var source = arguments[i]
for (var key in source) {
if (hasOwnProperty.call(source, key)) {
target[key] = source[key]
}
}
}
return target
}
// From: https://remysharp.com/2010/07/21/throttling-function-calls
function throttle(fn, threshhold, scope) {
threshhold || (threshhold = 250)
var last
var deferTimer
return function () {
var context = scope || this
var now = +new Date()
var args = arguments
if (last && now < last + threshhold) {
// hold on to it
clearTimeout(deferTimer)
deferTimer = setTimeout(function () {
last = now
fn.apply(context, args)
}, threshhold)
} else {
last = now
fn.apply(context, args)
}
}
}
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
}
}
/**
* Destroy tocbot.
*/
tocbot.destroy = function () {
var 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', this._scrollListener, false)
document.querySelector(options.scrollContainer).removeEventListener('resize', this._scrollListener, false)
} else {
document.removeEventListener('scroll', this._scrollListener, false)
document.removeEventListener('resize', this._scrollListener, false)
}
}
/**
* Initialize tocbot.
* @param {object} customOptions
*/
tocbot.init = function (customOptions) {
// feature test
if (!supports) {
return
}
// Merge defaults with user options.
// Set to options variable at the top.
options = extend(defaultOptions, customOptions || {})
this.options = options
this.state = {}
// Init smooth scroll if enabled (default).
if (options.scrollSmooth) {
options.duration = options.scrollSmoothDuration
options.offset = options.scrollSmoothOffset
}
// Pass options to these modules.
buildHtml = BuildHtml(options)
parseContent = ParseContent(options)
// For testing purposes.
this._buildHtml = buildHtml
this._parseContent = parseContent
this._headingsArray = headingsArray
this.updateTocListActiveElement = buildHtml.updateListActiveElement
// Destroy it if it exists first.
tocbot.destroy()
var contentElement = getContentElement(options)
if (contentElement === null) {
return
}
var 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.
var nestedHeadingsObj = parseContent.nestHeadingsArray(headingsArray)
var nestedHeadings = nestedHeadingsObj.nest
// Render.
if (!options.skipRendering) {
buildHtml.render(tocElement, nestedHeadings)
}
// Update Sidebar and bind listeners.
this._scrollListener = throttle(function (e) {
buildHtml.updateToc(headingsArray)
!options.disableTocScrollSync && updateTocScroll(options)
var isTop = e && e.target && e.target.scrollingElement && e.target.scrollingElement.scrollTop === 0
if ((e && (e.eventPhase === 0 || e.currentTarget === null)) || isTop) {
buildHtml.updateToc(headingsArray)
if (options.scrollEndCallback) {
options.scrollEndCallback(e)
}
}
}, options.throttleTimeout)
this._scrollListener()
if (options.scrollContainer && document.querySelector(options.scrollContainer)) {
document.querySelector(options.scrollContainer).addEventListener('scroll', this._scrollListener, false)
document.querySelector(options.scrollContainer).addEventListener('resize', this._scrollListener, false)
} else {
document.addEventListener('scroll', this._scrollListener, false)
document.addEventListener('resize', this._scrollListener, false)
}
return this
}
/**
* Refresh tocbot.
*/
tocbot.refresh = function (customOptions) {
tocbot.destroy()
tocbot.init(customOptions || this.options)
}
// Make tocbot available globally.
root.tocbot = tocbot
return tocbot
})