xfy 25eacc1c3c refactor(lightbox): modernize to const/let, template literals, drop IIFE
纯现代化重构,行为不变(23 个回归测试全绿作证):
- var → const/let(state、gotoIndex 的 newIndex 用 let,其余 const)
- 循环 for+var+IIFE包装 → for..of/forEach + const(天然捕获迭代变量,
  消除旧 var 循环闭包必须立即执行函数固定变量的写法)
- 字符串拼接 → 模板字面量(counter 文本、transform、width/height)
- 事件处理 function() → 箭头函数(无 this 依赖场景)
- 拆掉外层 (function(){'use strict'; ... })():ES module 作用域已封装,
  IIFE 是历史遗留;辅助函数仍用 function 声明(openLightbox↔closeLightbox
  互递归依赖提升)

几何纯函数(fitCentered/transformFor/originalUrl)已在 geometry.ts,
本文件只剩 DOM 胶水 + state 单例。
2026-06-24 11:44:06 +08:00

546 lines
18 KiB
TypeScript
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.

import { fitCentered, transformFor, originalUrl, type Rect } from "./geometry";
import "./style.css";
interface LightboxState {
overlay: HTMLDivElement;
img: HTMLImageElement;
caption: HTMLElement;
counter: HTMLDivElement;
prevBtn: HTMLButtonElement | null;
nextBtn: HTMLButtonElement | null;
originNode: HTMLElement;
gallery: HTMLElement[];
index: number | null;
isSingle: boolean;
openScrollY: number;
origSrc: string;
altText: string;
closing: boolean;
reduced: boolean;
scrollHandler: ((this: Window, ev: Event) => void) | null;
keyHandler: ((this: Document, ev: KeyboardEvent) => void) | null;
target?: Rect;
baseW?: number;
baseH?: number;
}
declare global {
interface Window {
__initLightbox: (selectors: string | string[]) => void;
__lightboxSelectors?: string[];
}
}
export {};
// ============ 工具函数 ============
function prefersReducedMotion(): boolean {
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: Element): Rect {
const r = el.getBoundingClientRect();
return {
x: r.left,
y: r.top,
w: r.width,
h: r.height,
};
}
// 注意fitCentered / transformFor / originalUrl 已抽到 ./geometry.ts。
// ============ 懒加载 ============
// 为单个 .blur-img 容器初始化高清图懒加载。
// IO 进入视口后把 data-src 写入 src加载完成加 is-loaded 触发 CSS 淡入。
function initLazyLoad(container: Element): void {
const raw = container.querySelector(".blur-img-full");
if (!(raw instanceof HTMLImageElement)) return;
const fullImg: HTMLImageElement = raw;
if (container.getAttribute("data-blur-init")) return;
container.setAttribute("data-blur-init", "true");
const fullSrc = fullImg.getAttribute("data-src");
if (!fullSrc) return;
const onFullLoaded = (): void => {
// 给容器加 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) {
const io = new IntersectionObserver(
(entries: IntersectionObserverEntry[]): void => {
for (const entry of entries) {
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: Element[]): { gallery: HTMLElement[]; singles: HTMLElement[] } {
const gallery: HTMLElement[] = [];
const singles: HTMLElement[] = [];
for (const root of roots) {
const nodes = root.querySelectorAll(".blur-img");
for (const n of nodes) {
if (!(n instanceof HTMLElement)) continue;
if (n.classList.contains("lightbox-single")) {
singles.push(n);
} else {
gallery.push(n);
}
}
}
return { gallery, singles };
}
// ============ 灯箱状态与开/关 ============
// 当前灯箱状态(同时只允许一个灯箱)。
let state: LightboxState | null = null;
function openLightbox(originNode: HTMLElement, gallery: HTMLElement[], index: number | null): void {
if (state) closeLightbox(true);
const fullImgEl = originNode.querySelector(".blur-img-full");
if (!(fullImgEl instanceof HTMLImageElement)) return;
const dataSrc = fullImgEl.getAttribute("data-src") || "";
const origSrc = originalUrl(dataSrc);
const altText = fullImgEl.getAttribute("alt") || "";
const isSingle =
originNode.classList.contains("lightbox-single") ||
gallery.length === 0;
const vw = window.innerWidth;
const vh = window.innerHeight;
// 建 DOM
const 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";
const 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";
const caption = document.createElement("figcaption");
caption.className = "lightbox-caption";
caption.textContent = altText;
if (!altText) caption.style.display = "none";
const counter = document.createElement("div");
counter.className = "lightbox-counter";
if (isSingle || gallery.length === 0) {
counter.style.display = "none";
} else {
counter.textContent = `${(index ?? 0) + 1} / ${gallery.length}`;
}
// 图集模式(>1 张)才加左右导航箭头;单张不显示。
let prevBtn: HTMLButtonElement | null = null;
let nextBtn: HTMLButtonElement | null = 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,
img,
caption,
counter,
prevBtn,
nextBtn,
originNode,
gallery,
index,
isSingle,
openScrollY: window.scrollY,
origSrc,
altText,
closing: false,
reduced: prefersReducedMotion(),
scrollHandler: null,
keyHandler: null,
};
// 焦点移入灯箱
overlay.focus();
// 立即绑定交互(不等图片加载):加载期间 Esc/滚动/点背景也须可关闭。
bindInteractions();
// 图片加载后再做动画naturalW/H 要等加载)
const start = (): void => {
if (!state) return; // 加载前可能已被关闭
const naturalW = img.naturalWidth || img.clientWidth || 1;
const naturalH = img.naturalHeight || img.clientHeight || 1;
const originRect = rectOf(originNode);
// 基准 = originRect文章里图片的实际尺寸
// img 的布局尺寸固定为 originRecttransform 的 scale 相对它缩放:
// 首帧文章位置scale=1居中态 scale=target.w/originRect.w。
// 这样无论 target 比 originRect 大或小,动画都是「从文章图原样连续缩放」,
// 视觉上是原地展开不会像「从外面飞来」。灯箱图尺寸恒为视口最大fitCentered
const target = fitCentered(naturalW, naturalH, vw, vh);
const baseW = originRect.w;
const 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((): void => {
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((): void => {
if (!state) return;
requestAnimationFrame((): void => {
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: boolean): void {
if (!state || state.closing) return;
state.closing = true;
cleanupInteractions();
const s = state;
// 基准 = originRect 尺寸与打开时一致scale 相对它缩放。
const baseW = s.baseW || (s.target ? s.target.w : 1);
const baseH = s.baseH || (s.target ? s.target.h : 1);
const 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";
const done = (): void => {
removeOverlay();
};
// 250ms 兜底,避免 transitionend 不触发
const timer = setTimeout(done, 280);
s.img.addEventListener(
"transitionend",
(): void => {
clearTimeout(timer);
done();
},
{ once: true }
);
}
function removeOverlay(): void {
if (!state) return;
const prev = state.originNode;
if (state.overlay && state.overlay.parentNode) {
state.overlay.parentNode.removeChild(state.overlay);
}
state = null;
// 焦点归还:.blur-img 是 span 不可聚焦,让其内部 full img 获得焦点。
// 用 preventScroll 抑制 focus() 默认的 scrollIntoView 行为——否则关闭灯箱后
// 页面会自动滚动把原图完整纳入视口(用户只点了一半露出的图时尤其明显)。
if (prev) {
const f = prev.querySelector(".blur-img-full");
if (f instanceof HTMLImageElement) {
f.setAttribute("tabindex", "-1");
f.focus({ preventScroll: true });
}
}
}
// ============ 图集切换 ============
// 图集切换淡入淡出不飞行。newIndex 循环(首尾衔接)。
function gotoIndex(rawIndex: number): void {
if (!state || state.isSingle) return;
const s = state;
if (!s.gallery || s.gallery.length === 0) return;
let newIndex = rawIndex;
if (newIndex < 0) newIndex = s.gallery.length - 1;
if (newIndex >= s.gallery.length) newIndex = 0;
if (newIndex === s.index) return;
const newNode = s.gallery[newIndex];
const fullImgEl = newNode.querySelector(".blur-img-full");
if (!(fullImgEl instanceof HTMLImageElement)) return;
const origSrc = originalUrl(fullImgEl.getAttribute("data-src") || "");
const altText = fullImgEl.getAttribute("alt") || "";
// 淡出当前图
s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "0";
// 150ms 后换图淡入
const swap = (): void => {
if (!state) return; // 切换中可能已关闭
const fade = (): void => {
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(): void {
const s = state;
if (!s) return;
// 点背景关闭(点图片本身不关,因箭头在图上、避免误关)
s.overlay.addEventListener("click", (ev: MouseEvent): void => {
if (state && ev.target === state.overlay) closeLightbox(false);
});
// 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。
// 关键:逐帧读 originNode 实时 rect文章滚多少图就回多少。
s.scrollHandler = (): void => {
if (!state) return;
const st = state;
if (st.closing) return;
const dy = Math.abs(window.scrollY - st.openScrollY);
if (st.reduced) {
// reduced-motion立即关
closeLightbox(true);
return;
}
const target = st.target;
const baseW = st.baseW || (target ? target.w : 1);
const baseH = st.baseH || (target ? target.h : 1);
const originRect = rectOf(st.originNode);
if (!target) return; // 无 target 时不插值(忠实原 JS 的兜底语义)
// 在 originRect 与居中 target 之间按 progress 线性插值
const progress = Math.min(dy / 120, 1);
const cur: Rect = {
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 = (ev: KeyboardEvent): void => {
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 ?? 0) - 1);
} else if (ev.key === "ArrowRight") {
ev.preventDefault();
gotoIndex((state.index ?? 0) + 1);
}
}
};
document.addEventListener("keydown", s.keyHandler);
// 图集导航箭头点击stopPropagation 防止冒泡到 overlay 触发关闭)
s.prevBtn?.addEventListener("click", (ev: MouseEvent): void => {
ev.stopPropagation();
if (state) gotoIndex((state.index ?? 0) - 1);
});
s.nextBtn?.addEventListener("click", (ev: MouseEvent): void => {
ev.stopPropagation();
if (state) gotoIndex((state.index ?? 0) + 1);
});
}
function cleanupInteractions(): void {
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 = (selectors: string | string[]): void => {
// selectors 可以是字符串、字符串数组
const sels = Array.isArray(selectors) ? selectors : [selectors];
const roots: Element[] = [];
for (const sel of sels) {
const found = document.querySelectorAll(sel);
for (const el of found) roots.push(el);
}
// 先对所有图片做懒加载(图集与单张都做)
const collected = collectImages(roots);
for (const node of collected.gallery.concat(collected.singles)) {
initLazyLoad(node);
}
// 正文图:带 index。for..of + const 天然捕获每次迭代的 idx
// 无需旧 IIFE 包装(旧 var 循环闭包必须立即执行函数固定变量)。
const gallery = collected.gallery;
gallery.forEach((node, idx) => {
node.addEventListener("click", (e: MouseEvent) => {
e.preventDefault();
openLightbox(node, gallery, idx);
});
});
// 单张图封面index = nullgallery 传空数组表示单张
for (const node of collected.singles) {
node.addEventListener("click", (e: MouseEvent) => {
e.preventDefault();
openLightbox(node, [], null);
});
}
};
// ============ 自启动 ============
// 方案 iii双保险契约无需轮询。
// 1) Rust 内联 eval 先跑(常态):设 __lightboxSelectors此时 __initLightbox 可能未定义 → 只设配置;
// lightbox.js 后加载完 → 读到配置 → 这里自启动。
// 2) lightbox.js 先加载完__initLightbox 就绪但无配置 → 不自启动;
// Rust eval 后跑 → 设配置 + 兜底 if(__initLightbox) 显式调用 → 初始化。
if (Array.isArray(window.__lightboxSelectors)) {
window.__initLightbox(window.__lightboxSelectors);
}