diff --git a/libs/lightbox/package-lock.json b/libs/lightbox/package-lock.json
index a4532c9..f740a54 100644
--- a/libs/lightbox/package-lock.json
+++ b/libs/lightbox/package-lock.json
@@ -8,6 +8,7 @@
"name": "@yggdrasil/lightbox",
"version": "1.0.0",
"devDependencies": {
+ "happy-dom": "^20.10.6",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vitest": "^4.1.9"
@@ -388,6 +389,33 @@
"dev": true,
"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": {
"version": "4.1.9",
"resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-4.1.9.tgz",
@@ -511,6 +539,19 @@
"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": {
"version": "6.2.2",
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
@@ -538,6 +579,19 @@
"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": {
"version": "2.1.0",
"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_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": {
"version": "1.32.0",
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1101,6 +1174,13 @@
"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": {
"version": "8.1.0",
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -1285,6 +1375,28 @@
"engines": {
"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
+ }
+ }
}
}
}
diff --git a/libs/lightbox/package.json b/libs/lightbox/package.json
index b9cae50..b4808bf 100644
--- a/libs/lightbox/package.json
+++ b/libs/lightbox/package.json
@@ -11,6 +11,7 @@
"test:watch": "vitest"
},
"devDependencies": {
+ "happy-dom": "^20.10.6",
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vitest": "^4.1.9"
diff --git a/libs/lightbox/src/geometry.test.ts b/libs/lightbox/src/geometry.test.ts
index 82e19d2..dbf4975 100644
--- a/libs/lightbox/src/geometry.test.ts
+++ b/libs/lightbox/src/geometry.test.ts
@@ -1,3 +1,4 @@
+// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { fitCentered, transformFor, originalUrl, type Rect } from './geometry';
diff --git a/libs/lightbox/src/index.ts b/libs/lightbox/src/index.ts
index 4a87bb7..009df89 100644
--- a/libs/lightbox/src/index.ts
+++ b/libs/lightbox/src/index.ts
@@ -33,522 +33,513 @@ declare global {
export {};
-(function () {
- "use strict";
+// ============ 工具函数 ============
- // ============ 工具函数 ============
+function prefersReducedMotion(): boolean {
+ return (
+ !!window.matchMedia &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
- 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-loaded,CSS 据此显式隐藏 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();
}
- // 读取元素当前在视口里的 rect(用于飞行起点/终点)。
- // 统一映射成 {x,y,w,h}:getBoundingClientRect 返回的 DOMRect 用
- // left/top/width/height,而 fitCentered/transformFor 用 x/y/w/h,
- // 这里转成一致格式,避免 .w 读到 undefined。
- function rectOf(el: Element): Rect {
- var r = el.getBoundingClientRect();
- return {
- x: r.left,
- y: r.top,
- w: r.width,
- h: r.height,
- };
- }
-
- // 注意:fitCentered / transformFor / originalUrl 已抽到 ./geometry.ts(见 Task 2)。
-
- // ============ 懒加载 ============
-
- // 为单个 .blur-img 容器初始化高清图懒加载。
- // IO 进入视口后把 data-src 写入 src,加载完成加 is-loaded 触发 CSS 淡入。
- function initLazyLoad(container: Element): void {
- var raw = container.querySelector(".blur-img-full");
- if (!(raw instanceof HTMLImageElement)) return;
- // 用 const + 显式类型锁住窄化结果:var 在闭包内会被放宽回 Element | null,
- // 导致 onFullLoaded/IntersectionObserver 回调里访问 .style/.src 报错。
- var fullImg: HTMLImageElement = raw;
- if (container.getAttribute("data-blur-init")) return;
- container.setAttribute("data-blur-init", "true");
-
- var rawSrc = fullImg.getAttribute("data-src");
- if (!rawSrc) return;
- // 同上:const 锁住 string 窄化,避免闭包内放宽回 string | null。
- var fullSrc: string = rawSrc;
-
- var onFullLoaded = function (): void {
- // 给容器加 is-loaded,CSS 据此显式隐藏 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: IntersectionObserverEntry[]): void {
- entries.forEach(function (entry): void {
- 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[] } {
- var gallery: HTMLElement[] = [];
- var singles: HTMLElement[] = [];
- 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 instanceof HTMLElement)) continue;
- if (n.classList.contains("lightbox-single")) {
- singles.push(n);
- } else {
- gallery.push(n);
+ 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: gallery, singles: singles };
+ }
+ 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 0:append 到 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";
- // 当前灯箱状态(同时只允许一个灯箱)。
- var state: LightboxState | null = null;
+ nextBtn = document.createElement("button");
+ nextBtn.className = "lightbox-nav lightbox-next";
+ nextBtn.setAttribute("type", "button");
+ nextBtn.setAttribute("aria-label", "下一张");
+ nextBtn.textContent = "\u203a";
+ }
- function openLightbox(originNode: HTMLElement, gallery: HTMLElement[], index: number | null): void {
- if (state) closeLightbox(true);
+ overlay.appendChild(img);
+ overlay.appendChild(caption);
+ overlay.appendChild(counter);
+ if (prevBtn) overlay.appendChild(prevBtn);
+ if (nextBtn) overlay.appendChild(nextBtn);
+ document.body.appendChild(overlay);
- var fullImgEl = originNode.querySelector(".blur-img-full");
- if (!(fullImgEl instanceof HTMLImageElement)) return;
- var dataSrc = fullImgEl.getAttribute("data-src") || "";
- var origSrc = originalUrl(dataSrc);
- var altText = fullImgEl.getAttribute("alt") || "";
- var isSingle =
- originNode.classList.contains("lightbox-single") ||
- gallery.length === 0;
+ state = {
+ overlay,
+ img,
+ caption,
+ counter,
+ prevBtn,
+ nextBtn,
+ originNode,
+ gallery,
+ index,
+ isSingle,
+ openScrollY: window.scrollY,
+ origSrc,
+ altText,
+ closing: false,
+ reduced: prefersReducedMotion(),
+ scrollHandler: null,
+ keyHandler: null,
+ };
- var vw = window.innerWidth;
- var vh = window.innerHeight;
+ // 焦点移入灯箱
+ overlay.focus();
+ // 立即绑定交互(不等图片加载):加载期间 Esc/滚动/点背景也须可关闭。
+ bindInteractions();
- // 建 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 0:append 到 DOM 时是透明的,避免在图片加载期间
- // (start() 执行前)显示全黑背景造成闪烁。start() 的渐变会把 opacity 升到 1。
- overlay.style.opacity = "0";
+ // 图片加载后再做动画(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);
- 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";
+ // 基准 = originRect(文章里图片的实际尺寸)。
+ // img 的布局尺寸固定为 originRect,transform 的 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`;
- 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 ?? 0) + 1 + " / " + gallery.length;
- }
-
- // 图集模式(>1 张)才加左右导航箭头;单张不显示。
- var prevBtn: HTMLButtonElement | null = null;
- var 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: 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 (): void {
- if (!state) return; // 加载前可能已被关闭
- var naturalW = img.naturalWidth || img.clientWidth || 1;
- var naturalH = img.naturalHeight || img.clientHeight || 1;
- var originRect = rectOf(originNode);
-
- // 基准 = originRect(文章里图片的实际尺寸)。
- // img 的布局尺寸固定为 originRect,transform 的 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 (): 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";
+ // reduced-motion:直接淡入居中
+ if (state.reduced) {
+ img.style.opacity = "0";
+ img.style.transform = transformFor(target, baseW, baseH);
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 (): void {
+ // 下一帧淡入
+ requestAnimationFrame((): void => {
if (!state) return;
- requestAnimationFrame(function (): 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";
- });
+ overlay.style.transition = "opacity 200ms ease-out";
+ img.style.transition = "opacity 200ms ease-out";
+ overlay.style.opacity = "1";
+ img.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();
-
- 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;
}
- // 飞回 originRect:scale 从 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";
+ // 首帧:文章位置 + 原尺寸(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;
- var done = function (): void {
- removeOverlay();
- };
- // 250ms 兜底,避免 transitionend 不触发
- var timer = setTimeout(done, 280);
- s.img.addEventListener("transitionend", function (): void { clearTimeout(timer); done(); }, { once: true });
- }
-
- function removeOverlay(): void {
- 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 获得焦点。
- // 用 preventScroll 抑制 focus() 默认的 scrollIntoView 行为——否则关闭灯箱后
- // 页面会自动滚动把原图完整纳入视口(用户只点了一半露出的图时尤其明显)。
- if (prev) {
- var f = prev.querySelector(".blur-img-full");
- if (f instanceof HTMLImageElement) {
- f.setAttribute("tabindex", "-1");
- f.focus({ preventScroll: true });
- }
- }
- }
-
- // ============ 图集切换 ============
-
- // 图集切换:淡入淡出,不飞行。newIndex 循环(首尾衔接)。
- function gotoIndex(newIndex: number): void {
- 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 fullImgEl = newNode.querySelector(".blur-img-full");
- if (!(fullImgEl instanceof HTMLImageElement)) return;
- var origSrc = originalUrl(fullImgEl.getAttribute("data-src") || "");
- var altText = fullImgEl.getAttribute("alt") || "";
-
- // 淡出当前图
- s.img.style.transition = "opacity 150ms ease-out";
- s.img.style.opacity = "0";
-
- // 150ms 后换图淡入
- var swap = function (): void {
- if (!state) return; // 切换中可能已关闭
- var fade = function (): void {
+ // double-rAF:第一帧绘制首帧(无动画),第二帧才启动 transition 到居中。
+ requestAnimationFrame((): void => {
+ if (!state) return;
+ requestAnimationFrame((): 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 {
- var s = state;
- if (!s) return;
-
- // 点背景关闭(点图片本身不关,因箭头在图上、避免误关)
- s.overlay.addEventListener("click", function (ev: MouseEvent): void {
- if (state && ev.target === state.overlay) closeLightbox(false);
+ 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";
+ });
});
-
- // 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。
- // 关键:逐帧读 originNode 实时 rect,文章滚多少图就回多少。
- s.scrollHandler = function (): void {
- 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 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 progress = Math.min(dy / 120, 1);
- if (!target) return; // 无 target 时不插值(忠实原 JS 的兜底语义)
- var 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 = function (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 触发关闭)
- if (s.prevBtn) {
- s.prevBtn.addEventListener("click", function (ev: MouseEvent): void {
- ev.stopPropagation();
- if (state) gotoIndex((state.index ?? 0) - 1);
- });
- }
- if (s.nextBtn) {
- s.nextBtn.addEventListener("click", function (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 = function (selectors: string | string[]): void {
- // selectors 可以是字符串、字符串数组
- var sels = Array.isArray(selectors) ? selectors : [selectors];
- var roots: Element[] = [];
- 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: HTMLElement, idx: number): void {
- node.addEventListener("click", function (e: MouseEvent): void {
- e.preventDefault();
- openLightbox(node, gallery, idx);
- });
- })(gallery[g], g);
- }
- // 单张图(封面):index = null,gallery 传空数组表示单张
- for (var si = 0; si < collected.singles.length; si++) {
- (function (node: HTMLElement): void {
- node.addEventListener("click", function (e: MouseEvent): void {
- e.preventDefault();
- openLightbox(node, [], null);
- });
- })(collected.singles[si]);
- }
};
- // ============ 自启动 ============
- // 方案 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);
+ 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;
+ }
+
+ // 飞回 originRect:scale 从 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 = null,gallery 传空数组表示单张
+ 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);
+}
diff --git a/libs/lightbox/src/lightbox.test.ts b/libs/lightbox/src/lightbox.test.ts
new file mode 100644
index 0000000..22ffc88
--- /dev/null
+++ b/libs/lightbox/src/lightbox.test.ts
@@ -0,0 +1,266 @@
+/**
+ * Lightbox 行为回归测试(happy-dom 真实 DOM,黑盒驱动)。
+ *
+ * 通过唯一公开入口 window.__initLightbox 驱动,构造 .blur-img DOM,
+ * 模拟点击/键盘/滚动,断言 overlay 出现/消失、counter 文本、originNode 捕获。
+ *
+ * 目的:钉住高风险路径(循环闭包捕获 idx、图集 gotoIndex 循环边界、
+ * 关闭清理),供后续现代化重构(var→const、拆 IIFE、for→for..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 = `
+
+
+ `;
+ 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 + matchMedia(prefersReducedMotion 读它)
+ document.body.innerHTML = '';
+ // happy-dom 的 matchMedia 返回值默认 matches=false,reduced-motion 关闭,
+ // 这样打开走 double-rAF 动画路径(更接近真实)。但我们用 fake timers 跳过动画。
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ document.body.innerHTML = '';
+ });
+
+ describe('循环闭包捕获 idx(gallery 绑定)', () => {
+ 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 图后出现 overlay,img 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 走 transitionend(happy-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(); // 仍打开
+ });
+ });
+});
diff --git a/libs/lightbox/vitest.config.ts b/libs/lightbox/vitest.config.ts
index 7eeb3f8..beedb8f 100644
--- a/libs/lightbox/vitest.config.ts
+++ b/libs/lightbox/vitest.config.ts
@@ -2,7 +2,9 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
- environment: 'node',
+ // 默认 happy-dom:index.ts 的行为测试需要真实 DOM(点击/keydown/scroll/IO)。
+ // geometry.test.ts 是纯函数,用文件内 // @vitest-environment node 指令保持 node 环境。
+ environment: 'happy-dom',
include: ['src/**/*.test.ts'],
},
});