diff --git a/src/scripts/modules/figures.js b/src/scripts/modules/figures.js index 962f9c5409..e2fc56b8d1 100644 --- a/src/scripts/modules/figures.js +++ b/src/scripts/modules/figures.js @@ -1,26 +1,197 @@ -import { qs, qsa } from './query.js'; +import { qsa } from './query.js'; -const activeClass = 'magnify-icon'; +const inset = { wide: 50, narrow: 16 }; +const duration = 400; + +/** @type {(() => void) | null} */ +let dismiss = null; + +function stillFrames() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/** + * The rect the image animates to: its own aspect ratio, centred in the + * viewport, never scaled past the source image's real pixel width. + * + * @param {HTMLImageElement} img + */ +function targetRect(img) { + const padding = window.innerWidth < 640 ? inset.narrow : inset.wide; + const room = { + width: window.innerWidth - padding * 2, + height: window.innerHeight - padding * 2, + }; + + const ratio = img.naturalWidth / img.naturalHeight; + const native = Number(img.getAttribute('width')) || img.naturalWidth; + + let width = Math.min(room.width, room.height * ratio, native); + let height = width / ratio; + + return { + left: (window.innerWidth - width) / 2, + top: (window.innerHeight - height) / 2, + width, + height, + }; +} + +/** + * The biggest version of an image we can reach. A srcset carries the real + * width of each candidate in its descriptor, and an image without one is + * already serving its full size. + * + * @param {HTMLImageElement} img + */ +function largest(img) { + const candidates = (img.getAttribute('srcset') ?? '') + .split(',') + .map((candidate) => candidate.trim().split(/\s+/)) + .filter(([, descriptor]) => descriptor?.endsWith('w')) + .map(([url, descriptor]) => ({ url, width: parseInt(descriptor, 10) })) + .sort((a, b) => b.width - a.width); + + return candidates[0]?.url ?? img.currentSrc ?? img.src; +} + +/** + * @param {HTMLElement} node + * @param {{ left: number, top: number, width: number, height: number }} rect + */ +function place(node, rect) { + node.style.left = `${rect.left}px`; + node.style.top = `${rect.top}px`; + node.style.width = `${rect.width}px`; + node.style.height = `${rect.height}px`; +} + +/** + * @param {HTMLImageElement} img + */ +function open(img) { + dismiss?.(); + + const overlay = document.createElement('div'); + overlay.className = 'zoom-overlay'; + + const lightbox = document.createElement('div'); + lightbox.className = 'zoom-lightbox'; + + const close = document.createElement('button'); + close.type = 'button'; + close.className = 'zoom-close'; + close.textContent = '×'; + close.setAttribute('aria-label', 'Close image'); + + const frame = document.createElement('div'); + const clone = /** @type {HTMLImageElement} */ (img.cloneNode()); + + // The thumbnail was picked for a column, so it has fewer pixels than the + // lightbox needs. Naming the full-size file beats leaving the browser to + // reselect, which keeps the small one on screen until the swap lands. + clone.removeAttribute('srcset'); + clone.removeAttribute('sizes'); + clone.src = largest(img); + + frame.appendChild(clone); + lightbox.append(frame, close); + document.body.append(overlay, lightbox); + + // Refusing the scroll on the overlay itself, the way PhotoSwipe does it. + // Hiding the document's overflow would take the scrollbar away, widening + // the viewport and shifting the content out from under the fixed header. + const refuse = (/** @type {Event} */ event) => event.preventDefault(); + lightbox.addEventListener('wheel', refuse, { passive: false }); + lightbox.addEventListener('touchmove', refuse, { passive: false }); + + place(frame, img.getBoundingClientRect()); + img.dataset.hidden = 'true'; + + if (stillFrames()) { + place(frame, targetRect(img)); + } else { + frame.getBoundingClientRect(); + requestAnimationFrame(() => { + frame.style.transition = `all ${duration}ms ease-out`; + place(frame, targetRect(img)); + }); + } + + /** @param {KeyboardEvent} event */ + const onKey = (event) => { + if (event.key === 'Escape') dismiss?.(); + }; + + dismiss = () => { + dismiss = null; + document.removeEventListener('keydown', onKey); + overlay.classList.add('zoom-overlay--closing'); + + // The way out has been taken, so the button has no job for the length of + // the animation. Leaving it up reads as the lightbox failing to close. + close.remove(); + + let closed = false; + const done = () => { + if (closed) return; + closed = true; + + overlay.remove(); + lightbox.remove(); + delete img.dataset.hidden; + img.focus({ preventScroll: true }); + }; + + if (stillFrames()) { + done(); + return; + } + + // Recomputed, because the page can scroll while the lightbox is open + place(frame, img.getBoundingClientRect()); + frame.addEventListener('transitionend', done, { once: true }); + setTimeout(done, duration + 50); + }; + + overlay.addEventListener('click', () => dismiss?.()); + lightbox.addEventListener('click', () => dismiss?.()); + document.addEventListener('keydown', onKey); + + // Focus stays on the image otherwise, which is hidden while the lightbox is + // open, so the first Tab would walk into the page behind it instead of + // reaching the way out. + close.focus({ preventScroll: true }); +} /** - * Enables opening image in new tab + * Opens content images in a full-viewport lightbox. + * + * The image carries the interaction itself, so the markup stays a plain + * . tabindex is what a wrapping button would otherwise have given for + * free, and without it the zoom would be mouse-only. */ function enhanceFigures() { - qsa(`figure > p > img, [data-image] > .image__img`).forEach((node) => { - const src = node.src; + qsa('figure img').forEach((img) => { + img.tabIndex = 0; - const magnify = document.createElement('button'); - magnify.classList.add(activeClass); - magnify.title = 'Enlarge'; + // Fetching the full size on the way to the click means the zoom does not + // spend its first frames showing the column-sized version stretched + const warm = () => { + const full = largest(img); + if (full !== img.currentSrc) new Image().src = full; + }; - const magnifyContainer = document.createElement('div'); - magnifyContainer.className = 'magnify-container'; - magnifyContainer.appendChild(magnify); + img.addEventListener('pointerenter', warm, { once: true }); + img.addEventListener('focus', warm, { once: true }); + img.addEventListener('touchstart', warm, { once: true, passive: true }); - node.insertAdjacentElement('beforebegin', magnifyContainer); + img.addEventListener('click', () => open(img)); + img.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; - magnify.addEventListener('click', async () => { - window.open(src); + event.preventDefault(); + open(img); }); }); } diff --git a/src/styles/main.css b/src/styles/main.css index c7ab53f4ea..46d6207de3 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -227,34 +227,19 @@ img { background-color: var(--color-base-primary); } -/* Image component */ -.image { - border-radius: 0.9375rem; - border: 0.0625rem solid var(--navy-200); - background: var(--grey-lighter); - display: flex; - flex-direction: column; - gap: 1.56rem; - padding: 2rem; - position: relative; -} - -@media (max-width: 930px) { - .image { - padding: 1rem; - } -} - -.image .magnify-container { - position: absolute; - right: 0; +figure img { + display: block; + /* Only html is border-box, so without this the border pushes the image + 2px past the max-width: 100% it inherits from the global img rule */ + box-sizing: border-box; + margin-inline: auto; + border: var(--borderWidth1) solid var(--colorBorderPrimary); + border-radius: 0.5rem; + cursor: zoom-in; } -.image__img { - border-radius: 0.6875rem; - box-shadow: 0px 4px 20px 0px rgba(0, 0, 0, 0.1); - width: fit-content; - margin: 0 auto; +figure img[data-hidden='true'] { + visibility: hidden; } .image__caption { @@ -262,29 +247,62 @@ img { font-size: var(--fontSizeBase); font-weight: var(--fontWeight400); text-align: start; + margin-block-start: 0.8125rem; } -/* TODO: Once all images are used with Image component these styles should be removed */ -figure > p > img { - display: flex; - margin: 0 auto; - border-radius: 0.9375rem; +.zoom-overlay { + position: fixed; + inset: 0; + z-index: 100; + background: var(--color-base-primary); + animation: zoom-fade 200ms ease-out; } -figure:has(p > img) { - width: fit-content; +.zoom-overlay--closing { + animation: zoom-fade 200ms ease-out reverse forwards; } -figure:has(p > img) p { - border: 0.0625rem solid var(--navy-200); - border-radius: 0.9375rem; - background: var(--grey-lighter); - padding: 2rem; +.zoom-lightbox { + position: fixed; + inset: 0; + z-index: 101; + cursor: zoom-out; } -@media (max-width: 930px) { - figure:has(p > img) p { - padding: 1rem; +.zoom-lightbox > div { + position: absolute; +} + +.zoom-close { + position: absolute; + top: 0.75rem; + right: 0.75rem; + width: 2.75rem; + height: 2.75rem; + border-radius: 50%; + background: none; + color: var(--color-heading); + font-size: 1.75rem; + line-height: 1; + cursor: pointer; +} + +.zoom-close:hover, +.zoom-close:focus-visible { + background: var(--bg-color-menu); +} + +.zoom-lightbox img { + box-sizing: border-box; + width: 100%; + height: 100%; + border: var(--borderWidth1) solid var(--colorBorderPrimary); + border-radius: 0.5rem; +} + +@keyframes zoom-fade { + from { + opacity: 0; } } @@ -2776,54 +2794,6 @@ a[data-youtube] { cursor: pointer; } -.magnify-container { - max-height: 0px; - margin: 0; - width: 100%; - text-align: end; - z-index: 1; - position: relative; - top: -1rem; -} - -.magnify-icon { - opacity: 0; - border-radius: 0.2rem; - color: var(--body-link-color); - display: inline-block; - cursor: pointer; - background-color: var(--color-base-primary); - border-radius: 50%; - padding: 2px; - width: 2.6rem; - height: 2.6rem; -} - -.input-touch .magnify-icon { - opacity: 1; -} - -.magnify-icon:before { - /* fa-magnifying-glass-plus */ - content: '\f00e'; - font-family: fa-solid; - font-size: var(--fontSizeLarge); - line-height: 2rem; -} - -figure:hover .magnify-icon, -figure:focus .magnify-icon { - opacity: 1; -} - -.magnify-icon:hover, -.magnify-icon:focus, -.magnify-icon:focus-within { - stroke: var(--fore-link); - transform: rotate(4deg); - opacity: 1; -} - /* Custom Divisions */ .simple-grid {