greenelab/deep-review

View on GitHub
build/plugins/lightbox.html

Summary

Maintainability
Test Coverage
<!-- lightbox plugin -->
 
<script>
(function() {
// /////////////////////////
// DESCRIPTION
// /////////////////////////
 
// This Manubot plugin makes it such that when a user clicks on an
// image, the image fills the screen and the user can pan/drag/zoom
// the image and navigate between other images in the document.
 
// /////////////////////////
// OPTIONS
// /////////////////////////
 
// plugin name prefix for url parameters
const pluginName = 'lightbox';
 
// default plugin options
const options = {
// list of possible zoom/scale factors
zoomSteps:
'0.1, 0.25, 0.333333, 0.5, 0.666666, 0.75, 1,' +
'1.25, 1.5, 1.75, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8',
// whether to fit image to view ('fit'), display at 100% and shrink
// if necessary ('shrink'), or always display at 100% ('100')
defaultZoom: 'fit',
// whether to zoom in/out toward center of view ('true') or mouse
// ('false')
centerZoom: '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() {
// run through each <img> element
const imgs = document.querySelectorAll('figure > img');
let count = 1;
for (const img of imgs) {
img.classList.add('lightbox_document_img');
img.dataset.number = count;
img.dataset.total = imgs.length;
img.addEventListener('click', openLightbox);
count++;
}
 
// attach mouse and key listeners to window
window.addEventListener('mousemove', onWindowMouseMove);
window.addEventListener('keyup', onKeyUp);
}
 
// when mouse is moved anywhere in window
function onWindowMouseMove(event) {
window.mouseX = event.clientX;
window.mouseY = event.clientY;
}
 
// 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(
'lightbox_prev_button'
);
if (prevButton)
prevButton.click();
break;
// trigger click of next button
case 'ArrowRight':
const nextButton = document.getElementById(
'lightbox_next_button'
);
if (nextButton)
nextButton.click();
break;
// close on esc
case 'Escape':
closeLightbox();
break;
}
}
 
// open lightbox
function openLightbox() {
const lightbox = makeLightbox(this);
if (!lightbox)
return;
 
blurBody(lightbox);
document.body.appendChild(lightbox);
}
 
// make lightbox
function makeLightbox(img) {
// delete lightbox if it exists, start fresh
closeLightbox();
 
// create screen overlay containing lightbox
const overlay = document.createElement('div');
overlay.id = 'lightbox_overlay';
 
// create image info boxes
const numberInfo = document.createElement('div');
const zoomInfo = document.createElement('div');
numberInfo.id = 'lightbox_number_info';
zoomInfo.id = 'lightbox_zoom_info';
 
// create container for image
const imageContainer = document.createElement('div');
imageContainer.id = 'lightbox_image_container';
const lightboxImg = makeLightboxImg(
img,
imageContainer,
numberInfo,
zoomInfo
);
imageContainer.appendChild(lightboxImg);
 
// create bottom container for caption and navigation buttons
const bottomContainer = document.createElement('div');
bottomContainer.id = 'lightbox_bottom_container';
const caption = makeCaption(img);
const prevButton = makePrevButton(img);
const nextButton = makeNextButton(img);
bottomContainer.appendChild(prevButton);
bottomContainer.appendChild(caption);
bottomContainer.appendChild(nextButton);
 
// attach top middle and bottom to overlay
overlay.appendChild(numberInfo);
overlay.appendChild(zoomInfo);
overlay.appendChild(imageContainer);
overlay.appendChild(bottomContainer);
 
return overlay;
}
 
