/* 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); } } }); } }