greenelab/deep-review

View on GitHub
build/plugins/table-of-contents.html

Summary

Maintainability
Test Coverage
<script>
(function() {
// /////////////////////////
// DESCRIPTION
// /////////////////////////
 
// This Manubot plugin provides a "table of contents" (toc) panel on
// the side of the document that allows the user to conveniently
// navigate between sections of the document.
 
// /////////////////////////
// OPTIONS
// /////////////////////////
 
// plugin name prefix for url parameters
const pluginName = 'tableOfContents';
 
// default plugin options
const options = {
// which types of elements to add links for, in
// "document.querySelector" format
typesQuery: 'h1, h2, h3',
// whether toc starts open. use 'true' or 'false', or 'auto' to
// use 'true' behavior when screen wide enough and 'false' when not
startOpen: 'false',
// whether toc closes when clicking on toc link. use 'true' or
// 'false', or 'auto' to use 'false' behavior when screen wide
// enough and 'true' when not
clickClose: 'auto',
// if list item is more than this many characters, text will be
// truncated
charLimit: '50',
// whether or not to show bullets next to each toc item
bullets: 'false',
// whether plugin is on or not
enabled: 'true'
};
 
// change options above, or override with url parameter, eg:
// 'manuscript.html?pluginName-enabled=false'
 
// /////////////////////////
// SCRIPT
// /////////////////////////
 
// start script
function start() {
// make toc panel and populate with entries (links to document
// sections)
const panel = makePanel();
if (!panel)
return;
makeEntries(panel);
// attach panel to document after making entries, so 'toc' heading
// in panel isn't included in toc
document.body.insertBefore(panel, document.body.firstChild);
 
// initial panel state
if (
options.startOpen === 'true' ||
(options.startOpen === 'auto' && !isSmallScreen())
)
openPanel();
else
closePanel();
 
// attach click, scroll, and hash change listeners to window
window.addEventListener('click', onClick);
window.addEventListener('scroll', onScroll);
window.addEventListener('hashchange', onScroll);
window.addEventListener('keyup', onKeyUp);
onScroll();
 
// add class to push document body down out of way of toc button
document.body.classList.add('toc_body_nudge');
}
 
// determine if screen wide enough to fit toc panel
function isSmallScreen() {
// in default theme:
// 816px = 8.5in = width of "page" (<body>) element
// 260px = min width of toc panel (*2 for both sides of <body>)
return window.innerWidth < 816 + 260 * 2;
}
 
// when mouse is clicked anywhere in window
function onClick() {
if (isSmallScreen())
closePanel();
}
 
// when window is scrolled or hash changed
function onScroll() {
highlightViewed();
}
 
// when key pressed
function onKeyUp(event) {
if (!event || !event.key)
return;
 
// close on esc
if (event.key === 'Escape')
closePanel();
}
 
// find entry of currently viewed document section in toc and highlight
function highlightViewed() {
const firstId = getFirstInView(options.typesQuery);
 
// get toc entries (links), unhighlight all, then highlight viewed
const list = document.getElementById('toc_list');
if (!firstId || !list)
return;
const links = list.querySelectorAll('a');
for (const link of links)
link.dataset.viewing = 'false';
const link = list.querySelector('a[href="#' + firstId + '"]');
if (!link)
return;
link.dataset.viewing = 'true';
}
 
// get first or previous toc listed element in top half of view
function getFirstInView(query) {
// get all elements matching query and with id
const elements = document.querySelectorAll(query);
const elementsWithIds = [];
for (const element of elements) {
if (element.id)
elementsWithIds.push(element);
}
 
 
// get first or previous element in top half of view
for (let i = 0; i < elementsWithIds.length; i++) {
const element = elementsWithIds[i];
const prevElement = elementsWithIds[Math.max(0, i - 1)];
if (element.getBoundingClientRect().top >= 0) {
if (
element.getBoundingClientRect().top <
window.innerHeight / 2
)
return element.id;
else
return prevElement.id;
}
}
}
 
// make panel
function makePanel() {
// create panel
const panel = document.createElement('div');
panel.id = 'toc_panel';
if (options.bullets === 'true')
panel.dataset.bullets = 'true';
 
// create header
const header = document.createElement('div');
header.id = 'toc_header';
 
// create toc button
const button = document.createElement('button');
button.id = 'toc_button';
button.innerHTML = document.querySelector('.icon_th_list').innerHTML;
button.title = 'Table of Contents';
button.classList.add('icon_button');
 
// create header text
const text = document.createElement('h4');
text.innerHTML = 'Table of Contents';
 
// create container for toc list
const list = document.createElement('div');
list.id = 'toc_list';
 
// attach click listeners
panel.addEventListener('click', onPanelClick);
header.addEventListener('click', onHeaderClick);
button.addEventListener('click', onButtonClick);
 
// attach elements
header.appendChild(button);
header.appendChild(text);
panel.appendChild(header);
panel.appendChild(list);
 
return panel;
}
 
// create toc entries (links) to each element of the specified types
function makeEntries(panel) {
const elements = document.querySelectorAll(options.typesQuery);
for (const element of elements) {
// do not add link if element doesn't have assigned id
if (!element.id)
continue;
 
// create link/list item
const link = document.createElement('a');
link.classList.add('toc_link');
switch (element.tagName.toLowerCase()) {
case 'h1':
link.dataset.level = '1';
break;
case 'h2':
link.dataset.level = '2';
break;
case 'h3':
link.dataset.level = '3';
break;
case 'h4':
link.dataset.level = '4';
break;
}
link.title = element.innerText;
let text = element.innerText;
if (text.length > options.charLimit)
text = text.slice(0, options.charLimit) + '...';
link.innerHTML = text;
link.href = '#' + element.id;
link.addEventListener('click', onLinkClick);
 
// attach link
panel.querySelector('#toc_list').appendChild(link);
}
}
 
// when panel is clicked
function onPanelClick(event) {
// stop click from propagating to window/document and closing panel
event.stopPropagation();
}
 
// when header itself is clicked
function onHeaderClick(event) {
togglePanel();
}
 
// when button is clicked
function onButtonClick(event) {
togglePanel();
// stop header underneath button from also being clicked
event.stopPropagation();
}
 
// when link is clicked
function onLinkClick(event) {
if (
options.clickClose === 'true' ||
(options.clickClose === 'auto' && isSmallScreen())
)
closePanel();
else
openPanel();
}
 
// open panel if closed, close if opened
function togglePanel() {
const panel = document.getElementById('toc_panel');
if (!panel)
return;
 
if (panel.dataset.open === 'true')
closePanel();
else
openPanel();
}
 
// open panel
function openPanel() {
const panel = document.getElementById('toc_panel');
if (panel)
panel.dataset.open = 'true';
}
 
// close panel
function closePanel() {
const panel = document.getElementById('toc_panel');
if (panel)
panel.dataset.open = 'false';
}
 
// load options from url parameters
function loadOptions() {
const url = window.location.search;
const params = new URLSearchParams(url);
for (const optionName of Object.keys(options)) {
const paramName = pluginName + '-' + optionName;
const param = params.get(paramName);
if (param !== '' && param !== null)
options[optionName] = param;
}
}
loadOptions();
 
// start script when document is finished loading
if (options.enabled === 'true')
window.addEventListener('load', start);
})();
</script>
 
<!-- th list icon -->
 
<template class="icon_th_list">
<!-- modified from: https://fontawesome.com/icons/th-list -->
<svg width="16" height="16" viewBox="0 0 512 512" tabindex="-1">
<path
fill="currentColor"
d="M96 96c0 26.51-21.49 48-48 48S0 122.51 0 96s21.49-48 48-48 48 21.49 48 48zM48 208c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zm0 160c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zm96-236h352c8.837 0 16-7.163 16-16V76c0-8.837-7.163-16-16-16H144c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16zm0 160h352c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H144c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16zm0 160h352c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H144c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16z"
tabindex="-1"
></path>
</svg>
</template>