// make <img> object that is intuitively draggable and zoomable
function makeLightboxImg(
sourceImg,
container,
numberInfoBox,
zoomInfoBox
) {
// create copy of source <img>
const img = sourceImg.cloneNode(true);
img.classList.remove('lightbox_document_img');
img.removeAttribute('id');
img.removeAttribute('width');
img.removeAttribute('height');
img.style.position = 'unset';
img.style.margin = '0';
img.style.padding = '0';
img.style.width = '';
img.style.height = '';
img.style.minWidth = '';
img.style.minHeight = '';
img.style.maxWidth = '';
img.style.maxHeight = '';
img.id = 'lightbox_img';
 
// build sorted list of unique zoomSteps, always including a 100%
let zoomSteps = [];
const optionsZooms = options.zoomSteps.split(/[^0-9.]/);
for (const optionZoom of optionsZooms) {
const newZoom = parseFloat(optionZoom);
if (newZoom && !zoomSteps.includes(newZoom))
zoomSteps.push(newZoom);
}
if (!zoomSteps.includes(1))
zoomSteps.push(1);
zoomSteps = zoomSteps.sort(function sortNumber(a, b) {
return a - b;
});
 
// <img> object property variables
let zoom = 1;
let translateX = 0;
let translateY = 0;
let clickMouseX = undefined;
let clickMouseY = undefined;
let clickTranslateX = undefined;
let clickTranslateY = undefined;
 
updateNumberInfo();
 
// update image numbers displayed in info box
function updateNumberInfo() {
numberInfoBox.innerHTML =
sourceImg.dataset.number + ' of ' + sourceImg.dataset.total;
}
 
// update zoom displayed in info box
function updateZoomInfo() {
let zoomInfo = zoom * 100;
if (!Number.isInteger(zoomInfo))
zoomInfo = zoomInfo.toFixed(2);
zoomInfoBox.innerHTML = zoomInfo + '%';
}
 
// move to closest zoom step above current zoom
const zoomIn = function() {
for (const zoomStep of zoomSteps) {
if (zoomStep > zoom) {
zoom = zoomStep;
break;
}
}
updateTransform();
};
 
// move to closest zoom step above current zoom
const zoomOut = function() {
zoomSteps.reverse();
for (const zoomStep of zoomSteps) {
if (zoomStep < zoom) {
zoom = zoomStep;
break;
}
}
zoomSteps.reverse();
 
updateTransform();
};
 
// update display of <img> based on scale/translate properties
const updateTransform = function() {
// set transform
img.style.transform =
'translate(' +
(translateX || 0) +
'px,' +
(translateY || 0) +
'px) scale(' +
(zoom || 1) +
')';
 
// get new width/height after scale
const rect = img.getBoundingClientRect();
// limit translate
translateX = Math.max(translateX, -rect.width / 2);
translateX = Math.min(translateX, rect.width / 2);
translateY = Math.max(translateY, -rect.height / 2);
translateY = Math.min(translateY, rect.height / 2);
 
// set transform
img.style.transform =
'translate(' +
(translateX || 0) +
'px,' +
(translateY || 0) +
'px) scale(' +
(zoom || 1) +
')';
 
updateZoomInfo();
};
 
// fit <img> to container
const fit = function() {
// no x/y offset, 100% zoom by default
translateX = 0;
translateY = 0;
zoom = 1;
 
// widths of <img> and container
const imgWidth = img.naturalWidth;
const imgHeight = img.naturalHeight;
const containerWidth = parseFloat(
window.getComputedStyle(container).width
);
const containerHeight = parseFloat(
window.getComputedStyle(container).height
);
 
// how much zooming is needed to fit <img> to container
const xRatio = imgWidth / containerWidth;
const yRatio = imgHeight / containerHeight;
const maxRatio = Math.max(xRatio, yRatio);
const newZoom = 1 / maxRatio;
 
// fit <img> to container according to option
if (options.defaultZoom === 'shrink') {
if (maxRatio > 1)
zoom = newZoom;
} else if (options.defaultZoom === 'fit')
zoom = newZoom;
 
updateTransform();
};
 
// when mouse wheel is rolled anywhere in container
const onContainerWheel = function(event) {
if (!event)
return;
 
// let ctrl + mouse wheel to zoom behave as normal
if (event.ctrlKey)
return;
 
// prevent normal scroll behavior
event.preventDefault();
event.stopPropagation();
 
// point around which to scale img
const viewRect = container.getBoundingClientRect();
const viewX = (viewRect.left + viewRect.right) / 2;
const viewY = (viewRect.top + viewRect.bottom) / 2;
const originX = options.centerZoom === 'true' ? viewX : mouseX;
const originY = options.centerZoom === 'true' ? viewY : mouseY;
 
// get point on image under origin
const oldRect = img.getBoundingClientRect();
const oldPercentX = (originX - oldRect.left) / oldRect.width;
const oldPercentY = (originY - oldRect.top) / oldRect.height;
 
// increment/decrement zoom
if (event.deltaY < 0)
zoomIn();
if (event.deltaY > 0)
zoomOut();
 
// get offset between previous image point and origin
const newRect = img.getBoundingClientRect();
const offsetX =
originX - (newRect.left + newRect.width * oldPercentX);
const offsetY =
originY - (newRect.top + newRect.height * oldPercentY);
 
// translate image to keep image point under origin
translateX += offsetX;
translateY += offsetY;
 
// perform translate
updateTransform();
};
 
// when container is clicked
function onContainerClick(event) {
// if container itself is target of click, and not other
// element above it
if (event.target === this)
closeLightbox();
}
 
// when mouse button is pressed on image
const onImageMouseDown = function(event) {
// store original mouse position relative to image
clickMouseX = window.mouseX;
clickMouseY = window.mouseY;
clickTranslateX = translateX;
clickTranslateY = translateY;
event.stopPropagation();
event.preventDefault();
};
 
// when mouse button is released anywhere in window
const onWindowMouseUp = function(event) {
// reset original mouse position
clickMouseX = undefined;
clickMouseY = undefined;
clickTranslateX = undefined;
clickTranslateY = undefined;
 
// remove global listener if lightbox removed from document
if (!document.body.contains(container))
window.removeEventListener('mouseup', onWindowMouseUp);
};
 
// when mouse is moved anywhere in window
const onWindowMouseMove = function(event) {
if (
clickMouseX === undefined ||
clickMouseY === undefined ||
clickTranslateX === undefined ||
clickTranslateY === undefined
)
return;
 
// offset image based on original and current mouse position
translateX = clickTranslateX + window.mouseX - clickMouseX;
translateY = clickTranslateY + window.mouseY - clickMouseY;
updateTransform();
event.preventDefault();
 
// remove global listener if lightbox removed from document
if (!document.body.contains(container))
window.removeEventListener('mousemove', onWindowMouseMove);
};
 
// when window is resized
const onWindowResize = function(event) {
fit();
 
// remove global listener if lightbox removed from document
if (!document.body.contains(container))
window.removeEventListener('resize', onWindowResize);
};
 
// attach the necessary event listeners
img.addEventListener('dblclick', fit);
img.addEventListener('mousedown', onImageMouseDown);
container.addEventListener('wheel', onContainerWheel);
container.addEventListener('mousedown', onContainerClick);
container.addEventListener('touchstart', onContainerClick);
window.addEventListener('mouseup', onWindowMouseUp);
window.addEventListener('mousemove', onWindowMouseMove);
window.addEventListener('resize', onWindowResize);
 
// run fit() after lightbox atttached to document and <img> Loaded
// so needed container and img dimensions available
img.addEventListener('load', fit);
 
return img;
}
 
