Merge branch 'refactor/lightbox-modernize'
Some checks failed
CI / check (push) Failing after 5m14s
CI / build (push) Has been skipped

灯箱 index.ts 现代化重构。先补 happy-dom 行为回归测试(11 个黑盒用例覆盖
循环闭包捕获、打开/关闭、图集循环边界、单张不切换),再做重构、用 23 个
测试全绿作证行为不变:
- var → const/let
- for+var+IIFE 包装 → for..of/forEach + const
- 字符串拼接 → 模板字面量
- 拆外层 IIFE(ES module 作用域已封装)
This commit is contained in:
xfy 2026-06-24 13:17:18 +08:00
commit eed0b24ec3
6 changed files with 863 additions and 490 deletions

View File

@ -8,6 +8,7 @@
"name": "@yggdrasil/lightbox", "name": "@yggdrasil/lightbox",
"version": "1.0.0", "version": "1.0.0",
"devDependencies": { "devDependencies": {
"happy-dom": "^20.10.6",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.1.0", "vite": "^8.1.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"
@ -388,6 +389,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "26.0.0",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-26.0.0.tgz",
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.9.tgz", "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.9.tgz",
@ -511,6 +539,19 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmmirror.com/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
@ -538,6 +579,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
@ -598,6 +652,25 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/happy-dom": {
"version": "20.10.6",
"resolved": "https://registry.npmmirror.com/happy-dom/-/happy-dom-20.10.6.tgz",
"integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
@ -1101,6 +1174,13 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmmirror.com/vite/-/vite-8.1.0.tgz", "resolved": "https://registry.npmmirror.com/vite/-/vite-8.1.0.tgz",
@ -1269,6 +1349,16 @@
} }
} }
}, },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@ -1285,6 +1375,28 @@
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
} }
} }
} }

View File

@ -11,6 +11,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"happy-dom": "^20.10.6",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.1.0", "vite": "^8.1.0",
"vitest": "^4.1.9" "vitest": "^4.1.9"

View File

@ -1,3 +1,4 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { fitCentered, transformFor, originalUrl, type Rect } from './geometry'; import { fitCentered, transformFor, originalUrl, type Rect } from './geometry';

View File

