Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 185 additions & 14 deletions src/scripts/modules/figures.js
Original file line number Diff line number Diff line change
@@ -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
* <img>. 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);

@borland borland Aug 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our responsive image thing renders image tags like this:

<img src="/docs/i/x/getting-started/dashboard.png" alt="Octopus Dashboard" srcset="/docs/i/600/getting-started/dashboard.webp 600w, /docs/i/1000/getting-started/dashboard.webp 1000w, /docs/i/2000/getting-started/dashboard.webp 1500w" sizes="(min-width: 1680px) 1000px, (min-width: 940px) calc(71.81vw - 192px), calc(100vw - 32px)" class="resp-img" width="1500" height="736" style="--shown: 1;" tabindex="0">

The original/largest image is just in the src attribute. We should ignore the srcset when looking to swap in a full-sized replacement

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);
});
});
}
Expand Down
148 changes: 59 additions & 89 deletions src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -227,64 +227,82 @@ 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 {
color: var(--color-heading);
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;
}
}

Expand Down Expand Up @@ -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 {
Expand Down