greenelab/deep-review

View on GitHub
build/plugins/tooltips.html

Summary

Maintainability
Test Coverage
<!-- tooltips plugin -->
 
<script>
(function() {
// /////////////////////////
// DESCRIPTION
// /////////////////////////
 
// This Manubot plugin makes it such that when the user hovers or
// focuses a link to a citation or figure, a tooltip appears with a
// preview of the reference content, along with arrows to navigate
// between instances of the same reference in the document.
 
// /////////////////////////
// OPTIONS
// /////////////////////////
 
// plugin name prefix for url parameters
const pluginName = 'tooltips';
 
// default plugin options
const options = {
// whether user must click off to close tooltip instead of just
// un-hovering
clickClose: 'false',
// delay (in ms) between opening and closing tooltip
delay: '100',
// 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() {
const links = getLinks();
for (const link of links) {
// attach hover and focus listeners to link
link.addEventListener('mouseover', onLinkHover);
link.addEventListener('mouseleave', onLinkUnhover);
link.addEventListener('focus', onLinkFocus);
link.addEventListener('touchend', onLinkTouch);
}
 
// attach mouse, key, and resize listeners to window
window.addEventListener('mousedown', onClick);
window.addEventListener('touchstart', onClick);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('resize', onResize);
}
 
// when link is hovered
function onLinkHover() {
// function to open tooltip
const delayOpenTooltip = function() {
openTooltip(this);
}.bind(this);
 
// run open function after delay
this.openTooltipTimer = window.setTimeout(
delayOpenTooltip,
options.delay
);
}
 
// when mouse leaves link
function onLinkUnhover() {
// cancel opening tooltip
window.clearTimeout(this.openTooltipTimer);
 
// don't close on unhover if option specifies
if (options.clickClose === 'true')
return;
 
// function to close tooltip
const delayCloseTooltip = function() {
// if tooltip open and if mouse isn't over tooltip, close
const tooltip = document.getElementById('tooltip');
if (tooltip && !tooltip.matches(':hover'))
closeTooltip();
};
 
// run close function after delay
this.closeTooltipTimer = window.setTimeout(
delayCloseTooltip,
options.delay
);
}
 
// when link is focused (tabbed to)
function onLinkFocus(event) {
openTooltip(this);
}
 
// when link is touched on touch screen
function onLinkTouch(event) {
// attempt to force hover state on first tap always, and trigger
// regular link click (and navigation) on second tap
if (event.target === document.activeElement)
event.target.click();
else {
document.activeElement.blur();
event.target.focus();
}
if (event.cancelable)
event.preventDefault();
event.stopPropagation();
return false;
}
 
// when mouse is clicked anywhere in window
function onClick(event) {
closeTooltip();
}
 
// when key pressed
function onKeyUp(event) {
if (!event || !event.key)
return;
 
switch (event.key) {
// trigger click of prev button
case 'ArrowLeft':
const prevButton = document.getElementById(
'tooltip_prev_button'
);
if (prevButton)
prevButton.click();
break;
// trigger click of next button
case 'ArrowRight':
const nextButton = document.getElementById(
'tooltip_next_button'
);
if (nextButton)
nextButton.click();
break;
// close on esc
case 'Escape':
closeTooltip();
break;
}
}
 
// when window is resized or zoomed
function onResize() {
closeTooltip();
}
 
// get all links of types we wish to handle
function getLinks() {
const queries = [];
// exclude buttons, anchor links, toc links, etc
const exclude =
':not(.button):not(.icon_button):not(.anchor):not(.toc_link)';
queries.push('a[href^="#ref-"]' + exclude); // citation links
queries.push('a[href^="#fig:"]' + exclude); // figure links
const query = queries.join(', ');
return document.querySelectorAll(query);
}
 
// get links with same target, get index of link in set, get total
// same links
function getSameLinks(link) {
const sameLinks = [];
const links = getLinks();
for (const otherLink of links) {
if (
otherLink.getAttribute('href') === link.getAttribute('href')
)
sameLinks.push(otherLink);
}
 
return {
elements: sameLinks,
index: sameLinks.indexOf(link),
total: sameLinks.length
};
}
 
// open tooltip
function openTooltip(link) {
// delete tooltip if it exists, start fresh
closeTooltip();
 
// make tooltip element
const tooltip = makeTooltip(link);
 
// if source couldn't be found and tooltip not made, exit
if (!tooltip)
return;
 
// make navbar elements
const navBar = makeNavBar(link);
if (navBar)
tooltip.firstElementChild.appendChild(navBar);
 
// attach tooltip to page
document.body.appendChild(tooltip);
 
// position tooltip
const position = function() {
positionTooltip(link);
};
position();
 
// if tooltip contains images, position again after they've loaded
const imgs = tooltip.querySelectorAll('img');
for (const img of imgs)
img.addEventListener('load', position);
}
 
// close (delete) tooltip
function closeTooltip() {
const tooltip = document.getElementById('tooltip');
if (tooltip)
tooltip.remove();
}
 
// make tooltip
function makeTooltip(link) {
// get target element that link points to
const source = getSource(link);
 
// if source can't be found, exit
if (!source)
return;
 
// create new tooltip
const tooltip = document.createElement('div');
tooltip.id = 'tooltip';
const tooltipContent = document.createElement('div');
tooltipContent.id = 'tooltip_content';
tooltip.appendChild(tooltipContent);
 
// make copy of source node and put in tooltip
const sourceCopy = makeCopy(source);
tooltipContent.appendChild(sourceCopy);
 
// attach mouse event listeners
tooltip.addEventListener('click', onTooltipClick);
tooltip.addEventListener('mousedown', onTooltipClick);
tooltip.addEventListener('touchstart', onTooltipClick);
tooltip.addEventListener('mouseleave', onTooltipUnhover);
 
// (for interaction with lightbox plugin)
// transfer click on tooltip copied img to original img
const sourceImg = source.querySelector('img');
const sourceCopyImg = sourceCopy.querySelector('img');
if (sourceImg && sourceCopyImg) {
const clickImg = function() {
sourceImg.click();
closeTooltip();
};
sourceCopyImg.addEventListener('click', clickImg);
}
 
return tooltip;
}
 
// make carbon copy of html dom element
function makeCopy(source) {
const sourceCopy = source.cloneNode(true);
 
// delete elements marked with ignore (eg anchor and jump buttons)
const deleteFromCopy = sourceCopy.querySelectorAll(
'[data-ignore="true"]'
);
for (const element of deleteFromCopy)
element.remove();
 
// delete certain element attributes
const attributes = [
'id',
'data-collapsed',
'data-selected',
'data-highlighted',
'data-glow'
];
for (const attribute of attributes) {
sourceCopy.removeAttribute(attribute);
const elements = sourceCopy.querySelectorAll(
'[' + attribute + ']'
);
for (const element of elements)
element.removeAttribute(attribute);
}
 
return sourceCopy;
}
 
// when tooltip is clicked
function onTooltipClick(event) {
// when user clicks on tooltip, stop click from transferring
// outside of tooltip (eg, click off to close tooltip, or eg click
// off to unhighlight same refs)
event.stopPropagation();
}
 
// when tooltip is unhovered
function onTooltipUnhover(event) {
if (options.clickClose === 'true')
return;
 
// make sure new mouse/touch/focus no longer over tooltip or any
// element within it
const tooltip = document.getElementById('tooltip');
if (!tooltip)
return;
if (this.contains(event.relatedTarget))
return;
 
closeTooltip();
}
 
// make nav bar to go betwen prev/next instances of same reference
function makeNavBar(link) {
// find other links to the same source
const sameLinks = getSameLinks(link);
 
// don't show nav bar when singular reference
if (sameLinks.total <= 1)
return;
 
// find prev/next links with same target
const prevLink = getPrevLink(link, sameLinks);
const nextLink = getNextLink(link, sameLinks);
 
// create nav bar
const navBar = document.createElement('div');
navBar.id = 'tooltip_nav_bar';
const text = sameLinks.index + 1 + ' of ' + sameLinks.total;
 
// create nav bar prev/next buttons
const prevButton = document.createElement('button');
const nextButton = document.createElement('button');
prevButton.id = 'tooltip_prev_button';
nextButton.id = 'tooltip_next_button';
prevButton.title =
'Jump to the previous occurence of this item in the document [←]';
nextButton.title =
'Jump to the next occurence of this item in the document [→]';
prevButton.classList.add('icon_button');
nextButton.classList.add('icon_button');
prevButton.innerHTML = document.querySelector(
'.icon_caret_left'
).innerHTML;
nextButton.innerHTML = document.querySelector(
'.icon_caret_right'
).innerHTML;
navBar.appendChild(prevButton);
navBar.appendChild(document.createTextNode(text));
navBar.appendChild(nextButton);
 
// attach click listeners to buttons
prevButton.addEventListener('click', function() {
onPrevNextClick(link, prevLink);
});
nextButton.addEventListener('click', function() {
onPrevNextClick(link, nextLink);
});
 
return navBar;
}
 
// get previous link with same target
function getPrevLink(link, sameLinks) {
if (!sameLinks)
sameLinks = getSameLinks(link);
// wrap index to other side if < 1
let index;
if (sameLinks.index - 1 >= 0)
index = sameLinks.index - 1;
else
index = sameLinks.total - 1;
return sameLinks.elements[index];
}
 
// get next link with same target
function getNextLink(link, sameLinks) {
if (!sameLinks)
sameLinks = getSameLinks(link);
// wrap index to other side if > total
let index;
if (sameLinks.index + 1 <= sameLinks.total - 1)
index = sameLinks.index + 1;
else
index = 0;
return sameLinks.elements[index];
}
 
// get element that is target of link or url hash
function getSource(link) {
const hash = link ? link.hash : window.location.hash;
const id = hash.slice(1);
let target = document.querySelector('[id="' + id + '"]');
if (!target)
return;
 
// if ref or figure, modify target to get expected element
if (id.indexOf('ref-') === 0)
target = target.querySelector('p');
else if (id.indexOf('fig:') === 0)
target = target.querySelector('figure');
 
return target;
}
 
// when prev/next arrow button is clicked
function onPrevNextClick(link, prevNextLink) {
if (link && prevNextLink)
goToElement(prevNextLink, window.innerHeight * 0.5);
}
 
// scroll to and focus element
function goToElement(element, offset) {
// expand accordion section if collapsed
expandElement(element);
const y =
getRectInView(element).top -
getRectInView(document.documentElement).top -
(offset || 0);
// trigger any function listening for "onscroll" event
window.dispatchEvent(new Event('scroll'));
window.scrollTo(0, y);
document.activeElement.blur();
element.focus();
}
 
// determine position to place tooltip based on link position in
// viewport and tooltip size
function positionTooltip(link, left, top) {
const tooltipElement = document.getElementById('tooltip');
if (!tooltipElement)
return;
 
// get convenient vars for position/dimensions of
// link/tooltip/page/view
link = getRectInPage(link);
const tooltip = getRectInPage(tooltipElement);
const view = getRectInPage();
 
// horizontal positioning
if (left)
// use explicit value
left = left;
else if (link.left + tooltip.width < view.right)
// fit tooltip to right of link
left = link.left;
else if (link.right - tooltip.width > view.left)
// fit tooltip to left of link
left = link.right - tooltip.width;
// center tooltip in view
else
left = (view.right - view.left) / 2 - tooltip.width / 2;
 
// vertical positioning
if (top)
// use explicit value
top = top;
else if (link.top - tooltip.height > view.top)
// fit tooltip above link
top = link.top - tooltip.height;
else if (link.bottom + tooltip.height < view.bottom)
// fit tooltip below link
top = link.bottom;
else {
// center tooltip in view
top = view.top + view.height / 2 - tooltip.height / 2;
// nudge off of link to left/right if possible
if (link.right + tooltip.width < view.right)
left = link.right;
else if (link.left - tooltip.width > view.left)
left = link.left - tooltip.width;
}
 
tooltipElement.style.left = left + 'px';
tooltipElement.style.top = top + 'px';
}
 
// get position/dimensions of element or viewport
function getRectInView(element) {
let rect = {};
rect.left = 0;
rect.top = 0;
rect.right = document.documentElement.clientWidth;
rect.bottom = document.documentElement.clientHeight;
let style = {};
 
if (element instanceof HTMLElement) {
rect = element.getBoundingClientRect();
style = window.getComputedStyle(element);
}
 
const margin = {};
margin.left = parseFloat(style.marginLeftWidth) || 0;
margin.top = parseFloat(style.marginTopWidth) || 0;
margin.right = parseFloat(style.marginRightWidth) || 0;
margin.bottom = parseFloat(style.marginBottomWidth) || 0;
 
const border = {};
border.left = parseFloat(style.borderLeftWidth) || 0;
border.top = parseFloat(style.borderTopWidth) || 0;
border.right = parseFloat(style.borderRightWidth) || 0;
border.bottom = parseFloat(style.borderBottomWidth) || 0;
 
const newRect = {};
newRect.left = rect.left + margin.left + border.left;
newRect.top = rect.top + margin.top + border.top;
newRect.right = rect.right + margin.right + border.right;
newRect.bottom = rect.bottom + margin.bottom + border.bottom;
newRect.width = newRect.right - newRect.left;
newRect.height = newRect.bottom - newRect.top;
 
return newRect;
}
 
// get position of element relative to page
function getRectInPage(element) {
const rect = getRectInView(element);
const body = getRectInView(document.body);
 
const newRect = {};
newRect.left = rect.left - body.left;
newRect.top = rect.top - body.top;
newRect.right = rect.right - body.left;
newRect.bottom = rect.bottom - body.top;
newRect.width = rect.width;
newRect.height = rect.height;
 
return newRect;
}
 
// (for interaction with accordion plugin)
// get closest element before specified element that matches query
function firstBefore(element, query) {
while (
element &&
element !== document.body &&
!element.matches(query)
)
element = element.previousElementSibling || element.parentNode;
 
return element;
}
 
// (for interaction with accordion plugin)
// check if element is part of collapsed heading
function isCollapsed(element) {
while (element && element !== document.body) {
if (element.dataset.collapsed === 'true')
return true;
element = element.parentNode;
}
return false;
}
 
// (for interaction with accordion plugin)
// expand heading containing element if necesary
function expandElement(element) {
if (isCollapsed(element)) {
const heading = firstBefore(element, 'h2');
if (heading)
heading.click();
}
}
 
// 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>
 
<!-- caret left icon -->
 
<template class="icon_caret_left">
<!-- modified from: https://fontawesome.com/icons/caret-left -->
<svg width="16" height="16" viewBox="0 0 192 512">
<path
fill="currentColor"
d="M192 127.338v257.324c0 17.818-21.543 26.741-34.142 14.142L29.196 270.142c-7.81-7.81-7.81-20.474 0-28.284l128.662-128.662c12.599-12.6 34.142-3.676 34.142 14.142z"
></path>
</svg>
</template>
 
<!-- caret right icon -->
 
<template class="icon_caret_right">
<!-- modified from: https://fontawesome.com/icons/caret-right -->
<svg width="16" height="16" viewBox="0 0 192 512">
<path
fill="currentColor"
d="M0 384.662V127.338c0-17.818 21.543-26.741 34.142-14.142l128.662 128.662c7.81 7.81 7.81 20.474 0 28.284L34.142 398.804C21.543 411.404 0 402.48 0 384.662z"
></path>
</svg>
</template>