// make caption
function makeCaption(img) {
const caption = document.createElement('div');
caption.id = 'lightbox_caption';
const captionSource = img.nextElementSibling;
if (captionSource.tagName.toLowerCase() === 'figcaption') {
const captionCopy = makeCopy(captionSource);
caption.innerHTML = captionCopy.innerHTML;
}
 
caption.addEventListener('touchstart', function(event) {
event.stopPropagation();
});
 
return caption;
}
 
// 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;
}
 
// make button to jump to previous image in document
function makePrevButton(img) {
const prevButton = document.createElement('button');
prevButton.id = 'lightbox_prev_button';
prevButton.title = 'Jump to the previous image in the document [←]';
prevButton.classList.add('icon_button', 'lightbox_button');
prevButton.innerHTML = document.querySelector(
'.icon_caret_left'
).innerHTML;
 
// attach click listeners to button
prevButton.addEventListener('click', function() {
getPrevImg(img).click();
});
 
return prevButton;
}
 
// make button to jump to next image in document
function makeNextButton(img) {
const nextButton = document.createElement('button');
nextButton.id = 'lightbox_next_button';
nextButton.title = 'Jump to the next image in the document [→]';
nextButton.classList.add('icon_button', 'lightbox_button');
nextButton.innerHTML = document.querySelector(
'.icon_caret_right'
).innerHTML;
 
// attach click listeners to button
nextButton.addEventListener('click', function() {
getNextImg(img).click();
});
 
return nextButton;
}
 
// get previous image in document
function getPrevImg(img) {
const imgs = document.querySelectorAll('.lightbox_document_img');
 
// find index of provided img
let index;
for (index = 0; index < imgs.length; index++) {
if (imgs[index] === img)
break;
}
 
 
// wrap index to other side if < 1
if (index - 1 >= 0)
index--;
else
index = imgs.length - 1;
return imgs[index];
}
 
// get next image in document
function getNextImg(img) {
const imgs = document.querySelectorAll('.lightbox_document_img');
 
// find index of provided img
let index;
for (index = 0; index < imgs.length; index++) {
if (imgs[index] === img)
break;
}
 
 
// wrap index to other side if > total
if (index + 1 <= imgs.length - 1)
index++;
else
index = 0;
return imgs[index];
}
 
// close lightbox
function closeLightbox() {
focusBody();
 
const lightbox = document.getElementById('lightbox_overlay');
if (lightbox)
lightbox.remove();
}
 
// make all elements behind lightbox non-focusable
function blurBody(overlay) {
const all = document.querySelectorAll('*');
for (const element of all)
element.tabIndex = -1;
document.body.classList.add('body_no_scroll');
}
 
// make all elements focusable again
function focusBody() {
const all = document.querySelectorAll('*');
for (const element of all)
element.removeAttribute('tabIndex');
document.body.classList.remove('body_no_scroll');
}
 
// 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>