/* Image-focus.js http://share.obormot.net/misc/gwern/image-focus.js */
/* Written by Obormot, 15 February 2019 */
/* Lightweight dependency-free JavaScript library for "click to focus/zoom" images in HTML web pages. Originally coded for Obormot.net / GreaterWrong.com. */
if (typeof window.GW == "undefined")
window.GW = { };
GW.temp = { };
GW.isMobile = ('ontouchstart' in document.documentElement);
/********************/
/* DEBUGGING OUTPUT */
/********************/
function GWLog (string) {
if (GW.loggingEnabled || localStorage.getItem("logging-enabled") == "true")
console.log(string);
}
GW.enableLogging = (permanently = false) => {
if (permanently)
localStorage.setItem("logging-enabled", "true");
else
GW.loggingEnabled = true;
};
GW.disableLogging = (permanently = false) => {
if (permanently)
localStorage.removeItem("logging-enabled");
else
GW.loggingEnabled = false;
};
/****************/
/* MISC HELPERS */
/****************/
/* Given an HTML string, creates an element from that HTML, adds it to
#ui-elements-container (creating the latter if it does not exist), and
returns the created element.
*/
function addUIElement(element_html) {
var ui_elements_container = document.querySelector("#ui-elements-container");
if (!ui_elements_container) {
ui_elements_container = document.createElement("div");
ui_elements_container.id = "ui-elements-container";
document.querySelector("body").appendChild(ui_elements_container);
}
ui_elements_container.insertAdjacentHTML("beforeend", element_html);
return ui_elements_container.lastElementChild;
}
/* Toggles whether the page is scrollable.
*/
function togglePageScrolling(enable) {
if (!enable) {
window.addEventListener('keydown', GW.imageFocus.keyDown = (event) => {
let forbiddenKeys = [ " ", "Spacebar", "ArrowUp", "ArrowDown", "Up", "Down" ];
if (forbiddenKeys.contains(event.key) &&
event.target == document.body) {
event.preventDefault();
}
});
} else {
window.removeEventListener('keydown', GW.imageFocus.keyDown);
}
}
/* Returns true if the array contains the given element.
*/
Array.prototype.contains = function (element) {
return (this.indexOf(element) !== -1);
}
/* Returns true if the string begins with the given prefix.
*/
String.prototype.hasPrefix = function (prefix) {
return (this.lastIndexOf(prefix, 0) === 0);
}
/***************/
/* IMAGE FOCUS */
/***************/
function imageFocusSetup() {
if (typeof GW.imageFocus == "undefined")
GW.imageFocus = {
contentImagesSelector: ".gallery img",
focusedImageSelector: ".gallery img.focused",
shrinkRatio: 0.975,
hideUITimerDuration: 1500,
hideUITimerExpired: () => {
GWLog("GW.imageFocus.hideUITimerExpired");
let currentTime = new Date();
let timeSinceLastMouseMove = (new Date()) - GW.imageFocus.mouseLastMovedAt;
if (timeSinceLastMouseMove < GW.imageFocus.hideUITimerDuration) {
GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, (GW.imageFocus.hideUITimerDuration - timeSinceLastMouseMove));
} else {
hideImageFocusUI();
cancelImageFocusHideUITimer();
}
}
};
GWLog("imageFocusSetup");
// Create event listener for clicking on images to focus them.
GW.imageClickedToFocus = (event) => {
GWLog("GW.imageClickedToFocus");
focusImage(event.target);
if (!GW.isMobile) {
// Set timer to hide the image focus UI.
unhideImageFocusUI();
GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
}
};
// Add the listener to all content images.
document.querySelectorAll(GW.imageFocus.contentImagesSelector).forEach(image => {
image.addEventListener("click", GW.imageClickedToFocus);
});
// Add image wrapper class.
document.querySelectorAll(GW.imageFocus.contentImagesSelector).forEach(image => {
let wrapper = image.closest(".img");
if (wrapper)
wrapper.classList.add("image-wrapper");
});
// Create the image focus overlay.
let imageFocusOverlay = addUIElement("
" +
`
Arrow keys: Next/previous image
Escape or click: Hide zoomed image
Space bar: Reset image size & position
Scroll to zoom in/out
(When zoomed in, drag to pan;
double-click to close)
` +
"
");
imageFocusOverlay.dropShadowFilterForImages = " drop-shadow(10px 10px 10px #000) drop-shadow(0 0 10px #444)";
// On orientation change, reset the size & position.
window.matchMedia('(orientation: portrait)').addListener(() => { setTimeout(resetFocusedImagePosition, 0); });
// Accesskey-L starts the slideshow.
(document.querySelector(GW.imageFocus.contentImagesSelector)||{}).accessKey = 'l';
// Count how many images there are in the post, and set the "… of X" label to that.
((document.querySelector("#image-focus-overlay .image-number")||{}).dataset||{}).numberOfImages = document.querySelectorAll(GW.imageFocus.contentImagesSelector).length;
// Activate the buttons.
imageFocusOverlay.querySelectorAll(".slideshow-button").forEach(button => {
button.addEventListener("click", GW.imageFocus.slideshowButtonClicked = (event) => {
GWLog("GW.imageFocus.slideshowButtonClicked");
focusNextImage(event.target.classList.contains("next"));
event.target.blur();
});
});
// UI starts out hidden.
hideImageFocusUI();
}
function focusImage(imageToFocus) {
GWLog("focusImage");
// Clear 'last-focused' class of last focused image.
let lastFocusedImage = document.querySelector("img.last-focused");
if (lastFocusedImage) {
lastFocusedImage.classList.remove("last-focused");
lastFocusedImage.removeAttribute("accesskey");
}
// Create the focused version of the image.
imageToFocus.classList.toggle("focused", true);
let imageFocusOverlay = document.querySelector("#image-focus-overlay");
let clonedImage = imageToFocus.cloneNode(true);
clonedImage.style = "";
clonedImage.removeAttribute("width");
clonedImage.removeAttribute("height");
clonedImage.style.filter = imageToFocus.style.filter + imageFocusOverlay.dropShadowFilterForImages;
// Add the image to the overlay.
imageFocusOverlay.appendChild(clonedImage);
imageFocusOverlay.classList.toggle("engaged", true);
// Set image to default size and position.
resetFocusedImagePosition();
// Add listener to zoom image with scroll wheel.
window.addEventListener("wheel", GW.imageFocus.scrollEvent = (event) => {
GWLog("GW.imageFocus.scrollEvent");
event.preventDefault();
let image = document.querySelector("#image-focus-overlay img.focused");
// Remove the filter.
image.savedFilter = image.style.filter;
image.style.filter = 'none';
// Locate point under cursor.
let imageBoundingBox = image.getBoundingClientRect();
// Calculate resize factor.
var factor = (image.height > 10 && image.width > 10) || event.deltaY < 0 ?
1 + Math.sqrt(Math.abs(event.deltaY))/100.0 :
1;
// Resize.
image.style.width = (event.deltaY < 0 ?
(image.clientWidth * factor) :
(image.clientWidth / factor))
+ "px";
image.style.height = "";
// Designate zoom origin.
var zoomOrigin;
// Zoom from cursor if we're zoomed in to where image exceeds screen, AND
// the cursor is over the image.
let imageSizeExceedsWindowBounds = (image.getBoundingClientRect().width > window.innerWidth || image.getBoundingClientRect().height > window.innerHeight);
let zoomingFromCursor = imageSizeExceedsWindowBounds &&
(imageBoundingBox.left <= event.clientX &&
event.clientX <= imageBoundingBox.right &&
imageBoundingBox.top <= event.clientY &&
event.clientY <= imageBoundingBox.bottom);
// Otherwise, if we're zooming OUT, zoom from window center; if we're
// zooming IN, zoom from image center.
let zoomingFromWindowCenter = event.deltaY > 0;
if (zoomingFromCursor)
zoomOrigin = { x: event.clientX,
y: event.clientY };
else if (zoomingFromWindowCenter)
zoomOrigin = { x: window.innerWidth / 2,
y: window.innerHeight / 2 };
else
zoomOrigin = { x: imageBoundingBox.x + imageBoundingBox.width / 2,
y: imageBoundingBox.y + imageBoundingBox.height / 2 };
// Calculate offset from zoom origin.
let offsetOfImageFromZoomOrigin = {
x: imageBoundingBox.x - zoomOrigin.x,
y: imageBoundingBox.y - zoomOrigin.y
}
// Calculate delta from centered zoom.
let deltaFromCenteredZoom = {
x: image.getBoundingClientRect().x - (zoomOrigin.x + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.x * factor : offsetOfImageFromZoomOrigin.x / factor)),
y: image.getBoundingClientRect().y - (zoomOrigin.y + (event.deltaY < 0 ? offsetOfImageFromZoomOrigin.y * factor : offsetOfImageFromZoomOrigin.y / factor))
}
// Adjust image position appropriately.
image.style.left = parseInt(getComputedStyle(image).left) - deltaFromCenteredZoom.x + "px";
image.style.top = parseInt(getComputedStyle(image).top) - deltaFromCenteredZoom.y + "px";
// Gradually re-center image, if it's smaller than the window.
if (!imageSizeExceedsWindowBounds) {
let imageCenter = { x: image.getBoundingClientRect().x + image.getBoundingClientRect().width / 2,
y: image.getBoundingClientRect().y + image.getBoundingClientRect().height / 2 }
let windowCenter = { x: window.innerWidth / 2,
y: window.innerHeight / 2 }
let imageOffsetFromCenter = { x: windowCenter.x - imageCenter.x,
y: windowCenter.y - imageCenter.y }
// Divide the offset by 10 because we're nudging the image toward center,
// not jumping it there.
image.style.left = parseInt(getComputedStyle(image).left) + imageOffsetFromCenter.x / 10 + "px";
image.style.top = parseInt(getComputedStyle(image).top) + imageOffsetFromCenter.y / 10 + "px";
}
// Put the filter back.
image.style.filter = image.savedFilter;
// Set the cursor appropriately.
setFocusedImageCursor();
}, { passive: false });
window.addEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent = (event) => {
GWLog("GW.imageFocus.oldFirefoxCompatibilityScrollEvent");
event.preventDefault();
});
// If image is bigger than viewport, it's draggable. Otherwise, click unfocuses.
window.addEventListener("mouseup", GW.imageFocus.mouseUp = (event) => {
GWLog("GW.imageFocus.mouseUp");
window.onmousemove = '';
// We only want to do anything on left-clicks.
if (event.button != 0) return;
// Don't unfocus if click was on a slideshow next/prev button!
if (event.target.classList.contains("slideshow-button")) return;
// We also don't want to do anything if clicked on the help overlay.
if (event.target.classList.contains("help-overlay") ||
event.target.closest(".help-overlay"))
return;
let focusedImage = document.querySelector("#image-focus-overlay img.focused");
if ((event.target == focusedImage || event.target.tagName == "HTML") &&
(focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth)) {
// If the mouseup event was the end of a pan of an overside image,
// put the filter back; do not unfocus.
focusedImage.style.filter = focusedImage.savedFilter;
} else if (event.target.tagName != "HTML") {
unfocusImageOverlay();
return;
}
});
window.addEventListener("mousedown", GW.imageFocus.mouseDown = (event) => {
GWLog("GW.imageFocus.mouseDown");
// We only want to do anything on left-clicks.
if (event.button != 0) return;
event.preventDefault();
let focusedImage = document.querySelector("#image-focus-overlay img.focused");
if (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) {
let mouseCoordX = event.clientX;
let mouseCoordY = event.clientY;
let imageCoordX = parseInt(getComputedStyle(focusedImage).left);
let imageCoordY = parseInt(getComputedStyle(focusedImage).top);
// Save the filter.
focusedImage.savedFilter = focusedImage.style.filter;
window.onmousemove = (event) => {
// Remove the filter.
focusedImage.style.filter = "none";
focusedImage.style.left = imageCoordX + event.clientX - mouseCoordX + 'px';
focusedImage.style.top = imageCoordY + event.clientY - mouseCoordY + 'px';
};
return false;
}
});
// Double-click on the image unfocuses.
clonedImage.addEventListener('dblclick', GW.imageFocus.doubleClick = (event) => {
GWLog("GW.imageFocus.doubleClick");
if (event.target.classList.contains("slideshow-button")) return;
unfocusImageOverlay();
});
// Escape key unfocuses, spacebar resets.
document.addEventListener("keyup", GW.imageFocus.keyUp = (event) => {
GWLog("GW.imageFocus.keyUp");
let allowedKeys = [ " ", "Spacebar", "Escape", "Esc", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Up", "Down", "Left", "Right" ];
if (!allowedKeys.contains(event.key) ||
getComputedStyle(document.querySelector("#image-focus-overlay")).display == "none") return;
event.preventDefault();
switch (event.key) {
case "Escape":
case "Esc":
unfocusImageOverlay();
break;
case " ":
case "Spacebar":
resetFocusedImagePosition();
break;
case "ArrowDown":
case "Down":
case "ArrowRight":
case "Right":
if (document.querySelector(GW.imageFocus.focusedImageSelector)) focusNextImage(true);
break;
case "ArrowUp":
case "Up":
case "ArrowLeft":
case "Left":
if (document.querySelector(GW.imageFocus.focusedImageSelector)) focusNextImage(false);
break;
}
});
setTimeout(() => {
// Prevent spacebar or arrow keys from scrolling page when image focused.
togglePageScrolling(false);
});
// Mark the overlay as being in slide show mode (to show buttons/count).
imageFocusOverlay.classList.add("slideshow");
// Set state of next/previous buttons.
let images = document.querySelectorAll(GW.imageFocus.contentImagesSelector);
var indexOfFocusedImage = getIndexOfFocusedImage();
imageFocusOverlay.querySelector(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
imageFocusOverlay.querySelector(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
// Set the image number.
document.querySelector("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
// Replace the hash.
history.replaceState(null, null, "#if_slide_" + (indexOfFocusedImage + 1));
// Set the caption.
setImageFocusCaption();
// Moving mouse unhides image focus UI.
window.addEventListener("mousemove", GW.imageFocus.mouseMoved = (event) => {
GWLog("GW.imageFocus.mouseMoved");
let currentDateTime = new Date();
if (!(event.target.tagName == "IMG" || event.target.id == "image-focus-overlay")) {
cancelImageFocusHideUITimer();
} else {
if (!GW.imageFocus.hideUITimer) {
unhideImageFocusUI();
GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
}
GW.imageFocus.mouseLastMovedAt = currentDateTime;
}
});
}
function resetFocusedImagePosition() {
GWLog("resetFocusedImagePosition");
let focusedImage = document.querySelector("#image-focus-overlay img.focused");
if (!focusedImage) return;
let sourceImage = document.querySelector(GW.imageFocus.focusedImageSelector);
// Make sure that initially, the image fits into the viewport.
let constrainedWidth = Math.min(sourceImage.naturalWidth, window.innerWidth * GW.imageFocus.shrinkRatio);
let widthShrinkRatio = constrainedWidth / sourceImage.naturalWidth;
var constrainedHeight = Math.min(sourceImage.naturalHeight, window.innerHeight * GW.imageFocus.shrinkRatio);
let heightShrinkRatio = constrainedHeight / sourceImage.naturalHeight;
let shrinkRatio = Math.min(widthShrinkRatio, heightShrinkRatio);
focusedImage.style.width = (sourceImage.naturalWidth * shrinkRatio) + "px";
focusedImage.style.height = (sourceImage.naturalHeight * shrinkRatio) + "px";
// Remove modifications to position.
focusedImage.style.left = "";
focusedImage.style.top = "";
// Set the cursor appropriately.
setFocusedImageCursor();
}
function setFocusedImageCursor() {
let focusedImage = document.querySelector("#image-focus-overlay img.focused");
if (!focusedImage) return;
focusedImage.style.cursor = (focusedImage.height >= window.innerHeight || focusedImage.width >= window.innerWidth) ?
'move' : '';
}
function unfocusImageOverlay() {
GWLog("unfocusImageOverlay");
// Remove event listeners.
window.removeEventListener("wheel", GW.imageFocus.scrollEvent);
window.removeEventListener("MozMousePixelScroll", GW.imageFocus.oldFirefoxCompatibilityScrollEvent);
// NOTE: The double-click listener does not need to be removed manually,
// because the focused (cloned) image will be removed anyway.
document.removeEventListener("keyup", GW.imageFocus.keyUp);
window.removeEventListener("mousemove", GW.imageFocus.mouseMoved);
window.removeEventListener("mousedown", GW.imageFocus.mouseDown);
window.removeEventListener("mouseup", GW.imageFocus.mouseUp);
// Set accesskey of currently focused image.
let currentlyFocusedImage = document.querySelector(GW.imageFocus.focusedImageSelector)
if (currentlyFocusedImage) {
currentlyFocusedImage.classList.toggle("last-focused", true);
currentlyFocusedImage.accessKey = 'l';
}
// Remove focused image and hide overlay.
let imageFocusOverlay = document.querySelector("#image-focus-overlay");
imageFocusOverlay.classList.remove("engaged");
imageFocusOverlay.querySelector("img.focused").remove();
// Unset "focused" class of focused image.
document.querySelector(GW.imageFocus.focusedImageSelector).classList.remove("focused");
setTimeout(() => {
// Re-enable page scrolling.
togglePageScrolling(true);
});
// Reset the hash, if needed.
if (location.hash.hasPrefix("#if_slide_"))
history.replaceState(null, null, "#");
}
function getIndexOfFocusedImage() {
let images = document.querySelectorAll(GW.imageFocus.contentImagesSelector);
var indexOfFocusedImage = -1;
for (i = 0; i < images.length; i++) {
if (images[i].classList.contains("focused")) {
indexOfFocusedImage = i;
break;
}
}
return indexOfFocusedImage;
}
function focusNextImage(next = true) {
GWLog("focusNextImage");
let images = document.querySelectorAll(GW.imageFocus.contentImagesSelector);
var indexOfFocusedImage = getIndexOfFocusedImage();
if (next ? (++indexOfFocusedImage == images.length) : (--indexOfFocusedImage == -1)) return;
// Remove existing image.
document.querySelector("#image-focus-overlay img.focused").remove();
// Unset "focused" class of just-removed image.
document.querySelector(GW.imageFocus.focusedImageSelector).classList.remove("focused");
// Create the focused version of the image.
images[indexOfFocusedImage].classList.toggle("focused", true);
let imageFocusOverlay = document.querySelector("#image-focus-overlay");
let clonedImage = images[indexOfFocusedImage].cloneNode(true);
clonedImage.style = "";
clonedImage.removeAttribute("width");
clonedImage.removeAttribute("height");
clonedImage.style.filter = images[indexOfFocusedImage].style.filter + imageFocusOverlay.dropShadowFilterForImages;
imageFocusOverlay.appendChild(clonedImage);
imageFocusOverlay.classList.toggle("engaged", true);
// Set image to default size and position.
resetFocusedImagePosition();
// Set state of next/previous buttons.
imageFocusOverlay.querySelector(".slideshow-button.previous").disabled = (indexOfFocusedImage == 0);
imageFocusOverlay.querySelector(".slideshow-button.next").disabled = (indexOfFocusedImage == images.length - 1);
// Set the image number display.
document.querySelector("#image-focus-overlay .image-number").textContent = (indexOfFocusedImage + 1);
// Set the caption.
setImageFocusCaption();
// Replace the hash.
history.replaceState(null, null, "#if_slide_" + (indexOfFocusedImage + 1));
}
function setImageFocusCaption() {
GWLog("setImageFocusCaption");
var T = { }; // Temporary storage.
// Clear existing caption, if any.
let captionContainer = document.querySelector("#image-focus-overlay .caption");
Array.from(captionContainer.children).forEach(child => { child.remove(); });
// Determine caption.
let currentlyFocusedImage = document.querySelector(GW.imageFocus.focusedImageSelector);
var captionHTML;
if ((T.enclosingFigure = currentlyFocusedImage.closest("figure")) &&
(T.figcaption = T.enclosingFigure.querySelector("figcaption"))) {
captionHTML = (T.figcaption.querySelector("p")) ?
T.figcaption.innerHTML :
"" + T.figcaption.innerHTML + "
";
} else if (currentlyFocusedImage.title != "") {
captionHTML = `${currentlyFocusedImage.title}
`;
}
// Insert the caption, if any.
if (captionHTML) captionContainer.insertAdjacentHTML("beforeend", captionHTML);
}
function hideImageFocusUI() {
GWLog("hideImageFocusUI");
let imageFocusOverlay = document.querySelector("#image-focus-overlay");
imageFocusOverlay.querySelectorAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
element.classList.toggle("hidden", true);
});
}
function unhideImageFocusUI() {
GWLog("unhideImageFocusUI");
let imageFocusOverlay = document.querySelector("#image-focus-overlay");
imageFocusOverlay.querySelectorAll(".slideshow-button, .help-overlay, .image-number, .caption").forEach(element => {
element.classList.remove("hidden");
});
}
function cancelImageFocusHideUITimer() {
clearTimeout(GW.imageFocus.hideUITimer);
GW.imageFocus.hideUITimer = null;
}
function focusImageSpecifiedByURL() {
GWLog("focusImageSpecifiedByURL");
if (location.hash.hasPrefix("#if_slide_")) {
document.addEventListener("readystatechange", () => {
let images = document.querySelectorAll(GW.imageFocus.contentImagesSelector);
let imageToFocus = (/#if_slide_([0-9]+)/.exec(location.hash)||{})[1];
if (imageToFocus > 0 && imageToFocus <= images.length) {
focusImage(images[imageToFocus - 1]);
if (!GW.isMobile) {
// Set timer to hide the image focus UI.
unhideImageFocusUI();
GW.imageFocus.hideUITimer = setTimeout(GW.imageFocus.hideUITimerExpired, GW.imageFocus.hideUITimerDuration);
}
}
});
}
}