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 = ` + ${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 + 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'], }, });