@ -33,9 +33,6 @@ declare global {
export {}; export {};
(function () {
"use strict";
// ============ 工具函数 ============ // ============ 工具函数 ============
function prefersReducedMotion(): boolean { function prefersReducedMotion(): boolean {
@ -50,7 +47,7 @@ export {};
// left/top/width/height而 fitCentered/transformFor 用 x/y/w/h // left/top/width/height而 fitCentered/transformFor 用 x/y/w/h
// 这里转成一致格式,避免 .w 读到 undefined。 // 这里转成一致格式,避免 .w 读到 undefined。
function rectOf(el: Element): Rect { function rectOf(el: Element): Rect {
var r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
return { return {
x: r.left, x: r.left,
y: r.top, y: r.top,
@ -59,27 +56,23 @@ export {};
}; };
} }
// 注意fitCentered / transformFor / originalUrl 已抽到 ./geometry.ts(见 Task 2 // 注意fitCentered / transformFor / originalUrl 已抽到 ./geometry.ts
// ============ 懒加载 ============ // ============ 懒加载 ============
// 为单个 .blur-img 容器初始化高清图懒加载。 // 为单个 .blur-img 容器初始化高清图懒加载。
// IO 进入视口后把 data-src 写入 src加载完成加 is-loaded 触发 CSS 淡入。 // IO 进入视口后把 data-src 写入 src加载完成加 is-loaded 触发 CSS 淡入。
function initLazyLoad(container: Element): void { function initLazyLoad(container: Element): void {
var raw = container.querySelector(".blur-img-full"); const raw = container.querySelector(".blur-img-full");
if (!(raw instanceof HTMLImageElement)) return; if (!(raw instanceof HTMLImageElement)) return;
// 用 const + 显式类型锁住窄化结果var 在闭包内会被放宽回 Element | null const fullImg: HTMLImageElement = raw;
// 导致 onFullLoaded/IntersectionObserver 回调里访问 .style/.src 报错。
var fullImg: HTMLImageElement = raw;
if (container.getAttribute("data-blur-init")) return; if (container.getAttribute("data-blur-init")) return;
container.setAttribute("data-blur-init", "true"); container.setAttribute("data-blur-init", "true");
var rawSrc = fullImg.getAttribute("data-src"); const fullSrc = fullImg.getAttribute("data-src");
if (!rawSrc) return; if (!fullSrc) return;
// 同上const 锁住 string 窄化,避免闭包内放宽回 string | null。
var fullSrc: string = rawSrc;
var onFullLoaded = function (): void { const onFullLoaded = (): void => {
// 给容器加 is-loadedCSS 据此显式隐藏 placeholder。 // 给容器加 is-loadedCSS 据此显式隐藏 placeholder。
// 直接把 full 层 opacity 设为 1清掉 transition不依赖 CSS 的 opacity // 直接把 full 层 opacity 设为 1清掉 transition不依赖 CSS 的 opacity
// 过渡:合成层重绘时机不稳定,可能导致 full 层卡在 opacity:0直到一次 // 过渡:合成层重绘时机不稳定,可能导致 full 层卡在 opacity:0直到一次
@ -96,14 +89,14 @@ export {};
} }
if ("IntersectionObserver" in window) { if ("IntersectionObserver" in window) {
var io = new IntersectionObserver( const io = new IntersectionObserver(
function (entries: IntersectionObserverEntry[]): void { (entries: IntersectionObserverEntry[]): void => {
entries.forEach(function (entry): void { for (const entry of entries) {
if (entry.isIntersecting) { if (entry.isIntersecting) {
fullImg.src = fullSrc; fullImg.src = fullSrc;
io.unobserve(container); io.unobserve(container);
} }
}); }
}, },
{ rootMargin: "200px" } { rootMargin: "200px" }
); );
@ -118,12 +111,11 @@ export {};
// 收集所有 selectors命中的 .blur-img 节点。 // 收集所有 selectors命中的 .blur-img 节点。
// gallery: 正文图组成图集singles: 带 lightbox-single class 的单张图(如封面)。 // gallery: 正文图组成图集singles: 带 lightbox-single class 的单张图(如封面)。
function collectImages(roots: Element[]): { gallery: HTMLElement[]; singles: HTMLElement[] } { function collectImages(roots: Element[]): { gallery: HTMLElement[]; singles: HTMLElement[] } {
var gallery: HTMLElement[] = []; const gallery: HTMLElement[] = [];
var singles: HTMLElement[] = []; const singles: HTMLElement[] = [];
for (var i = 0; i < roots.length; i++) { for (const root of roots) {
var nodes = roots[i].querySelectorAll(".blur-img"); const nodes = root.querySelectorAll(".blur-img");
for (var j = 0; j < nodes.length; j++) { for (const n of nodes) {
var n = nodes[j];
if (!(n instanceof HTMLElement)) continue; if (!(n instanceof HTMLElement)) continue;
if (n.classList.contains("lightbox-single")) { if (n.classList.contains("lightbox-single")) {
singles.push(n); singles.push(n);
@ -132,31 +124,31 @@ export {};
} }
} }
} }
return { gallery: gallery, singles: singles }; return { gallery, singles };
} }
// ============ 灯箱状态与开/关 ============ // ============ 灯箱状态与开/关 ============
// 当前灯箱状态(同时只允许一个灯箱)。 // 当前灯箱状态(同时只允许一个灯箱)。
var state: LightboxState | null = null; let state: LightboxState | null = null;
function openLightbox(originNode: HTMLElement, gallery: HTMLElement[], index: number | null): void { function openLightbox(originNode: HTMLElement, gallery: HTMLElement[], index: number | null): void {
if (state) closeLightbox(true); if (state) closeLightbox(true);
var fullImgEl = originNode.querySelector(".blur-img-full"); const fullImgEl = originNode.querySelector(".blur-img-full");
if (!(fullImgEl instanceof HTMLImageElement)) return; if (!(fullImgEl instanceof HTMLImageElement)) return;
var dataSrc = fullImgEl.getAttribute("data-src") || ""; const dataSrc = fullImgEl.getAttribute("data-src") || "";
var origSrc = originalUrl(dataSrc); const origSrc = originalUrl(dataSrc);
var altText = fullImgEl.getAttribute("alt") || ""; const altText = fullImgEl.getAttribute("alt") || "";
var isSingle = const isSingle =
originNode.classList.contains("lightbox-single") || originNode.classList.contains("lightbox-single") ||
gallery.length === 0; gallery.length === 0;
var vw = window.innerWidth; const vw = window.innerWidth;
var vh = window.innerHeight; const vh = window.innerHeight;
// 建 DOM // 建 DOM
var overlay = document.createElement("div"); const overlay = document.createElement("div");
overlay.className = "lightbox-overlay"; overlay.className = "lightbox-overlay";
overlay.setAttribute("role", "dialog"); overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true"); overlay.setAttribute("aria-modal", "true");
@ -166,7 +158,7 @@ export {};
// start() 执行前显示全黑背景造成闪烁。start() 的渐变会把 opacity 升到 1。 // start() 执行前显示全黑背景造成闪烁。start() 的渐变会把 opacity 升到 1。
overlay.style.opacity = "0"; overlay.style.opacity = "0";
var img = document.createElement("img"); const img = document.createElement("img");
img.className = "lightbox-img"; img.className = "lightbox-img";
img.setAttribute("alt", altText); img.setAttribute("alt", altText);
// 加载前先占 0 尺寸,避免原图(可能数千 px在加载期间撑大文档 // 加载前先占 0 尺寸,避免原图(可能数千 px在加载期间撑大文档
@ -174,22 +166,22 @@ export {};
img.style.width = "0px"; img.style.width = "0px";
img.style.height = "0px"; img.style.height = "0px";
var caption = document.createElement("figcaption"); const caption = document.createElement("figcaption");
caption.className = "lightbox-caption"; caption.className = "lightbox-caption";
caption.textContent = altText; caption.textContent = altText;
if (!altText) caption.style.display = "none"; if (!altText) caption.style.display = "none";
var counter = document.createElement("div"); const counter = document.createElement("div");
counter.className = "lightbox-counter"; counter.className = "lightbox-counter";
if (isSingle || gallery.length === 0) { if (isSingle || gallery.length === 0) {
counter.style.display = "none"; counter.style.display = "none";
} else { } else {
counter.textContent = (index ?? 0) + 1 + " / " + gallery.length; counter.textContent = `${(index ?? 0) + 1} / ${gallery.length}`;
} }
// 图集模式(>1 张)才加左右导航箭头;单张不显示。 // 图集模式(>1 张)才加左右导航箭头;单张不显示。
var prevBtn: HTMLButtonElement | null = null; let prevBtn: HTMLButtonElement | null = null;
var nextBtn: HTMLButtonElement | null = null; let nextBtn: HTMLButtonElement | null = null;
if (!isSingle && gallery.length > 1) { if (!isSingle && gallery.length > 1) {
prevBtn = document.createElement("button"); prevBtn = document.createElement("button");
prevBtn.className = "lightbox-nav lightbox-prev"; prevBtn.className = "lightbox-nav lightbox-prev";
@ -212,19 +204,19 @@ export {};
document.body.appendChild(overlay); document.body.appendChild(overlay);
state = { state = {
overlay: overlay, overlay,
img: img, img,
caption: caption, caption,
counter: counter, counter,
prevBtn: prevBtn, prevBtn,
nextBtn: nextBtn, nextBtn,
originNode: originNode, originNode,
gallery: gallery, gallery,
index: index, index,
isSingle: isSingle, isSingle,
openScrollY: window.scrollY, openScrollY: window.scrollY,
origSrc: origSrc, origSrc,
altText: altText, altText,
closing: false, closing: false,
reduced: prefersReducedMotion(), reduced: prefersReducedMotion(),
scrollHandler: null, scrollHandler: null,
@ -237,26 +229,26 @@ export {};
bindInteractions(); bindInteractions();
// 图片加载后再做动画naturalW/H 要等加载) // 图片加载后再做动画naturalW/H 要等加载)
var start = function (): void { const start = (): void => {
if (!state) return; // 加载前可能已被关闭 if (!state) return; // 加载前可能已被关闭
var naturalW = img.naturalWidth || img.clientWidth || 1; const naturalW = img.naturalWidth || img.clientWidth || 1;
var naturalH = img.naturalHeight || img.clientHeight || 1; const naturalH = img.naturalHeight || img.clientHeight || 1;
var originRect = rectOf(originNode); const originRect = rectOf(originNode);
// 基准 = originRect文章里图片的实际尺寸 // 基准 = originRect文章里图片的实际尺寸
// img 的布局尺寸固定为 originRecttransform 的 scale 相对它缩放: // img 的布局尺寸固定为 originRecttransform 的 scale 相对它缩放:
// 首帧文章位置scale=1居中态 scale=target.w/originRect.w。 // 首帧文章位置scale=1居中态 scale=target.w/originRect.w。
// 这样无论 target 比 originRect 大或小,动画都是「从文章图原样连续缩放」, // 这样无论 target 比 originRect 大或小,动画都是「从文章图原样连续缩放」,
// 视觉上是原地展开不会像「从外面飞来」。灯箱图尺寸恒为视口最大fitCentered // 视觉上是原地展开不会像「从外面飞来」。灯箱图尺寸恒为视口最大fitCentered
var target = fitCentered(naturalW, naturalH, vw, vh); const target = fitCentered(naturalW, naturalH, vw, vh);
var baseW = originRect.w; const baseW = originRect.w;
var baseH = originRect.h; const baseH = originRect.h;
// 存基准与目标,供关闭/滚动关闭复用同一对(保证 scale 连续)。 // 存基准与目标,供关闭/滚动关闭复用同一对(保证 scale 连续)。
state.target = target; state.target = target;
state.baseW = baseW; state.baseW = baseW;
state.baseH = baseH; state.baseH = baseH;
img.style.width = baseW + "px"; img.style.width = `${baseW}px`;
img.style.height = baseH + "px"; img.style.height = `${baseH}px`;
// reduced-motion直接淡入居中 // reduced-motion直接淡入居中
if (state.reduced) { if (state.reduced) {
@ -266,7 +258,7 @@ export {};
img.style.top = "0"; img.style.top = "0";
overlay.style.opacity = "0"; overlay.style.opacity = "0";
// 下一帧淡入 // 下一帧淡入
requestAnimationFrame(function (): void { requestAnimationFrame((): void => {
if (!state) return; if (!state) return;
overlay.style.transition = "opacity 200ms ease-out"; overlay.style.transition = "opacity 200ms ease-out";
img.style.transition = "opacity 200ms ease-out"; img.style.transition = "opacity 200ms ease-out";
@ -288,9 +280,9 @@ export {};
void img.offsetHeight; void img.offsetHeight;
// double-rAF第一帧绘制首帧无动画第二帧才启动 transition 到居中。 // double-rAF第一帧绘制首帧无动画第二帧才启动 transition 到居中。
requestAnimationFrame(function (): void { requestAnimationFrame((): void => {
if (!state) return; if (!state) return;
requestAnimationFrame(function (): void { requestAnimationFrame((): void => {
if (!state) return; if (!state) return;
img.style.transition = img.style.transition =
"transform 250ms ease-out, opacity 250ms ease-out"; "transform 250ms ease-out, opacity 250ms ease-out";
@ -315,12 +307,12 @@ export {};
state.closing = true; state.closing = true;
cleanupInteractions(); cleanupInteractions();
var s = state; const s = state;
// 基准 = originRect 尺寸与打开时一致scale 相对它缩放。 // 基准 = originRect 尺寸与打开时一致scale 相对它缩放。
var baseW = s.baseW || (s.target ? s.target.w : 1); const baseW = s.baseW || (s.target ? s.target.w : 1);
var baseH = s.baseH || (s.target ? s.target.h : 1); const baseH = s.baseH || (s.target ? s.target.h : 1);
var originRect = rectOf(s.originNode); // 实时读,处理期间滚动过的情况 const originRect = rectOf(s.originNode); // 实时读,处理期间滚动过的情况
if (s.reduced || immediate) { if (s.reduced || immediate) {
removeOverlay(); removeOverlay();
@ -335,17 +327,24 @@ export {};
s.img.style.opacity = "0"; s.img.style.opacity = "0";
s.overlay.style.opacity = "0"; s.overlay.style.opacity = "0";
var done = function (): void { const done = (): void => {
removeOverlay(); removeOverlay();
}; };
// 250ms 兜底,避免 transitionend 不触发 // 250ms 兜底,避免 transitionend 不触发
var timer = setTimeout(done, 280); const timer = setTimeout(done, 280);
s.img.addEventListener("transitionend", function (): void { clearTimeout(timer); done(); }, { once: true }); s.img.addEventListener(
"transitionend",
(): void => {
clearTimeout(timer);
done();
},
{ once: true }
);
} }
function removeOverlay(): void { function removeOverlay(): void {
if (!state) return; if (!state) return;
var prev = state.originNode; const prev = state.originNode;
if (state.overlay && state.overlay.parentNode) { if (state.overlay && state.overlay.parentNode) {
state.overlay.parentNode.removeChild(state.overlay); state.overlay.parentNode.removeChild(state.overlay);
} }
@ -354,7 +353,7 @@ export {};
// 用 preventScroll 抑制 focus() 默认的 scrollIntoView 行为——否则关闭灯箱后 // 用 preventScroll 抑制 focus() 默认的 scrollIntoView 行为——否则关闭灯箱后
// 页面会自动滚动把原图完整纳入视口(用户只点了一半露出的图时尤其明显)。 // 页面会自动滚动把原图完整纳入视口(用户只点了一半露出的图时尤其明显)。
if (prev) { if (prev) {
var f = prev.querySelector(".blur-img-full"); const f = prev.querySelector(".blur-img-full");
if (f instanceof HTMLImageElement) { if (f instanceof HTMLImageElement) {
f.setAttribute("tabindex", "-1"); f.setAttribute("tabindex", "-1");
f.focus({ preventScroll: true }); f.focus({ preventScroll: true });
@ -365,28 +364,29 @@ export {};
// ============ 图集切换 ============ // ============ 图集切换 ============
// 图集切换淡入淡出不飞行。newIndex 循环(首尾衔接)。 // 图集切换淡入淡出不飞行。newIndex 循环(首尾衔接)。
function gotoIndex(newIndex: number): void { function gotoIndex(rawIndex: number): void {
if (!state || state.isSingle) return; if (!state || state.isSingle) return;
var s = state; const s = state;
if (!s.gallery || s.gallery.length === 0) return; if (!s.gallery || s.gallery.length === 0) return;
let newIndex = rawIndex;
if (newIndex < 0) newIndex = s.gallery.length - 1; if (newIndex < 0) newIndex = s.gallery.length - 1;
if (newIndex >= s.gallery.length) newIndex = 0; if (newIndex >= s.gallery.length) newIndex = 0;
if (newIndex === s.index) return; if (newIndex === s.index) return;
var newNode = s.gallery[newIndex]; const newNode = s.gallery[newIndex];
var fullImgEl = newNode.querySelector(".blur-img-full"); const fullImgEl = newNode.querySelector(".blur-img-full");
if (!(fullImgEl instanceof HTMLImageElement)) return; if (!(fullImgEl instanceof HTMLImageElement)) return;
var origSrc = originalUrl(fullImgEl.getAttribute("data-src") || ""); const origSrc = originalUrl(fullImgEl.getAttribute("data-src") || "");
var altText = fullImgEl.getAttribute("alt") || ""; const altText = fullImgEl.getAttribute("alt") || "";
// 淡出当前图 // 淡出当前图
s.img.style.transition = "opacity 150ms ease-out"; s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "0"; s.img.style.opacity = "0";
// 150ms 后换图淡入 // 150ms 后换图淡入
var swap = function (): void { const swap = (): void => {
if (!state) return; // 切换中可能已关闭 if (!state) return; // 切换中可能已关闭
var fade = function (): void { const fade = (): void => {
if (!state) return; if (!state) return;
s.img.style.transition = "opacity 150ms ease-out"; s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "1"; s.img.style.opacity = "1";
@ -401,7 +401,7 @@ export {};
} }
s.caption.textContent = altText; s.caption.textContent = altText;
s.caption.style.display = altText ? "" : "none"; s.caption.style.display = altText ? "" : "none";
s.counter.textContent = newIndex + 1 + " / " + s.gallery.length; s.counter.textContent = `${newIndex + 1} / ${s.gallery.length}`;
// 更新 originNode 为新图,使后续关闭/滚动关闭飞回新图位置 // 更新 originNode 为新图,使后续关闭/滚动关闭飞回新图位置
s.originNode = newNode; s.originNode = newNode;
s.index = newIndex; s.index = newIndex;
@ -413,34 +413,34 @@ export {};
// ============ 交互绑定 ============ // ============ 交互绑定 ============
function bindInteractions(): void { function bindInteractions(): void {
var s = state; const s = state;
if (!s) return; if (!s) return;
// 点背景关闭(点图片本身不关,因箭头在图上、避免误关) // 点背景关闭(点图片本身不关,因箭头在图上、避免误关)
s.overlay.addEventListener("click", function (ev: MouseEvent): void { s.overlay.addEventListener("click", (ev: MouseEvent): void => {
if (state && ev.target === state.overlay) closeLightbox(false); if (state && ev.target === state.overlay) closeLightbox(false);
}); });
// 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。 // 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。
// 关键:逐帧读 originNode 实时 rect文章滚多少图就回多少。 // 关键:逐帧读 originNode 实时 rect文章滚多少图就回多少。
s.scrollHandler = function (): void { s.scrollHandler = (): void => {
if (!state) return; if (!state) return;
var st = state; const st = state;
if (st.closing) return; if (st.closing) return;
var dy = Math.abs(window.scrollY - st.openScrollY); const dy = Math.abs(window.scrollY - st.openScrollY);
if (st.reduced) { if (st.reduced) {
// reduced-motion立即关 // reduced-motion立即关
closeLightbox(true); closeLightbox(true);
return; return;
} }
var target = st.target; const target = st.target;
var baseW = st.baseW || (target ? target.w : 1); const baseW = st.baseW || (target ? target.w : 1);
var baseH = st.baseH || (target ? target.h : 1); const baseH = st.baseH || (target ? target.h : 1);
var originRect = rectOf(st.originNode); const originRect = rectOf(st.originNode);
// 在 originRect 与居中 target 之间按 progress 线性插值
var progress = Math.min(dy / 120, 1);
if (!target) return; // 无 target 时不插值(忠实原 JS 的兜底语义) if (!target) return; // 无 target 时不插值(忠实原 JS 的兜底语义)
var cur: Rect = { // 在 originRect 与居中 target 之间按 progress 线性插值
const progress = Math.min(dy / 120, 1);
const cur: Rect = {
x: target.x + (originRect.x - target.x) * progress, x: target.x + (originRect.x - target.x) * progress,
y: target.y + (originRect.y - target.y) * progress, y: target.y + (originRect.y - target.y) * progress,
w: target.w + (originRect.w - target.w) * progress, w: target.w + (originRect.w - target.w) * progress,
@ -460,7 +460,7 @@ export {};
window.addEventListener("scroll", s.scrollHandler, { passive: true }); window.addEventListener("scroll", s.scrollHandler, { passive: true });
// 键盘Esc 关;图集模式 ←→ 切换 // 键盘Esc 关;图集模式 ←→ 切换
s.keyHandler = function (ev: KeyboardEvent): void { s.keyHandler = (ev: KeyboardEvent): void => {
if (!state) return; if (!state) return;
if (ev.key === "Escape") { if (ev.key === "Escape") {
closeLightbox(false); closeLightbox(false);
@ -477,19 +477,15 @@ export {};
document.addEventListener("keydown", s.keyHandler); document.addEventListener("keydown", s.keyHandler);
// 图集导航箭头点击stopPropagation 防止冒泡到 overlay 触发关闭) // 图集导航箭头点击stopPropagation 防止冒泡到 overlay 触发关闭)
if (s.prevBtn) { s.prevBtn?.addEventListener("click", (ev: MouseEvent): void => {
s.prevBtn.addEventListener("click", function (ev: MouseEvent): void {
ev.stopPropagation(); ev.stopPropagation();
if (state) gotoIndex((state.index ?? 0) - 1); if (state) gotoIndex((state.index ?? 0) - 1);
}); });
} s.nextBtn?.addEventListener("click", (ev: MouseEvent): void => {
if (s.nextBtn) {
s.nextBtn.addEventListener("click", function (ev: MouseEvent): void {
ev.stopPropagation(); ev.stopPropagation();
if (state) gotoIndex((state.index ?? 0) + 1); if (state) gotoIndex((state.index ?? 0) + 1);
}); });
} }
}
function cleanupInteractions(): void { function cleanupInteractions(): void {
if (!state) return; if (!state) return;
@ -505,40 +501,36 @@ export {};
// ============ 初始化入口 ============ // ============ 初始化入口 ============
window.__initLightbox = function (selectors: string | string[]): void { window.__initLightbox = (selectors: string | string[]): void => {
// selectors 可以是字符串、字符串数组 // selectors 可以是字符串、字符串数组
var sels = Array.isArray(selectors) ? selectors : [selectors]; const sels = Array.isArray(selectors) ? selectors : [selectors];
var roots: Element[] = []; const roots: Element[] = [];
for (var i = 0; i < sels.length; i++) { for (const sel of sels) {
var found = document.querySelectorAll(sels[i]); const found = document.querySelectorAll(sel);
for (var j = 0; j < found.length; j++) roots.push(found[j]); for (const el of found) roots.push(el);
} }
// 先对所有图片做懒加载(图集与单张都做) // 先对所有图片做懒加载(图集与单张都做)
var collected = collectImages(roots); const collected = collectImages(roots);
var everyone = collected.gallery.concat(collected.singles); for (const node of collected.gallery.concat(collected.singles)) {
for (var k = 0; k < everyone.length; k++) { initLazyLoad(node);
initLazyLoad(everyone[k]);
} }
// 正文图:带 index // 正文图:带 index。for..of + const 天然捕获每次迭代的 idx
var gallery = collected.gallery; // 无需旧 IIFE 包装(旧 var 循环闭包必须立即执行函数固定变量)。
for (var g = 0; g < gallery.length; g++) { const gallery = collected.gallery;
(function (node: HTMLElement, idx: number): void { gallery.forEach((node, idx) => {
node.addEventListener("click", function (e: MouseEvent): void { node.addEventListener("click", (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
openLightbox(node, gallery, idx); openLightbox(node, gallery, idx);
}); });
})(gallery[g], g); });
}
// 单张图封面index = nullgallery 传空数组表示单张 // 单张图封面index = nullgallery 传空数组表示单张
for (var si = 0; si < collected.singles.length; si++) { for (const node of collected.singles) {
(function (node: HTMLElement): void { node.addEventListener("click", (e: MouseEvent) => {
node.addEventListener("click", function (e: MouseEvent): void {
e.preventDefault(); e.preventDefault();
openLightbox(node, [], null); openLightbox(node, [], null);
}); });
})(collected.singles[si]);
} }
}; };
@ -551,4 +543,3 @@ export {};
if (Array.isArray(window.__lightboxSelectors)) { if (Array.isArray(window.__lightboxSelectors)) {
window.__initLightbox(window.__lightboxSelectors); window.__initLightbox(window.__lightboxSelectors);
} }
})();

View File

@ -0,0 +1,266 @@
/**
* Lightbox happy-dom DOM
*
* window.__initLightbox .blur-img DOM
* // overlay /counter originNode
*
* idx gotoIndex
* varconst IIFEforfor..of线
*
* img naturalWidth + load happy-dom
* geometry.test.ts
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import './index';
// ============ 测试夹具 ============
/**
* .blur-img
* full img data-src + alt
*/
function makeGalleryImage(dataSrc: string, alt: string): HTMLElement {
const container = document.createElement('div');
container.className = 'blur-img';
container.innerHTML = `
<img class="blur-img-placeholder" src="${dataSrc}?w=20" alt="${alt}">
<img class="blur-img-full" data-src="${dataSrc}" alt="${alt}">
`;
return container;
}
/** 构造单张图(封面,带 lightbox-single。 */
function makeSingleImage(dataSrc: string, alt: string): HTMLElement {
const container = makeGalleryImage(dataSrc, alt);
container.classList.add('lightbox-single');
return container;
}
/**
* root document.body
* root 便
*/
function mountRoot(images: HTMLElement[]): HTMLElement {
const root = document.createElement('div');
root.className = 'post-content';
for (const img of images) root.appendChild(img);
document.body.appendChild(root);
return root;
}
/** 取当前 overlay灯箱打开时存在。 */
function getOverlay(): HTMLElement | null {
return document.querySelector('.lightbox-overlay');
}
/** 取灯箱图(.lightbox-img。 */
function getLightboxImg(): HTMLImageElement | null {
return document.querySelector('.lightbox-img');
}
/** 取计数器。 */
function getCounter(): HTMLElement | null {
return document.querySelector('.lightbox-counter');
}
/** 模拟元素 click真实事件派发触发 addEventListener('click'))。 */
function clickEl(el: Element): void {
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
}
/** 模拟 keydown。 */
function pressKey(key: string): void {
document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
// ============ 测试 ============
describe('lightbox 黑盒行为', () => {
beforeEach(() => {
// 每个测试干净的 DOM + matchMediaprefersReducedMotion 读它)
document.body.innerHTML = '';
// happy-dom 的 matchMedia 返回值默认 matches=falsereduced-motion 关闭,
// 这样打开走 double-rAF 动画路径(更接近真实)。但我们用 fake timers 跳过动画。
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
describe('循环闭包捕获 idxgallery 绑定)', () => {
it('点击第 1/2/3 张图counter 分别显示 1/3、2/3、3/3', () => {
const imgs = [
makeGalleryImage('/a.webp', '图A'),
makeGalleryImage('/b.webp', '图B'),
makeGalleryImage('/c.webp', '图C'),
];
mountRoot(imgs);
window.__initLightbox('.post-content');
// 逐张点击,验证捕获的 idx 正确(这是 var g + IIFE 的核心风险点)
clickEl(imgs[0]);
expect(getCounter()?.textContent).toBe('1 / 3');
pressKey('Escape'); // 关闭
vi.advanceTimersByTime(300); // 等 close 的 280ms 兜底
clickEl(imgs[1]);
expect(getCounter()?.textContent).toBe('2 / 3');
pressKey('Escape');
vi.advanceTimersByTime(300);
clickEl(imgs[2]);
expect(getCounter()?.textContent).toBe('3 / 3');
});
it('点图片时 preventDefault阻止默认导航/拖拽)', () => {
const img = makeGalleryImage('/a.webp', '图A');
mountRoot([img]);
window.__initLightbox('.post-content');
const ev = new MouseEvent('click', { bubbles: true, cancelable: true });
img.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
});
});
describe('点击打开灯箱overlay 创建 + originNode 捕获)', () => {
it('点击 gallery 图后出现 overlayimg src 为原图(去 query', () => {
const img = makeGalleryImage('/uploads/x.webp?w=800', '描述');
mountRoot([img]);
window.__initLightbox('.post-content');
expect(getOverlay()).toBeNull(); // 打开前无 overlay
clickEl(img);
expect(getOverlay()).not.toBeNull(); // 打开后有 overlay
// origSrc = data-src 去 query
expect(getLightboxImg()?.getAttribute('src')).toBe('/uploads/x.webp');
});
it('caption 显示 alt 文本', () => {
const img = makeGalleryImage('/a.webp', '我的描述文字');
mountRoot([img]);
window.__initLightbox('.post-content');
clickEl(img);
const caption = document.querySelector('.lightbox-caption');
expect(caption?.textContent).toBe('我的描述文字');
});
it('单张图lightbox-single打开时 counter 隐藏', () => {
const img = makeSingleImage('/cover.webp', '封面');
mountRoot([img]);
window.__initLightbox('.post-content');
clickEl(img);
expect(getCounter()?.style.display).toBe('none');
});
});
describe('关闭灯箱', () => {
it('Esc 关闭后 overlay 从 DOM 移除', () => {
const img = makeGalleryImage('/a.webp', 'A');
mountRoot([img]);
window.__initLightbox('.post-content');
clickEl(img);
expect(getOverlay()).not.toBeNull();
pressKey('Escape');
// closeLightbox 走 transitionendhappy-dom 不触发)+ 280ms 兜底
vi.advanceTimersByTime(300);
expect(getOverlay()).toBeNull();
});
it('点 overlay 背景关闭(点图片本身不关)', () => {
const img = makeGalleryImage('/a.webp', 'A');
mountRoot([img]);
window.__initLightbox('.post-content');
clickEl(img);
const overlay = getOverlay()!;
const lbImg = getLightboxImg()!;
// 点图片本身 → 不关闭(箭头在图上,避免误关)
clickEl(lbImg);
expect(getOverlay()).not.toBeNull();
// 点背景 → 关闭
clickEl(overlay);
vi.advanceTimersByTime(300);
expect(getOverlay()).toBeNull();
});
});
describe('图集 gotoIndex 循环边界', () => {
it('第 1 张按 ← 跳到最后一张(循环)', () => {
const imgs = [
makeGalleryImage('/a.webp', 'A'),
makeGalleryImage('/b.webp', 'B'),
];
mountRoot(imgs);
window.__initLightbox('.post-content');
clickEl(imgs[0]);
expect(getCounter()?.textContent).toBe('1 / 2');
pressKey('ArrowLeft'); // 从第 1 张往左 → 循环到最后
vi.advanceTimersByTime(200); // gotoIndex 的 150ms 淡出 + 淡入
expect(getCounter()?.textContent).toBe('2 / 2');
});
it('最后一张按 → 跳到第 1 张(循环)', () => {
const imgs = [
makeGalleryImage('/a.webp', 'A'),
makeGalleryImage('/b.webp', 'B'),
];
mountRoot(imgs);
window.__initLightbox('.post-content');
clickEl(imgs[1]); // 最后一张
expect(getCounter()?.textContent).toBe('2 / 2');
pressKey('ArrowRight'); // 循环到第 1 张
vi.advanceTimersByTime(200);
expect(getCounter()?.textContent).toBe('1 / 2');
});
it('切换后 originNode 更新:关闭后焦点归还到新图', () => {
const imgs = [
makeGalleryImage('/a.webp', 'A'),
makeGalleryImage('/b.webp', 'B'),
];
mountRoot(imgs);
window.__initLightbox('.post-content');
clickEl(imgs[0]);
pressKey('ArrowRight'); // 切到 imgs[1]
vi.advanceTimersByTime(200);
// originNode 应已更新为 imgs[1],关闭后焦点归还给 imgs[1] 的 full img
pressKey('Escape');
vi.advanceTimersByTime(300);
const fullB = imgs[1].querySelector('.blur-img-full');
expect(document.activeElement).toBe(fullB);
});
});
describe('单张图不参与图集切换', () => {
it('单张图打开后按 ←/→ 不切换(无 counter、无箭头', () => {
const img = makeSingleImage('/cover.webp', '封面');
mountRoot([img]);
window.__initLightbox('.post-content');
clickEl(img);
// 单张模式无导航箭头
expect(document.querySelector('.lightbox-prev')).toBeNull();
expect(document.querySelector('.lightbox-next')).toBeNull();
// 按 ← 不报错也不改变状态gotoIndex 早返)
pressKey('ArrowLeft');
vi.advanceTimersByTime(200);
expect(getOverlay()).not.toBeNull(); // 仍打开
});
});
});

View File

@ -2,7 +2,9 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
environment: 'node', // 默认 happy-domindex.ts 的行为测试需要真实 DOM点击/keydown/scroll/IO
// geometry.test.ts 是纯函数,用文件内 // @vitest-environment node 指令保持 node 环境。
environment: 'happy-dom',
include: ['src/**/*.test.ts'], include: ['src/**/*.test.ts'],
}, },
}); });