yggdrasil/public/js/lightbox.js
xfy c1610ace99
Some checks failed
CI / check (push) Failing after 5m16s
CI / build (push) Has been skipped
fix(lightbox): prevent background flash on open
overlay inherited opacity:1 from .lightbox-overlay CSS, so the moment
it was appended to the DOM it painted a full-opacity black background.
this showed for ~24ms (the time until the image loaded and start() ran
the first frame that sets opacity 0), visible as a black flash before
the fade-in.

set overlay.style.opacity = '0' right after creating it, so it is
transparent when appended; start()'s transition then fades it to 1 as
intended.
2026-06-23 17:37:38 +08:00

541 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
"use strict";
// ============ 工具函数 ============
function prefersReducedMotion() {
return (
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
// 读取元素当前在视口里的 rect用于飞行起点/终点)。
// 统一映射成 {x,y,w,h}getBoundingClientRect 返回的 DOMRect 用
// left/top/width/height而 fitCentered/transformFor 用 x/y/w/h
// 这里转成一致格式,避免 .w 读到 undefined。
function rectOf(el) {
var r = el.getBoundingClientRect();
return {
x: r.left,
y: r.top,
w: r.width,
h: r.height,
};
}
// 计算图片在视口居中、contain 适配后的目标 rect。
// naturalW/H: 图片真实像素尺寸vw/vh: 视口尺寸。
function fitCentered(naturalW, naturalH, vw, vh) {
var maxW = vw * 0.92;
var maxH = vh * 0.88;
var scale = Math.min(maxW / naturalW, maxH / naturalH, 1);
var w = naturalW * scale;
var h = naturalH * scale;
return {
x: (vw - w) / 2,
y: (vh - h) / 2,
w: w,
h: h,
};
}
// 把目标 rect 转成 transform 字符串。
// baseW/baseH 是 img 元素的布局尺寸(=居中目标尺寸scale 相对它缩放。
// transform-origin 为 top left见 CSStranslate 到 rect 左上角后 scale。
// - 居中态scale=1base 就是居中尺寸)
// - originRect 态scale = originRect.w / base.w缩小
function transformFor(rect, baseW, baseH) {
var sx = baseW > 0 ? rect.w / baseW : 1;
var sy = baseH > 0 ? rect.h / baseH : 1;
return (
"translate(" +
rect.x +
"px," +
rect.y +
"px) scale(" +
sx +
"," +
sy +
")"
);
}
// 原图 URL = data-src 去 query。data-src 形如 "/uploads/x.webp?w=800"。
function originalUrl(dataSrc) {
return (dataSrc || "").split("?")[0];
}
// ============ 懒加载 ============
// 为单个 .blur-img 容器初始化高清图懒加载。
// IO 进入视口后把 data-src 写入 src加载完成加 is-loaded 触发 CSS 淡入。
function initLazyLoad(container) {
var fullImg = container.querySelector(".blur-img-full");
if (!fullImg) return;
if (container.getAttribute("data-blur-init")) return;
container.setAttribute("data-blur-init", "true");
var fullSrc = fullImg.getAttribute("data-src");
if (!fullSrc) return;
var onFullLoaded = function () {
// 给容器加 is-loadedCSS 据此显式隐藏 placeholder。
// 直接把 full 层 opacity 设为 1清掉 transition不依赖 CSS 的 opacity
// 过渡:合成层重绘时机不稳定,可能导致 full 层卡在 opacity:0直到一次
// 强制重排才更新。
container.classList.add("is-loaded");
fullImg.style.transition = "none";
fullImg.style.opacity = "1";
};
fullImg.addEventListener("load", onFullLoaded);
// 缓存兜底:若设 src 时图片已在缓存load 几乎立即触发,可能早于监听注册),
// 用 complete 补一次。注意无 src 的 img complete 也为 true故先判 src。
if (fullImg.getAttribute("src") && fullImg.complete) {
onFullLoaded();
}
if ("IntersectionObserver" in window) {
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
fullImg.src = fullSrc;
io.unobserve(container);
}
});
},
{ rootMargin: "200px" }
);
io.observe(container);
} else {
fullImg.src = fullSrc;
}
}
// ============ 图像收集 ============
// 收集所有 selectors命中的 .blur-img 节点。
// gallery: 正文图组成图集singles: 带 lightbox-single class 的单张图(如封面)。
function collectImages(roots) {
var gallery = [];
var singles = [];
for (var i = 0; i < roots.length; i++) {
var nodes = roots[i].querySelectorAll(".blur-img");
for (var j = 0; j < nodes.length; j++) {
var n = nodes[j];
if (n.classList.contains("lightbox-single")) {
singles.push(n);
} else {
gallery.push(n);
}
}
}
return { gallery: gallery, singles: singles };
}
// ============ 灯箱状态与开/关 ============
// 当前灯箱状态(同时只允许一个灯箱)。
var state = null;
function openLightbox(originNode, gallery, index) {
if (state) closeLightbox(true);
var fullImg = originNode.querySelector(".blur-img-full");
if (!fullImg) return;
var dataSrc = fullImg.getAttribute("data-src") || "";
var origSrc = originalUrl(dataSrc);
var altText = fullImg.getAttribute("alt") || "";
var isSingle =
originNode.classList.contains("lightbox-single") ||
gallery.length === 0;
var vw = window.innerWidth;
var vh = window.innerHeight;
// 建 DOM
var overlay = document.createElement("div");
overlay.className = "lightbox-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "图片预览");
overlay.setAttribute("tabindex", "-1");
// 创建后立即设 opacity 0append 到 DOM 时是透明的,避免在图片加载期间
// start() 执行前显示全黑背景造成闪烁。start() 的渐变会把 opacity 升到 1。
overlay.style.opacity = "0";
var img = document.createElement("img");
img.className = "lightbox-img";
img.setAttribute("alt", altText);
// 加载前先占 0 尺寸,避免原图(可能数千 px在加载期间撑大文档
// 可滚动区、触发非预期的 scroll 事件。start() 拿到 natural 尺寸后再设真实值。
img.style.width = "0px";
img.style.height = "0px";
var caption = document.createElement("figcaption");
caption.className = "lightbox-caption";
caption.textContent = altText;
if (!altText) caption.style.display = "none";
var counter = document.createElement("div");
counter.className = "lightbox-counter";
if (isSingle || gallery.length === 0) {
counter.style.display = "none";
} else {
counter.textContent = index + 1 + " / " + gallery.length;
}
// 图集模式(>1 张)才加左右导航箭头;单张不显示。
var prevBtn = null;
var nextBtn = null;
if (!isSingle && gallery.length > 1) {
prevBtn = document.createElement("button");
prevBtn.className = "lightbox-nav lightbox-prev";
prevBtn.setAttribute("type", "button");
prevBtn.setAttribute("aria-label", "上一张");
prevBtn.textContent = "\u2039";
nextBtn = document.createElement("button");
nextBtn.className = "lightbox-nav lightbox-next";
nextBtn.setAttribute("type", "button");
nextBtn.setAttribute("aria-label", "下一张");
nextBtn.textContent = "\u203a";
}
overlay.appendChild(img);
overlay.appendChild(caption);
overlay.appendChild(counter);
if (prevBtn) overlay.appendChild(prevBtn);
if (nextBtn) overlay.appendChild(nextBtn);
document.body.appendChild(overlay);
state = {
overlay: overlay,
img: img,
caption: caption,
counter: counter,
prevBtn: prevBtn,
nextBtn: nextBtn,
originNode: originNode,
gallery: gallery,
index: index,
isSingle: isSingle,
openScrollY: window.scrollY,
origSrc: origSrc,
altText: altText,
closing: false,
reduced: prefersReducedMotion(),
scrollHandler: null,
keyHandler: null,
};
// 焦点移入灯箱
overlay.focus();
// 立即绑定交互(不等图片加载):加载期间 Esc/滚动/点背景也须可关闭。
bindInteractions();
// 图片加载后再做动画naturalW/H 要等加载)
var start = function () {
if (!state) return; // 加载前可能已被关闭
var naturalW = img.naturalWidth || img.clientWidth || 1;
var naturalH = img.naturalHeight || img.clientHeight || 1;
var originRect = rectOf(originNode);
// 基准 = originRect文章里图片的实际尺寸
// img 的布局尺寸固定为 originRecttransform 的 scale 相对它缩放:
// 首帧文章位置scale=1居中态 scale=target.w/originRect.w。
// 这样无论 target 比 originRect 大或小,动画都是「从文章图原样连续缩放」,
// 视觉上是原地展开不会像「从外面飞来」。灯箱图尺寸恒为视口最大fitCentered
var target = fitCentered(naturalW, naturalH, vw, vh);
var baseW = originRect.w;
var baseH = originRect.h;
// 存基准与目标,供关闭/滚动关闭复用同一对(保证 scale 连续)。
state.target = target;
state.baseW = baseW;
state.baseH = baseH;
img.style.width = baseW + "px";
img.style.height = baseH + "px";
// reduced-motion直接淡入居中
if (state.reduced) {
img.style.opacity = "0";
img.style.transform = transformFor(target, baseW, baseH);
img.style.left = "0";
img.style.top = "0";
overlay.style.opacity = "0";
// 下一帧淡入
requestAnimationFrame(function () {
if (!state) return;
overlay.style.transition = "opacity 200ms ease-out";
img.style.transition = "opacity 200ms ease-out";
overlay.style.opacity = "1";
img.style.opacity = "1";
});
return;
}
// 首帧:文章位置 + 原尺寸scale=1透明且关闭 transition
img.style.transition = "none";
img.style.left = "0";
img.style.top = "0";
img.style.transform = transformFor(originRect, baseW, baseH);
img.style.opacity = "0";
overlay.style.opacity = "0";
// 强制 reflow确保首帧的 transform 已提交到渲染层。
// 否则单层 rAF 里浏览器可能合并首帧与目标帧,动画从错误位置起跳。
void img.offsetHeight;
// double-rAF第一帧绘制首帧无动画第二帧才启动 transition 到居中。
requestAnimationFrame(function () {
if (!state) return;
requestAnimationFrame(function () {
if (!state) return;
img.style.transition =
"transform 250ms ease-out, opacity 250ms ease-out";
overlay.style.transition = "opacity 250ms ease-out";
img.style.transform = transformFor(target, baseW, baseH);
img.style.opacity = "1";
overlay.style.opacity = "1";
});
});
};
if (img.complete && img.naturalWidth) {
start();
} else {
img.addEventListener("load", start, { once: true });
}
img.src = origSrc;
}
function closeLightbox(immediate) {
if (!state || state.closing) return;
state.closing = true;
cleanupInteractions();
var s = state;
// 基准 = originRect 尺寸与打开时一致scale 相对它缩放。
var baseW = s.baseW || (s.target ? s.target.w : 1);
var baseH = s.baseH || (s.target ? s.target.h : 1);
var originRect = rectOf(s.originNode); // 实时读,处理期间滚动过的情况
if (s.reduced || immediate) {
removeOverlay();
return;
}
// 飞回 originRectscale 从 1 缩到 originRect.w/baseW
s.img.style.transition =
"transform 250ms ease-out, opacity 250ms ease-out";
s.overlay.style.transition = "opacity 250ms ease-out";
s.img.style.transform = transformFor(originRect, baseW, baseH);
s.img.style.opacity = "0";
s.overlay.style.opacity = "0";
var done = function () {
removeOverlay();
};
// 250ms 兜底,避免 transitionend 不触发
var timer = setTimeout(done, 280);
s.img.addEventListener("transitionend", function () { clearTimeout(timer); done(); }, { once: true });
}
function removeOverlay() {
if (!state) return;
var prev = state.originNode;
if (state.overlay && state.overlay.parentNode) {
state.overlay.parentNode.removeChild(state.overlay);
}
state = null;
// 焦点归还:.blur-img 是 span 不可聚焦,让其内部 full img 获得焦点
if (prev) {
var f = prev.querySelector(".blur-img-full");
if (f) {
f.setAttribute("tabindex", "-1");
f.focus();
}
}
}
// ============ 图集切换 ============
// 图集切换淡入淡出不飞行。newIndex 循环(首尾衔接)。
function gotoIndex(newIndex) {
if (!state || state.isSingle) return;
var s = state;
if (!s.gallery || s.gallery.length === 0) return;
if (newIndex < 0) newIndex = s.gallery.length - 1;
if (newIndex >= s.gallery.length) newIndex = 0;
if (newIndex === s.index) return;
var newNode = s.gallery[newIndex];
var fullImg = newNode.querySelector(".blur-img-full");
if (!fullImg) return;
var origSrc = originalUrl(fullImg.getAttribute("data-src") || "");
var altText = fullImg.getAttribute("alt") || "";
// 淡出当前图
s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "0";
// 150ms 后换图淡入
var swap = function () {
if (!state) return; // 切换中可能已关闭
var fade = function () {
if (!state) return;
s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "1";
};
if (s.img.complete && s.img.naturalWidth) {
// 缓存命中,直接淡入
s.img.src = origSrc;
fade();
} else {
s.img.addEventListener("load", fade, { once: true });
s.img.src = origSrc;
}
s.caption.textContent = altText;
s.caption.style.display = altText ? "" : "none";
s.counter.textContent = newIndex + 1 + " / " + s.gallery.length;
// 更新 originNode 为新图,使后续关闭/滚动关闭飞回新图位置
s.originNode = newNode;
s.index = newIndex;
s.openScrollY = window.scrollY; // 重置滚动关闭基线
};
setTimeout(swap, 150);
}
// ============ 交互绑定 ============
function bindInteractions() {
var s = state;
if (!s) return;
// 点背景关闭(点图片本身不关,因箭头在图上、避免误关)
s.overlay.addEventListener("click", function (ev) {
if (state && ev.target === state.overlay) closeLightbox(false);
});
// 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。
// 关键:逐帧读 originNode 实时 rect文章滚多少图就回多少。
s.scrollHandler = function () {
if (!state) return;
var st = state;
if (st.closing) return;
var dy = Math.abs(window.scrollY - st.openScrollY);
if (st.reduced) {
// reduced-motion立即关
closeLightbox(true);
return;
}
var progress = Math.min(dy / 120, 1);
var target = st.target;
var baseW = st.baseW || (target ? target.w : 1);
var baseH = st.baseH || (target ? target.h : 1);
var originRect = rectOf(st.originNode);
// 在 originRect 与居中 target 之间按 progress 线性插值
var cur = {
x: target.x + (originRect.x - target.x) * progress,
y: target.y + (originRect.y - target.y) * progress,
w: target.w + (originRect.w - target.w) * progress,
h: target.h + (originRect.h - target.h) * progress,
};
st.img.style.transition = "none";
st.img.style.transform = transformFor(cur, baseW, baseH);
st.img.style.opacity = String(1 - progress);
st.overlay.style.opacity = String(1 - progress);
if (progress >= 1) {
// 已飞回原位:文章停在当前滚动位置,移除灯箱
st.closing = true;
cleanupInteractions();
removeOverlay();
}
};
window.addEventListener("scroll", s.scrollHandler, { passive: true });
// 键盘Esc 关;图集模式 ←→ 切换
s.keyHandler = function (ev) {
if (!state) return;
if (ev.key === "Escape") {
closeLightbox(false);
} else if (!state.isSingle && state.gallery.length > 1) {
if (ev.key === "ArrowLeft") {
ev.preventDefault();
gotoIndex(state.index - 1);
} else if (ev.key === "ArrowRight") {
ev.preventDefault();
gotoIndex(state.index + 1);
}
}
};
document.addEventListener("keydown", s.keyHandler);
// 图集导航箭头点击stopPropagation 防止冒泡到 overlay 触发关闭)
if (s.prevBtn) {
s.prevBtn.addEventListener("click", function (ev) {
ev.stopPropagation();
if (state) gotoIndex(state.index - 1);
});
}
if (s.nextBtn) {
s.nextBtn.addEventListener("click", function (ev) {
ev.stopPropagation();
if (state) gotoIndex(state.index + 1);
});
}
}
function cleanupInteractions() {
if (!state) return;
if (state.scrollHandler) {
window.removeEventListener("scroll", state.scrollHandler);
state.scrollHandler = null;
}
if (state.keyHandler) {
document.removeEventListener("keydown", state.keyHandler);
state.keyHandler = null;
}
}
// ============ 初始化入口 ============
window.__initLightbox = function (selectors) {
// selectors 可以是字符串、字符串数组
var sels = Array.isArray(selectors) ? selectors : [selectors];
var roots = [];
for (var i = 0; i < sels.length; i++) {
var found = document.querySelectorAll(sels[i]);
for (var j = 0; j < found.length; j++) roots.push(found[j]);
}
// 先对所有图片做懒加载(图集与单张都做)
var collected = collectImages(roots);
var everyone = collected.gallery.concat(collected.singles);
for (var k = 0; k < everyone.length; k++) {
initLazyLoad(everyone[k]);
}
// 正文图:带 index
var gallery = collected.gallery;
for (var g = 0; g < gallery.length; g++) {
(function (node, idx) {
node.addEventListener("click", function (e) {
e.preventDefault();
openLightbox(node, gallery, idx);
});
})(gallery[g], g);
}
// 单张图封面index = nullgallery 传空数组表示单张
for (var si = 0; si < collected.singles.length; si++) {
(function (node) {
node.addEventListener("click", function (e) {
e.preventDefault();
openLightbox(node, [], null);
});
})(collected.singles[si]);
}
};
})();