From 611c82294aecf54dd365aebcd4d34d0eda9a5f0a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 30 Nov 2024 18:07:37 -0400 Subject: client: image-overlay: be a client module --- src/static/js/client/image-overlay.js | 306 ++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 src/static/js/client/image-overlay.js (limited to 'src/static/js/client/image-overlay.js') diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js new file mode 100644 index 00000000..bdfefeea --- /dev/null +++ b/src/static/js/client/image-overlay.js @@ -0,0 +1,306 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; +import {fetchWithProgress} from '../xhr-util.js'; + +export const info = { + id: 'imageOverlayInfo', + + container: null, + actionContainer: null, + + viewOriginalLinks: null, + mainImage: null, + thumbImage: null, + + actionContentWithoutSize: null, + actionContentWithSize: null, + + megabytesContainer: null, + kilobytesContainer: null, + megabytesContent: null, + kilobytesContent: null, + fileSizeWarning: null, + + links: null, +}; + +export function getPageReferences() { + info.container = + document.getElementById('image-overlay-container'); + + if (!info.container) return; + + info.actionContainer = + document.getElementById('image-overlay-action-container'); + + info.viewOriginalLinks = + document.getElementsByClassName('image-overlay-view-original'); + + info.mainImage = + document.getElementById('image-overlay-image'); + + info.thumbImage = + document.getElementById('image-overlay-image-thumb'); + + info.actionContentWithoutSize = + document.getElementById('image-overlay-action-content-without-size'); + + info.actionContentWithSize = + document.getElementById('image-overlay-action-content-with-size'); + + info.megabytesContainer = + document.getElementById('image-overlay-file-size-megabytes'); + + info.kilobytesContainer = + document.getElementById('image-overlay-file-size-kilobytes'); + + info.megabytesContent = + info.megabytesContainer.querySelector('.image-overlay-file-size-count'); + + info.kilobytesContent = + info.kilobytesContainer.querySelector('.image-overlay-file-size-count'); + + info.fileSizeWarning = + document.getElementById('image-overlay-file-size-warning'); + + info.links = + Array.from(document.querySelectorAll('.image-link')) + .filter(link => !link.closest('.no-image-preview')); +} + +export function addPageListeners() { + if (!info.container) return; + + for (const link of info.links) { + link.addEventListener('click', handleImageLinkClicked); + } + + info.container.addEventListener('click', handleContainerClicked); + document.body.addEventListener('keydown', handleKeyDown); +} + +function handleContainerClicked(evt) { + // Only hide the image overlay if actually clicking the background. + if (evt.target !== info.container) { + return; + } + + // If you clicked anything close to or beneath the action bar, don't hide + // the image overlay. + const rect = info.actionContainer.getBoundingClientRect(); + if (evt.clientY >= rect.top - 40) { + return; + } + + info.container.classList.remove('visible'); +} + +function handleKeyDown(evt) { + if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { + info.container.classList.remove('visible'); + } +} + +async function handleImageLinkClicked(evt) { + if (evt.metaKey || evt.shiftKey || evt.altKey) { + return; + } + + evt.preventDefault(); + + // Don't show the overlay if the image still needs to be revealed. + if (evt.target.closest('.reveal:not(.revealed)')) { + return; + } + + info.container.classList.add('visible'); + info.container.classList.remove('loaded'); + info.container.classList.remove('errored'); + + const details = getImageLinkDetails(evt.target); + + updateFileSizeInformation(details.originalFileSize); + + let mainSrc = null; + let thumbSrc = null; + + if (details.availableThumbList) { + const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(details.availableThumbList); + const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(details.availableThumbList); + mainSrc = details.embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`); + thumbSrc = details.embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`); + // Show the thumbnail size on each element's data attributes. + // Y'know, just for debugging convenience. + info.mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; + info.thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; + } else { + mainSrc = details.originalSrc; + thumbSrc = null; + info.mainImage.dataset.displayingThumb = ''; + info.thumbImage.dataset.displayingThumb = ''; + } + + for (const link of info.viewOriginalLinks) { + link.href = details.originalSrc; + } + + await loadOverlayImage(mainSrc, thumbSrc); +} + +function getImageLinkDetails(imageLink) { + const a = imageLink.closest('a'); + const img = a.querySelector('img'); + + return { + originalSrc: + a.href, + + embeddedSrc: + img.src, + + originalFileSize: + img.dataset.originalSize, + + availableThumbList: + img.dataset.thumbs, + }; +} + +async function loadOverlayImage(mainSrc, thumbSrc) { + if (thumbSrc) { + info.thumbImage.src = thumbSrc; + info.thumbImage.style.display = null; + } else { + info.thumbImage.src = ''; + info.thumbImage.style.display = 'none'; + } + + info.mainImage.addEventListener('load', handleMainImageLoaded); + info.mainImage.addEventListener('error', handleMainImageErrored); + + const showProgress = amount => { + cssProp(info.container, '--download-progress', `${amount * 100}%`); + }; + + showProgress(0.00); + + const response = + await fetchWithProgress(mainSrc, progress => { + if (progress === -1) { + // TODO: Indeterminate response progress cue + showProgress(0.00); + } else { + showProgress(0.20 + 0.80 * progress); + } + }); + + if (!response.status.toString().startsWith('2')) { + handleMainImageErrored(); + return; + } + + const blob = await response.blob(); + const blobSrc = URL.createObjectURL(blob); + + info.mainImage.src = blobSrc; + showProgress(1.00); + + function handleMainImageLoaded() { + info.container.classList.add('loaded'); + removeEventListeners(); + } + + function handleMainImageErrored() { + info.container.classList.add('errored'); + removeEventListeners(); + } + + function removeEventListeners() { + info.mainImage.removeEventListener('load', handleMainImageLoaded); + info.mainImage.removeEventListener('error', handleMainImageErrored); + } +} + +function parseThumbList(availableThumbList) { + // Parse all the available thumbnail sizes! These are provided by the actual + // content generation on each image. + const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' + const availableSizes = + (availableThumbList || defaultThumbList) + .split(' ') + .map(part => part.split(':')) + .map(([thumb, length]) => ({thumb, length: parseInt(length)})) + .sort((a, b) => a.length - b.length); + + return availableSizes; +} + +function getPreferredThumbSize(availableThumbList) { + // Assuming a square, the image will be constrained to the lesser window + // dimension. Coefficient here matches CSS dimensions for image overlay. + const constrainedLength = Math.floor(Math.min( + 0.80 * window.innerWidth, + 0.80 * window.innerHeight)); + + // Match device pixel ratio, which is 2x for "retina" displays and certain + // device configurations. + const visualLength = window.devicePixelRatio * constrainedLength; + + const availableSizes = parseThumbList(availableThumbList); + + // Starting from the smallest dimensions, find (and return) the first + // available length which hits a "good enough" threshold - it's got to be + // at least that percent of the way to the actual displayed dimensions. + const goodEnoughThreshold = 0.90; + + // (The last item is skipped since we'd be falling back to it anyway.) + for (const {thumb, length} of availableSizes.slice(0, -1)) { + if (Math.floor(visualLength * goodEnoughThreshold) <= length) { + return {thumb, length}; + } + } + + // If none of the items in the list were big enough to hit the "good enough" + // threshold, just use the largest size available. + return availableSizes[availableSizes.length - 1]; +} + +function getSmallestThumbSize(availableThumbList) { + // Just snag the smallest size. This'll be used for displaying the "preview" + // as the bigger one is loading. + const availableSizes = parseThumbList(availableThumbList); + return availableSizes[0]; +} + +function updateFileSizeInformation(fileSize) { + const fileSizeWarningThreshold = 8 * 10 ** 6; + + if (!fileSize) { + info.actionContentWithSize.classList.remove('visible'); + info.actionContentWithoutSize.classList.add('visible'); + return; + } + + info.actionContentWithoutSize.classList.remove('visible'); + info.actionContentWithSize.classList.add('visible'); + + fileSize = parseInt(fileSize); + const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; + + if (fileSize > fileSizeWarningThreshold) { + info.fileSizeWarning.classList.add('visible'); + } else { + info.fileSizeWarning.classList.remove('visible'); + } + + if (fileSize > 10 ** 6) { + info.megabytesContainer.classList.add('visible'); + info.kilobytesContainer.classList.remove('visible'); + info.megabytesContent.innerText = round(6); + } else { + info.megabytesContainer.classList.remove('visible'); + info.kilobytesContainer.classList.add('visible'); + info.kilobytesContent.innerText = round(3); + } +} -- cgit 1.3.0-6-gf8a5