mirror of
https://github.com/ChenQihan666/MyBlog-Next.git
synced 2026-08-13 23:31:07 +08:00
feat: add accessible image lightbox for post images (#654)
* feat: add accessible image lightbox for post images * fix: iOS scroll lock, backdrop blur, dvh image sizing for lightbox * feat: add vanilla pinch-to-zoom to image lightbox * fix: improve touch event handling and scaling logic in image lightbox --------- Co-authored-by: satnaing <satnaingdev@gmail.com>
This commit is contained in:
@@ -260,6 +260,272 @@ const ogImage = ogImageUrl
|
||||
}
|
||||
attachCopyButtons();
|
||||
|
||||
/** Accessible image lightbox for article images. */
|
||||
function initLightbox() {
|
||||
const article = document.getElementById("article");
|
||||
if (!article) return;
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
let overlay = null;
|
||||
let lastFocused = null;
|
||||
|
||||
// Defer attribute mutations so they don't push the LCP timestamp.
|
||||
// Event listeners below use delegation and don't need the attributes to exist yet.
|
||||
requestAnimationFrame(() => {
|
||||
const images = Array.from(article.querySelectorAll("img"));
|
||||
for (const image of images) {
|
||||
if (image.closest("a")) continue;
|
||||
image.setAttribute("role", "button");
|
||||
image.setAttribute("tabindex", "0");
|
||||
image.setAttribute("aria-haspopup", "dialog");
|
||||
image.setAttribute(
|
||||
"aria-label",
|
||||
image.alt ? `Zoom image: ${image.alt}` : "Zoom image"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function open(src, alt, trigger) {
|
||||
if (overlay) return;
|
||||
lastFocused = trigger ?? document.activeElement;
|
||||
|
||||
overlay = document.createElement("div");
|
||||
overlay.setAttribute("role", "dialog");
|
||||
overlay.setAttribute("aria-modal", "true");
|
||||
overlay.setAttribute(
|
||||
"aria-label",
|
||||
alt ? `Image preview: ${alt}` : "Image preview"
|
||||
);
|
||||
overlay.className =
|
||||
"fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/70 backdrop-blur-sm opacity-0 transition-opacity duration-200 motion-reduce:transition-none";
|
||||
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.type = "button";
|
||||
closeButton.setAttribute("aria-label", "Close image preview");
|
||||
closeButton.className =
|
||||
"absolute end-4 top-4 rounded p-2 text-3xl leading-none text-white";
|
||||
closeButton.innerHTML = "✕";
|
||||
closeButton.addEventListener("click", close);
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.src = src;
|
||||
image.alt = "";
|
||||
image.className =
|
||||
"max-h-[90dvh] max-w-[90dvw] cursor-default object-contain";
|
||||
|
||||
overlay.append(closeButton, image);
|
||||
overlay.addEventListener("click", e => {
|
||||
if (e.target === overlay && currentScale <= 1) close();
|
||||
});
|
||||
|
||||
let currentScale = 1;
|
||||
let translateX = 0;
|
||||
let translateY = 0;
|
||||
let initialDist = 0;
|
||||
let initialScale = 1;
|
||||
let panStartX = 0;
|
||||
let panStartY = 0;
|
||||
let panStartTranslateX = 0;
|
||||
let panStartTranslateY = 0;
|
||||
let lastTapTime = 0;
|
||||
|
||||
function applyTransform() {
|
||||
image.style.transform = `scale(${currentScale}) translate(${translateX}px, ${translateY}px)`;
|
||||
}
|
||||
|
||||
function resetTransform() {
|
||||
currentScale = 1;
|
||||
translateX = 0;
|
||||
translateY = 0;
|
||||
image.style.transform = "";
|
||||
}
|
||||
|
||||
overlay.addEventListener(
|
||||
"touchstart",
|
||||
e => {
|
||||
const t = e.touches;
|
||||
if (t.length === 2) {
|
||||
initialDist = Math.hypot(
|
||||
t[1].clientX - t[0].clientX,
|
||||
t[1].clientY - t[0].clientY
|
||||
);
|
||||
initialScale = currentScale;
|
||||
} else if (t.length === 1) {
|
||||
const now = Date.now();
|
||||
if (now - lastTapTime < 300) {
|
||||
e.preventDefault();
|
||||
if (currentScale > 1) {
|
||||
resetTransform();
|
||||
} else {
|
||||
currentScale = 2;
|
||||
translateX = 0;
|
||||
translateY = 0;
|
||||
applyTransform();
|
||||
}
|
||||
lastTapTime = 0;
|
||||
panStartX = t[0].clientX;
|
||||
panStartY = t[0].clientY;
|
||||
panStartTranslateX = translateX;
|
||||
panStartTranslateY = translateY;
|
||||
} else {
|
||||
lastTapTime = now;
|
||||
if (currentScale > 1) {
|
||||
panStartX = t[0].clientX;
|
||||
panStartY = t[0].clientY;
|
||||
panStartTranslateX = translateX;
|
||||
panStartTranslateY = translateY;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
overlay.addEventListener(
|
||||
"touchmove",
|
||||
e => {
|
||||
const t = e.touches;
|
||||
if (t.length === 2) {
|
||||
e.preventDefault();
|
||||
const dist = Math.hypot(
|
||||
t[1].clientX - t[0].clientX,
|
||||
t[1].clientY - t[0].clientY
|
||||
);
|
||||
currentScale = Math.min(
|
||||
4,
|
||||
Math.max(1, initialScale * (dist / initialDist))
|
||||
);
|
||||
applyTransform();
|
||||
} else if (t.length === 1) {
|
||||
if (currentScale > 1) {
|
||||
e.preventDefault();
|
||||
translateX =
|
||||
panStartTranslateX + (t[0].clientX - panStartX) / currentScale;
|
||||
translateY =
|
||||
panStartTranslateY + (t[0].clientY - panStartY) / currentScale;
|
||||
const maxX = Math.max(
|
||||
0,
|
||||
(image.clientWidth - overlay.clientWidth / currentScale) / 2
|
||||
);
|
||||
const maxY = Math.max(
|
||||
0,
|
||||
(image.clientHeight - overlay.clientHeight / currentScale) / 2
|
||||
);
|
||||
translateX = Math.min(maxX, Math.max(-maxX, translateX));
|
||||
translateY = Math.min(maxY, Math.max(-maxY, translateY));
|
||||
applyTransform();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
overlay.addEventListener("touchend", e => {
|
||||
if (e.touches.length === 0 && currentScale <= 1.05) {
|
||||
resetTransform();
|
||||
}
|
||||
});
|
||||
|
||||
overlay.addEventListener("touchcancel", e => {
|
||||
if (e.touches.length === 0 && currentScale <= 1.05) {
|
||||
resetTransform();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
document.body.style.overflow = "hidden";
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
window.__closeLightbox = close;
|
||||
|
||||
requestAnimationFrame(() => overlay?.classList.add("opacity-100"));
|
||||
closeButton.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!overlay) return;
|
||||
const el = overlay;
|
||||
overlay = null;
|
||||
window.__closeLightbox = null;
|
||||
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
lastFocused?.focus();
|
||||
lastFocused = null;
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
el.remove();
|
||||
return;
|
||||
}
|
||||
const remove = () => el.remove();
|
||||
el.addEventListener("transitionend", remove, { once: true });
|
||||
setTimeout(remove, 250); // fallback in case transitionend never fires
|
||||
el.classList.remove("opacity-100");
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === "Escape") {
|
||||
close();
|
||||
} else if (e.key === "Tab") {
|
||||
trapFocus(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep keyboard focus inside the open dialog.
|
||||
function trapFocus(e) {
|
||||
if (!overlay) return;
|
||||
const focusables = overlay.querySelectorAll(
|
||||
'a[href], button, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function triggerFromEvent(e) {
|
||||
const image = e.target.closest("img");
|
||||
if (!image || !article.contains(image) || image.closest("a")) return null;
|
||||
return image;
|
||||
}
|
||||
|
||||
function activate(image) {
|
||||
open(image.currentSrc || image.src, image.alt, image);
|
||||
}
|
||||
|
||||
article.addEventListener("click", e => {
|
||||
const image = triggerFromEvent(e);
|
||||
if (!image) return;
|
||||
e.preventDefault();
|
||||
activate(image);
|
||||
});
|
||||
|
||||
article.addEventListener("keydown", e => {
|
||||
if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return;
|
||||
const image = triggerFromEvent(e);
|
||||
if (!image) return;
|
||||
e.preventDefault();
|
||||
activate(image);
|
||||
});
|
||||
}
|
||||
initLightbox();
|
||||
|
||||
if (!window.__lightboxSwapBound) {
|
||||
window.__lightboxSwapBound = true;
|
||||
document.addEventListener("astro:before-swap", () =>
|
||||
window.__closeLightbox?.()
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("astro:after-swap", () =>
|
||||
window.scrollTo({ left: 0, top: 0, behavior: "instant" })
|
||||
);
|
||||
|
||||
@@ -47,6 +47,14 @@
|
||||
@apply border-border mx-auto border;
|
||||
}
|
||||
|
||||
img[role="button"] {
|
||||
@apply cursor-zoom-in;
|
||||
|
||||
&:focus-visible {
|
||||
@apply outline-2 outline-offset-2 outline-dashed;
|
||||
}
|
||||
}
|
||||
|
||||
figcaption {
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user