Compare commits
14 Commits
1cc09e4cbc
...
eed0b24ec3
| Author | SHA1 | Date | |
|---|---|---|---|
| eed0b24ec3 | |||
| 25eacc1c3c | |||
| aadfed2595 | |||
| ffaf191398 | |||
| 507d271a2b | |||
| 05e2989278 | |||
| 022b63aedc | |||
| 0301185a17 | |||
| adf9e9cf07 | |||
| 3284b03819 | |||
| b4ef906141 | |||
| 5f57d375ea | |||
| 44fc641aaa | |||
| c5af4457e2 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,7 @@ others/
|
|||||||
public/style.css
|
public/style.css
|
||||||
public/highlight.css
|
public/highlight.css
|
||||||
public/tiptap
|
public/tiptap
|
||||||
|
public/lightbox
|
||||||
/static
|
/static
|
||||||
.env
|
.env
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|||||||
@ -10,9 +10,9 @@ title = "Yggdrasil - Dioxus SSR"
|
|||||||
watch_path = ["src", "Cargo.toml"]
|
watch_path = ["src", "Cargo.toml"]
|
||||||
|
|
||||||
[web.resource]
|
[web.resource]
|
||||||
style = ["/style.css", "/highlight.css", "/tiptap/editor.css"]
|
style = ["/style.css", "/highlight.css", "/tiptap/editor.css", "/lightbox/lightbox.css"]
|
||||||
script = ["/tiptap/editor.js"]
|
script = ["/tiptap/editor.js", "/lightbox/lightbox.js"]
|
||||||
|
|
||||||
[web.resource.dev]
|
[web.resource.dev]
|
||||||
style = ["/style.css", "/highlight.css", "/tiptap/editor.css"]
|
style = ["/style.css", "/highlight.css", "/tiptap/editor.css", "/lightbox/lightbox.css"]
|
||||||
script = ["/tiptap/editor.js"]
|
script = ["/tiptap/editor.js", "/lightbox/lightbox.js"]
|
||||||
|
|||||||
16
Makefile
16
Makefile
@ -1,13 +1,15 @@
|
|||||||
.PHONY: dev build build-linux css css-watch clean build-editor build-editor-incremental highlight-css test
|
.PHONY: dev build build-linux css css-watch clean build-editor build-editor-incremental build-lightbox build-lightbox-incremental highlight-css test
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@$(MAKE) build-editor
|
@$(MAKE) build-editor
|
||||||
|
@$(MAKE) build-lightbox
|
||||||
@$(MAKE) highlight-css
|
@$(MAKE) highlight-css
|
||||||
@tailwindcss -i input.css -o public/style.css --minify
|
@tailwindcss -i input.css -o public/style.css --minify
|
||||||
@dx build --release --debug-symbols=false
|
@dx build --release --debug-symbols=false
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
@$(MAKE) build-editor
|
@$(MAKE) build-editor
|
||||||
|
@$(MAKE) build-lightbox
|
||||||
@$(MAKE) highlight-css
|
@$(MAKE) highlight-css
|
||||||
@tailwindcss -i input.css -o public/style.css --minify
|
@tailwindcss -i input.css -o public/style.css --minify
|
||||||
@dx build @client --release --debug-symbols=false --wasm-js-cfg false
|
@dx build @client --release --debug-symbols=false --wasm-js-cfg false
|
||||||
@ -26,7 +28,16 @@ build-editor:
|
|||||||
build-editor-incremental:
|
build-editor-incremental:
|
||||||
@cd libs/tiptap-editor && npm run build
|
@cd libs/tiptap-editor && npm run build
|
||||||
|
|
||||||
dev: build-editor-incremental
|
build-lightbox:
|
||||||
|
@echo "Building Lightbox..."
|
||||||
|
@cd libs/lightbox && npm install && npm run build
|
||||||
|
@echo "Lightbox built."
|
||||||
|
|
||||||
|
# dev 用的增量构建:跳过 npm ci(假设 node_modules 已存在),仅 vite build。
|
||||||
|
build-lightbox-incremental:
|
||||||
|
@cd libs/lightbox && npm run build
|
||||||
|
|
||||||
|
dev: build-editor-incremental build-lightbox-incremental
|
||||||
@echo "Cleaning static/..."
|
@echo "Cleaning static/..."
|
||||||
@rm -rf static/
|
@rm -rf static/
|
||||||
@echo "Building Tiptap editor (incremental)..."
|
@echo "Building Tiptap editor (incremental)..."
|
||||||
@ -45,6 +56,7 @@ css-watch:
|
|||||||
test:
|
test:
|
||||||
@cargo test
|
@cargo test
|
||||||
@cd libs/tiptap-editor && npm test
|
@cd libs/tiptap-editor && npm test
|
||||||
|
@cd libs/lightbox && npm test
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@cargo clean
|
@cargo clean
|
||||||
|
|||||||
72
input.css
72
input.css
@ -526,78 +526,6 @@
|
|||||||
text-underline-offset: 0.3rem;
|
text-underline-offset: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 统一灯箱(Medium 风格) ========== */
|
|
||||||
.lightbox-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 50;
|
|
||||||
/* overflow:hidden 裁剪超大的 absolute img,防止原图(可能数千 px)
|
|
||||||
撑大文档可滚动区,否则会触发非预期的 scroll 事件。 */
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(0, 0, 0, 0.92);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 灯箱图片:绝对定位,尺寸/位置由 JS 的 transform 控制。
|
|
||||||
transform-origin 为 top left,配合 translate+scale 实现原地缩放。
|
|
||||||
显式 width/height 由 JS 设置为原图尺寸,确保布局尺寸确定,
|
|
||||||
transform 只做视觉缩放,不改变占位。 */
|
|
||||||
.lightbox-img {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
transform-origin: top left;
|
|
||||||
max-width: none;
|
|
||||||
max-height: none;
|
|
||||||
object-fit: contain;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox-caption {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
padding: 0 2rem;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox-counter {
|
|
||||||
position: fixed;
|
|
||||||
right: 1.5rem;
|
|
||||||
bottom: 1.5rem;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lightbox-nav {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-size: 3rem;
|
|
||||||
line-height: 1;
|
|
||||||
width: 3rem;
|
|
||||||
height: 5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 2;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
transition: color 0.2s ease-out, background 0.2s ease-out;
|
|
||||||
}
|
|
||||||
.lightbox-nav:hover {
|
|
||||||
color: #fff;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
.lightbox-prev { left: 1rem; }
|
|
||||||
.lightbox-next { right: 1rem; }
|
|
||||||
|
|
||||||
/* 卡片封面:blur-img 结构,固定 4:3 比例,hover 缩放 */
|
/* 卡片封面:blur-img 结构,固定 4:3 比例,hover 缩放 */
|
||||||
.post-card-cover-blur {
|
.post-card-cover-blur {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
1
libs/lightbox/.gitignore
vendored
Normal file
1
libs/lightbox/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
1402
libs/lightbox/package-lock.json
generated
Normal file
1402
libs/lightbox/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
libs/lightbox/package.json
Normal file
19
libs/lightbox/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@yggdrasil/lightbox",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"dev": "vite",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"happy-dom": "^20.10.6",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.1.0",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
libs/lightbox/src/geometry.test.ts
Normal file
87
libs/lightbox/src/geometry.test.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { fitCentered, transformFor, originalUrl, type Rect } from './geometry';
|
||||||
|
|
||||||
|
describe('fitCentered', () => {
|
||||||
|
// 大图受 maxW=vw*0.92 / maxH=vh*0.88 约束,scale 取较小者
|
||||||
|
it('宽图被视口宽度卡住(横向受限)', () => {
|
||||||
|
// 2000x1000 in 1000x800: maxW=920, maxH=704
|
||||||
|
// scale = min(920/2000, 704/1000, 1) = min(0.46, 0.704, 1) = 0.46
|
||||||
|
const r = fitCentered(2000, 1000, 1000, 800);
|
||||||
|
expect(r.w).toBeCloseTo(920, 5);
|
||||||
|
expect(r.h).toBeCloseTo(460, 5);
|
||||||
|
expect(r.x).toBeCloseTo((1000 - 920) / 2, 5); // 40
|
||||||
|
expect(r.y).toBeCloseTo((800 - 460) / 2, 5); // 170
|
||||||
|
});
|
||||||
|
|
||||||
|
it('高图被视口高度卡住(纵向受限)', () => {
|
||||||
|
// 1000x2000 in 1000x800: maxW=920, maxH=704
|
||||||
|
// scale = min(920/1000, 704/2000, 1) = min(0.92, 0.352, 1) = 0.352
|
||||||
|
const r = fitCentered(1000, 2000, 1000, 800);
|
||||||
|
expect(r.w).toBeCloseTo(352, 5);
|
||||||
|
expect(r.h).toBeCloseTo(704, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('小图不被放大(scale 钳到 1)', () => {
|
||||||
|
// 300x200 in 1000x800: scale = min(920/300, 704/200, 1) = 1
|
||||||
|
const r = fitCentered(300, 200, 1000, 800);
|
||||||
|
expect(r.w).toBe(300);
|
||||||
|
expect(r.h).toBe(200);
|
||||||
|
expect(r.x).toBeCloseTo((1000 - 300) / 2, 5); // 350
|
||||||
|
expect(r.y).toBeCloseTo((800 - 200) / 2, 5); // 300
|
||||||
|
});
|
||||||
|
|
||||||
|
it('正方形图按 maxW/maxH 中较小者缩放', () => {
|
||||||
|
// 1000x1000 in 1000x800: maxW=920, maxH=704
|
||||||
|
// scale = min(0.92, 0.704, 1) = 0.704
|
||||||
|
const r = fitCentered(1000, 1000, 1000, 800);
|
||||||
|
expect(r.w).toBeCloseTo(704, 5);
|
||||||
|
expect(r.h).toBeCloseTo(704, 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transformFor', () => {
|
||||||
|
it('居中态(base=target):scale=1,translate 到目标左上角', () => {
|
||||||
|
const target: Rect = { x: 40, y: 170, w: 920, h: 460 };
|
||||||
|
const t = transformFor(target, 920, 460);
|
||||||
|
expect(t).toBe('translate(40px,170px) scale(1,1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缩小态(rect 比 base 小):scale<1', () => {
|
||||||
|
// 原图位置小,base=居中尺寸 920x460
|
||||||
|
const origin: Rect = { x: 100, y: 500, w: 400, h: 200 };
|
||||||
|
const t = transformFor(origin, 920, 460);
|
||||||
|
// 400/920 = 0.43478260869565216(JS 浮点完整精度)
|
||||||
|
expect(t).toBe('translate(100px,500px) scale(0.43478260869565216,0.43478260869565216)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('baseW=0 守卫:scale 守卫为 1(不产生 NaN/Infinity)', () => {
|
||||||
|
const rect: Rect = { x: 0, y: 0, w: 0, h: 0 };
|
||||||
|
const t = transformFor(rect, 0, 0);
|
||||||
|
expect(t).toBe('translate(0px,0px) scale(1,1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('字符串格式为 translate(Xpx,Ypx) scale(SX,SY)', () => {
|
||||||
|
const rect: Rect = { x: 10, y: 20, w: 100, h: 50 };
|
||||||
|
const t = transformFor(rect, 200, 100);
|
||||||
|
expect(t).toMatch(/^translate\(\d+(\.\d+)?px,\d+(\.\d+)?px\) scale\(\d+(\.\d+)?,\d+(\.\d+)?\)$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('originalUrl', () => {
|
||||||
|
it('去 query string', () => {
|
||||||
|
expect(originalUrl('/uploads/x.webp?w=800')).toBe('/uploads/x.webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无 query 原样返回', () => {
|
||||||
|
expect(originalUrl('/uploads/x.webp')).toBe('/uploads/x.webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null 输入返回空串', () => {
|
||||||
|
expect(originalUrl(null)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空串输入返回空串', () => {
|
||||||
|
expect(originalUrl('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
48
libs/lightbox/src/geometry.ts
Normal file
48
libs/lightbox/src/geometry.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
export interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算图片在视口居中、contain 适配后的目标 rect。
|
||||||
|
// naturalW/H: 图片真实像素尺寸;vw/vh: 视口尺寸。
|
||||||
|
export function fitCentered(naturalW: number, naturalH: number, vw: number, vh: number): Rect {
|
||||||
|
var maxW = vw * 0.92;
|
||||||
|
var maxH = vh * 0.88;
|
||||||
|
var scale = Math.min(maxW / naturalW, maxH / naturalH, 1);
|
||||||
|
var w = naturalW * scale;
|
||||||
|
var h = naturalH * scale;
|
||||||
|
return {
|
||||||
|
x: (vw - w) / 2,
|
||||||
|
y: (vh - h) / 2,
|
||||||
|
w: w,
|
||||||
|
h: h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把目标 rect 转成 transform 字符串。
|
||||||
|
// baseW/baseH 是 img 元素的布局尺寸(=居中目标尺寸),scale 相对它缩放。
|
||||||
|
// transform-origin 为 top left(见 CSS),translate 到 rect 左上角后 scale。
|
||||||
|
// - 居中态:scale=1(base 就是居中尺寸)
|
||||||
|
// - originRect 态:scale = originRect.w / base.w(缩小)
|
||||||
|
export function transformFor(rect: Rect, baseW: number, baseH: number): string {
|
||||||
|
var sx = baseW > 0 ? rect.w / baseW : 1;
|
||||||
|
var sy = baseH > 0 ? rect.h / baseH : 1;
|
||||||
|
return (
|
||||||
|
"translate(" +
|
||||||
|
rect.x +
|
||||||
|
"px," +
|
||||||
|
rect.y +
|
||||||
|
"px) scale(" +
|
||||||
|
sx +
|
||||||
|
"," +
|
||||||
|
sy +
|
||||||
|
")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原图 URL = data-src 去 query。data-src 形如 "/uploads/x.webp?w=800"。
|
||||||
|
export function originalUrl(dataSrc: string | null): string {
|
||||||
|
return (dataSrc || "").split("?")[0];
|
||||||
|
}
|
||||||
545
libs/lightbox/src/index.ts
Normal file
545
libs/lightbox/src/index.ts
Normal file
@ -0,0 +1,545 @@
|
|||||||
|
import { fitCentered, transformFor, originalUrl, type Rect } from "./geometry";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
interface LightboxState {
|
||||||
|
overlay: HTMLDivElement;
|
||||||
|
img: HTMLImageElement;
|
||||||
|
caption: HTMLElement;
|
||||||
|
counter: HTMLDivElement;
|
||||||
|
prevBtn: HTMLButtonElement | null;
|
||||||
|
nextBtn: HTMLButtonElement | null;
|
||||||
|
originNode: HTMLElement;
|
||||||
|
gallery: HTMLElement[];
|
||||||
|
index: number | null;
|
||||||
|
isSingle: boolean;
|
||||||
|
openScrollY: number;
|
||||||
|
origSrc: string;
|
||||||
|
altText: string;
|
||||||
|
closing: boolean;
|
||||||
|
reduced: boolean;
|
||||||
|
scrollHandler: ((this: Window, ev: Event) => void) | null;
|
||||||
|
keyHandler: ((this: Document, ev: KeyboardEvent) => void) | null;
|
||||||
|
target?: Rect;
|
||||||
|
baseW?: number;
|
||||||
|
baseH?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__initLightbox: (selectors: string | string[]) => void;
|
||||||
|
__lightboxSelectors?: string[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
// ============ 工具函数 ============
|
||||||
|
|
||||||
|
function prefersReducedMotion(): boolean {
|
||||||
|
return (
|
||||||
|
!!window.matchMedia &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取元素当前在视口里的 rect(用于飞行起点/终点)。
|
||||||
|
// 统一映射成 {x,y,w,h}:getBoundingClientRect 返回的 DOMRect 用
|
||||||
|
// left/top/width/height,而 fitCentered/transformFor 用 x/y/w/h,
|
||||||
|
// 这里转成一致格式,避免 .w 读到 undefined。
|
||||||
|
function rectOf(el: Element): Rect {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: r.left,
|
||||||
|
y: r.top,
|
||||||
|
w: r.width,
|
||||||
|
h: r.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:fitCentered / transformFor / originalUrl 已抽到 ./geometry.ts。
|
||||||
|
|
||||||
|
// ============ 懒加载 ============
|
||||||
|
|
||||||
|
// 为单个 .blur-img 容器初始化高清图懒加载。
|
||||||
|
// IO 进入视口后把 data-src 写入 src,加载完成加 is-loaded 触发 CSS 淡入。
|
||||||
|
function initLazyLoad(container: Element): void {
|
||||||
|
const raw = container.querySelector(".blur-img-full");
|
||||||
|
if (!(raw instanceof HTMLImageElement)) return;
|
||||||
|
const fullImg: HTMLImageElement = raw;
|
||||||
|
if (container.getAttribute("data-blur-init")) return;
|
||||||
|
container.setAttribute("data-blur-init", "true");
|
||||||
|
|
||||||
|
const fullSrc = fullImg.getAttribute("data-src");
|
||||||
|
if (!fullSrc) return;
|
||||||
|
|
||||||
|
const onFullLoaded = (): void => {
|
||||||
|
// 给容器加 is-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) {
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries: IntersectionObserverEntry[]): void => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
fullImg.src = fullSrc;
|
||||||
|
io.unobserve(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" }
|
||||||
|
);
|
||||||
|
io.observe(container);
|
||||||
|
} else {
|
||||||
|
fullImg.src = fullSrc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 图像收集 ============
|
||||||
|
|
||||||
|
// 收集所有 selectors命中的 .blur-img 节点。
|
||||||
|
// gallery: 正文图(组成图集);singles: 带 lightbox-single class 的单张图(如封面)。
|
||||||
|
function collectImages(roots: Element[]): { gallery: HTMLElement[]; singles: HTMLElement[] } {
|
||||||
|
const gallery: HTMLElement[] = [];
|
||||||
|
const singles: HTMLElement[] = [];
|
||||||
|
for (const root of roots) {
|
||||||
|
const nodes = root.querySelectorAll(".blur-img");
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (!(n instanceof HTMLElement)) continue;
|
||||||
|
if (n.classList.contains("lightbox-single")) {
|
||||||
|
singles.push(n);
|
||||||
|
} else {
|
||||||
|
gallery.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { gallery, singles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 灯箱状态与开/关 ============
|
||||||
|
|
||||||
|
// 当前灯箱状态(同时只允许一个灯箱)。
|
||||||
|
let state: LightboxState | null = null;
|
||||||
|
|
||||||
|
function openLightbox(originNode: HTMLElement, gallery: HTMLElement[], index: number | null): void {
|
||||||
|
if (state) closeLightbox(true);
|
||||||
|
|
||||||
|
const fullImgEl = originNode.querySelector(".blur-img-full");
|
||||||
|
if (!(fullImgEl instanceof HTMLImageElement)) return;
|
||||||
|
const dataSrc = fullImgEl.getAttribute("data-src") || "";
|
||||||
|
const origSrc = originalUrl(dataSrc);
|
||||||
|
const altText = fullImgEl.getAttribute("alt") || "";
|
||||||
|
const isSingle =
|
||||||
|
originNode.classList.contains("lightbox-single") ||
|
||||||
|
gallery.length === 0;
|
||||||
|
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
// 建 DOM
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "lightbox-overlay";
|
||||||
|
overlay.setAttribute("role", "dialog");
|
||||||
|
overlay.setAttribute("aria-modal", "true");
|
||||||
|
overlay.setAttribute("aria-label", "图片预览");
|
||||||
|
overlay.setAttribute("tabindex", "-1");
|
||||||
|
// 创建后立即设 opacity 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";
|
||||||
|
|
||||||
|
nextBtn = document.createElement("button");
|
||||||
|
nextBtn.className = "lightbox-nav lightbox-next";
|
||||||
|
nextBtn.setAttribute("type", "button");
|
||||||
|
nextBtn.setAttribute("aria-label", "下一张");
|
||||||
|
nextBtn.textContent = "\u203a";
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.appendChild(img);
|
||||||
|
overlay.appendChild(caption);
|
||||||
|
overlay.appendChild(counter);
|
||||||
|
if (prevBtn) overlay.appendChild(prevBtn);
|
||||||
|
if (nextBtn) overlay.appendChild(nextBtn);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
state = {
|
||||||
|
overlay,
|
||||||
|
img,
|
||||||
|
caption,
|
||||||
|
counter,
|
||||||
|
prevBtn,
|
||||||
|
nextBtn,
|
||||||
|
originNode,
|
||||||
|
gallery,
|
||||||
|
index,
|
||||||
|
isSingle,
|
||||||
|
openScrollY: window.scrollY,
|
||||||
|
origSrc,
|
||||||
|
altText,
|
||||||
|
closing: false,
|
||||||
|
reduced: prefersReducedMotion(),
|
||||||
|
scrollHandler: null,
|
||||||
|
keyHandler: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 焦点移入灯箱
|
||||||
|
overlay.focus();
|
||||||
|
// 立即绑定交互(不等图片加载):加载期间 Esc/滚动/点背景也须可关闭。
|
||||||
|
bindInteractions();
|
||||||
|
|
||||||
|
// 图片加载后再做动画(naturalW/H 要等加载)
|
||||||
|
const start = (): void => {
|
||||||
|
if (!state) return; // 加载前可能已被关闭
|
||||||
|
const naturalW = img.naturalWidth || img.clientWidth || 1;
|
||||||
|
const naturalH = img.naturalHeight || img.clientHeight || 1;
|
||||||
|
const originRect = rectOf(originNode);
|
||||||
|
|
||||||
|
// 基准 = originRect(文章里图片的实际尺寸)。
|
||||||
|
// img 的布局尺寸固定为 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`;
|
||||||
|
|
||||||
|
// reduced-motion:直接淡入居中
|
||||||
|
if (state.reduced) {
|
||||||
|
img.style.opacity = "0";
|
||||||
|
img.style.transform = transformFor(target, baseW, baseH);
|
||||||
|
img.style.left = "0";
|
||||||
|
img.style.top = "0";
|
||||||
|
overlay.style.opacity = "0";
|
||||||
|
// 下一帧淡入
|
||||||
|
requestAnimationFrame((): void => {
|
||||||
|
if (!state) return;
|
||||||
|
overlay.style.transition = "opacity 200ms ease-out";
|
||||||
|
img.style.transition = "opacity 200ms ease-out";
|
||||||
|
overlay.style.opacity = "1";
|
||||||
|
img.style.opacity = "1";
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首帧:文章位置 + 原尺寸(scale=1),透明,且关闭 transition
|
||||||
|
img.style.transition = "none";
|
||||||
|
img.style.left = "0";
|
||||||
|
img.style.top = "0";
|
||||||
|
img.style.transform = transformFor(originRect, baseW, baseH);
|
||||||
|
img.style.opacity = "0";
|
||||||
|
overlay.style.opacity = "0";
|
||||||
|
// 强制 reflow,确保首帧的 transform 已提交到渲染层。
|
||||||
|
// 否则单层 rAF 里浏览器可能合并首帧与目标帧,动画从错误位置起跳。
|
||||||
|
void img.offsetHeight;
|
||||||
|
|
||||||
|
// double-rAF:第一帧绘制首帧(无动画),第二帧才启动 transition 到居中。
|
||||||
|
requestAnimationFrame((): void => {
|
||||||
|
if (!state) return;
|
||||||
|
requestAnimationFrame((): void => {
|
||||||
|
if (!state) return;
|
||||||
|
img.style.transition =
|
||||||
|
"transform 250ms ease-out, opacity 250ms ease-out";
|
||||||
|
overlay.style.transition = "opacity 250ms ease-out";
|
||||||
|
img.style.transform = transformFor(target, baseW, baseH);
|
||||||
|
img.style.opacity = "1";
|
||||||
|
overlay.style.opacity = "1";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (img.complete && img.naturalWidth) {
|
||||||
|
start();
|
||||||
|
} else {
|
||||||
|
img.addEventListener("load", start, { once: true });
|
||||||
|
}
|
||||||
|
img.src = origSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox(immediate: boolean): void {
|
||||||
|
if (!state || state.closing) return;
|
||||||
|
state.closing = true;
|
||||||
|
cleanupInteractions();
|
||||||
|
|
||||||
|
const s = state;
|
||||||
|
|
||||||
|
// 基准 = originRect 尺寸(与打开时一致),scale 相对它缩放。
|
||||||
|
const baseW = s.baseW || (s.target ? s.target.w : 1);
|
||||||
|
const baseH = s.baseH || (s.target ? s.target.h : 1);
|
||||||
|
const originRect = rectOf(s.originNode); // 实时读,处理期间滚动过的情况
|
||||||
|
|
||||||
|
if (s.reduced || immediate) {
|
||||||
|
removeOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 飞回 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);
|
||||||
|
}
|
||||||
266
libs/lightbox/src/lightbox.test.ts
Normal file
266
libs/lightbox/src/lightbox.test.ts
Normal file
@ -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 = `
|
||||||
|
<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 + 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(); // 仍打开
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
71
libs/lightbox/src/style.css
Normal file
71
libs/lightbox/src/style.css
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/* ========== 统一灯箱(Medium 风格) ========== */
|
||||||
|
.lightbox-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
/* overflow:hidden 裁剪超大的 absolute img,防止原图(可能数千 px)
|
||||||
|
撑大文档可滚动区,否则会触发非预期的 scroll 事件。 */
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 灯箱图片:绝对定位,尺寸/位置由 JS 的 transform 控制。
|
||||||
|
transform-origin 为 top left,配合 translate+scale 实现原地缩放。
|
||||||
|
显式 width/height 由 JS 设置为原图尺寸,确保布局尺寸确定,
|
||||||
|
transform 只做视觉缩放,不改变占位。 */
|
||||||
|
.lightbox-img {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
transform-origin: top left;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-caption {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0 2rem;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-counter {
|
||||||
|
position: fixed;
|
||||||
|
right: 1.5rem;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 3rem;
|
||||||
|
line-height: 1;
|
||||||
|
width: 3rem;
|
||||||
|
height: 5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: color 0.2s ease-out, background 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.lightbox-nav:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.lightbox-prev { left: 1rem; }
|
||||||
|
.lightbox-next { right: 1rem; }
|
||||||
1
libs/lightbox/src/vite-env.d.ts
vendored
Normal file
1
libs/lightbox/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
19
libs/lightbox/tsconfig.json
Normal file
19
libs/lightbox/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
23
libs/lightbox/vite.config.ts
Normal file
23
libs/lightbox/vite.config.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
outDir: resolve(__dirname, '../../public/lightbox'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
|
name: 'Lightbox',
|
||||||
|
fileName: () => 'lightbox.js',
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
assetFileNames: 'lightbox.[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cssCodeSplit: false,
|
||||||
|
minify: true,
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
10
libs/lightbox/vitest.config.ts
Normal file
10
libs/lightbox/vitest.config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
// 默认 happy-dom:index.ts 的行为测试需要真实 DOM(点击/keydown/scroll/IO)。
|
||||||
|
// geometry.test.ts 是纯函数,用文件内 // @vitest-environment node 指令保持 node 环境。
|
||||||
|
environment: 'happy-dom',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,540 +0,0 @@
|
|||||||
(function () {
|
|
||||||
"use strict";
|
|
||||||
|
|
||||||
// ============ 工具函数 ============
|
|
||||||
|
|
||||||
function prefersReducedMotion() {
|
|
||||||
return (
|
|
||||||
window.matchMedia &&
|
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取元素当前在视口里的 rect(用于飞行起点/终点)。
|
|
||||||
// 统一映射成 {x,y,w,h}:getBoundingClientRect 返回的 DOMRect 用
|
|
||||||
// left/top/width/height,而 fitCentered/transformFor 用 x/y/w/h,
|
|
||||||
// 这里转成一致格式,避免 .w 读到 undefined。
|
|
||||||
function rectOf(el) {
|
|
||||||
var r = el.getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
x: r.left,
|
|
||||||
y: r.top,
|
|
||||||
w: r.width,
|
|
||||||
h: r.height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算图片在视口居中、contain 适配后的目标 rect。
|
|
||||||
// naturalW/H: 图片真实像素尺寸;vw/vh: 视口尺寸。
|
|
||||||
function fitCentered(naturalW, naturalH, vw, vh) {
|
|
||||||
var maxW = vw * 0.92;
|
|
||||||
var maxH = vh * 0.88;
|
|
||||||
var scale = Math.min(maxW / naturalW, maxH / naturalH, 1);
|
|
||||||
var w = naturalW * scale;
|
|
||||||
var h = naturalH * scale;
|
|
||||||
return {
|
|
||||||
x: (vw - w) / 2,
|
|
||||||
y: (vh - h) / 2,
|
|
||||||
w: w,
|
|
||||||
h: h,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 把目标 rect 转成 transform 字符串。
|
|
||||||
// baseW/baseH 是 img 元素的布局尺寸(=居中目标尺寸),scale 相对它缩放。
|
|
||||||
// transform-origin 为 top left(见 CSS),translate 到 rect 左上角后 scale。
|
|
||||||
// - 居中态:scale=1(base 就是居中尺寸)
|
|
||||||
// - originRect 态:scale = originRect.w / base.w(缩小)
|
|
||||||
function transformFor(rect, baseW, baseH) {
|
|
||||||
var sx = baseW > 0 ? rect.w / baseW : 1;
|
|
||||||
var sy = baseH > 0 ? rect.h / baseH : 1;
|
|
||||||
return (
|
|
||||||
"translate(" +
|
|
||||||
rect.x +
|
|
||||||
"px," +
|
|
||||||
rect.y +
|
|
||||||
"px) scale(" +
|
|
||||||
sx +
|
|
||||||
"," +
|
|
||||||
sy +
|
|
||||||
")"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 原图 URL = data-src 去 query。data-src 形如 "/uploads/x.webp?w=800"。
|
|
||||||
function originalUrl(dataSrc) {
|
|
||||||
return (dataSrc || "").split("?")[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 懒加载 ============
|
|
||||||
|
|
||||||
// 为单个 .blur-img 容器初始化高清图懒加载。
|
|
||||||
// IO 进入视口后把 data-src 写入 src,加载完成加 is-loaded 触发 CSS 淡入。
|
|
||||||
function initLazyLoad(container) {
|
|
||||||
var fullImg = container.querySelector(".blur-img-full");
|
|
||||||
if (!fullImg) return;
|
|
||||||
if (container.getAttribute("data-blur-init")) return;
|
|
||||||
container.setAttribute("data-blur-init", "true");
|
|
||||||
|
|
||||||
var fullSrc = fullImg.getAttribute("data-src");
|
|
||||||
if (!fullSrc) return;
|
|
||||||
|
|
||||||
var onFullLoaded = function () {
|
|
||||||
// 给容器加 is-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) {
|
|
||||||
entries.forEach(function (entry) {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
fullImg.src = fullSrc;
|
|
||||||
io.unobserve(container);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ rootMargin: "200px" }
|
|
||||||
);
|
|
||||||
io.observe(container);
|
|
||||||
} else {
|
|
||||||
fullImg.src = fullSrc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 图像收集 ============
|
|
||||||
|
|
||||||
// 收集所有 selectors命中的 .blur-img 节点。
|
|
||||||
// gallery: 正文图(组成图集);singles: 带 lightbox-single class 的单张图(如封面)。
|
|
||||||
function collectImages(roots) {
|
|
||||||
var gallery = [];
|
|
||||||
var singles = [];
|
|
||||||
for (var i = 0; i < roots.length; i++) {
|
|
||||||
var nodes = roots[i].querySelectorAll(".blur-img");
|
|
||||||
for (var j = 0; j < nodes.length; j++) {
|
|
||||||
var n = nodes[j];
|
|
||||||
if (n.classList.contains("lightbox-single")) {
|
|
||||||
singles.push(n);
|
|
||||||
} else {
|
|
||||||
gallery.push(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { gallery: gallery, singles: singles };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 灯箱状态与开/关 ============
|
|
||||||
|
|
||||||
// 当前灯箱状态(同时只允许一个灯箱)。
|
|
||||||
var state = null;
|
|
||||||
|
|
||||||
function openLightbox(originNode, gallery, index) {
|
|
||||||
if (state) closeLightbox(true);
|
|
||||||
|
|
||||||
var fullImg = originNode.querySelector(".blur-img-full");
|
|
||||||
if (!fullImg) return;
|
|
||||||
var dataSrc = fullImg.getAttribute("data-src") || "";
|
|
||||||
var origSrc = originalUrl(dataSrc);
|
|
||||||
var altText = fullImg.getAttribute("alt") || "";
|
|
||||||
var isSingle =
|
|
||||||
originNode.classList.contains("lightbox-single") ||
|
|
||||||
gallery.length === 0;
|
|
||||||
|
|
||||||
var vw = window.innerWidth;
|
|
||||||
var vh = window.innerHeight;
|
|
||||||
|
|
||||||
// 建 DOM
|
|
||||||
var overlay = document.createElement("div");
|
|
||||||
overlay.className = "lightbox-overlay";
|
|
||||||
overlay.setAttribute("role", "dialog");
|
|
||||||
overlay.setAttribute("aria-modal", "true");
|
|
||||||
overlay.setAttribute("aria-label", "图片预览");
|
|
||||||
overlay.setAttribute("tabindex", "-1");
|
|
||||||
// 创建后立即设 opacity 0:append 到 DOM 时是透明的,避免在图片加载期间
|
|
||||||
// (start() 执行前)显示全黑背景造成闪烁。start() 的渐变会把 opacity 升到 1。
|
|
||||||
overlay.style.opacity = "0";
|
|
||||||
|
|
||||||
var img = document.createElement("img");
|
|
||||||
img.className = "lightbox-img";
|
|
||||||
img.setAttribute("alt", altText);
|
|
||||||
// 加载前先占 0 尺寸,避免原图(可能数千 px)在加载期间撑大文档
|
|
||||||
// 可滚动区、触发非预期的 scroll 事件。start() 拿到 natural 尺寸后再设真实值。
|
|
||||||
img.style.width = "0px";
|
|
||||||
img.style.height = "0px";
|
|
||||||
|
|
||||||
var caption = document.createElement("figcaption");
|
|
||||||
caption.className = "lightbox-caption";
|
|
||||||
caption.textContent = altText;
|
|
||||||
if (!altText) caption.style.display = "none";
|
|
||||||
|
|
||||||
var counter = document.createElement("div");
|
|
||||||
counter.className = "lightbox-counter";
|
|
||||||
if (isSingle || gallery.length === 0) {
|
|
||||||
counter.style.display = "none";
|
|
||||||
} else {
|
|
||||||
counter.textContent = index + 1 + " / " + gallery.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图集模式(>1 张)才加左右导航箭头;单张不显示。
|
|
||||||
var prevBtn = null;
|
|
||||||
var nextBtn = null;
|
|
||||||
if (!isSingle && gallery.length > 1) {
|
|
||||||
prevBtn = document.createElement("button");
|
|
||||||
prevBtn.className = "lightbox-nav lightbox-prev";
|
|
||||||
prevBtn.setAttribute("type", "button");
|
|
||||||
prevBtn.setAttribute("aria-label", "上一张");
|
|
||||||
prevBtn.textContent = "\u2039";
|
|
||||||
|
|
||||||
nextBtn = document.createElement("button");
|
|
||||||
nextBtn.className = "lightbox-nav lightbox-next";
|
|
||||||
nextBtn.setAttribute("type", "button");
|
|
||||||
nextBtn.setAttribute("aria-label", "下一张");
|
|
||||||
nextBtn.textContent = "\u203a";
|
|
||||||
}
|
|
||||||
|
|
||||||
overlay.appendChild(img);
|
|
||||||
overlay.appendChild(caption);
|
|
||||||
overlay.appendChild(counter);
|
|
||||||
if (prevBtn) overlay.appendChild(prevBtn);
|
|
||||||
if (nextBtn) overlay.appendChild(nextBtn);
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
state = {
|
|
||||||
overlay: overlay,
|
|
||||||
img: img,
|
|
||||||
caption: caption,
|
|
||||||
counter: counter,
|
|
||||||
prevBtn: prevBtn,
|
|
||||||
nextBtn: nextBtn,
|
|
||||||
originNode: originNode,
|
|
||||||
gallery: gallery,
|
|
||||||
index: index,
|
|
||||||
isSingle: isSingle,
|
|
||||||
openScrollY: window.scrollY,
|
|
||||||
origSrc: origSrc,
|
|
||||||
altText: altText,
|
|
||||||
closing: false,
|
|
||||||
reduced: prefersReducedMotion(),
|
|
||||||
scrollHandler: null,
|
|
||||||
keyHandler: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 焦点移入灯箱
|
|
||||||
overlay.focus();
|
|
||||||
// 立即绑定交互(不等图片加载):加载期间 Esc/滚动/点背景也须可关闭。
|
|
||||||
bindInteractions();
|
|
||||||
|
|
||||||
// 图片加载后再做动画(naturalW/H 要等加载)
|
|
||||||
var start = function () {
|
|
||||||
if (!state) return; // 加载前可能已被关闭
|
|
||||||
var naturalW = img.naturalWidth || img.clientWidth || 1;
|
|
||||||
var naturalH = img.naturalHeight || img.clientHeight || 1;
|
|
||||||
var originRect = rectOf(originNode);
|
|
||||||
|
|
||||||
// 基准 = originRect(文章里图片的实际尺寸)。
|
|
||||||
// img 的布局尺寸固定为 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 () {
|
|
||||||
if (!state) return;
|
|
||||||
overlay.style.transition = "opacity 200ms ease-out";
|
|
||||||
img.style.transition = "opacity 200ms ease-out";
|
|
||||||
overlay.style.opacity = "1";
|
|
||||||
img.style.opacity = "1";
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 首帧:文章位置 + 原尺寸(scale=1),透明,且关闭 transition
|
|
||||||
img.style.transition = "none";
|
|
||||||
img.style.left = "0";
|
|
||||||
img.style.top = "0";
|
|
||||||
img.style.transform = transformFor(originRect, baseW, baseH);
|
|
||||||
img.style.opacity = "0";
|
|
||||||
overlay.style.opacity = "0";
|
|
||||||
// 强制 reflow,确保首帧的 transform 已提交到渲染层。
|
|
||||||
// 否则单层 rAF 里浏览器可能合并首帧与目标帧,动画从错误位置起跳。
|
|
||||||
void img.offsetHeight;
|
|
||||||
|
|
||||||
// double-rAF:第一帧绘制首帧(无动画),第二帧才启动 transition 到居中。
|
|
||||||
requestAnimationFrame(function () {
|
|
||||||
if (!state) return;
|
|
||||||
requestAnimationFrame(function () {
|
|
||||||
if (!state) return;
|
|
||||||
img.style.transition =
|
|
||||||
"transform 250ms ease-out, opacity 250ms ease-out";
|
|
||||||
overlay.style.transition = "opacity 250ms ease-out";
|
|
||||||
img.style.transform = transformFor(target, baseW, baseH);
|
|
||||||
img.style.opacity = "1";
|
|
||||||
overlay.style.opacity = "1";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (img.complete && img.naturalWidth) {
|
|
||||||
start();
|
|
||||||
} else {
|
|
||||||
img.addEventListener("load", start, { once: true });
|
|
||||||
}
|
|
||||||
img.src = origSrc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLightbox(immediate) {
|
|
||||||
if (!state || state.closing) return;
|
|
||||||
state.closing = true;
|
|
||||||
cleanupInteractions();
|
|
||||||
|
|
||||||
var s = state;
|
|
||||||
|
|
||||||
// 基准 = originRect 尺寸(与打开时一致),scale 相对它缩放。
|
|
||||||
var baseW = s.baseW || (s.target ? s.target.w : 1);
|
|
||||||
var baseH = s.baseH || (s.target ? s.target.h : 1);
|
|
||||||
var originRect = rectOf(s.originNode); // 实时读,处理期间滚动过的情况
|
|
||||||
|
|
||||||
if (s.reduced || immediate) {
|
|
||||||
removeOverlay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 飞回 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";
|
|
||||||
|
|
||||||
var done = function () {
|
|
||||||
removeOverlay();
|
|
||||||
};
|
|
||||||
// 250ms 兜底,避免 transitionend 不触发
|
|
||||||
var timer = setTimeout(done, 280);
|
|
||||||
s.img.addEventListener("transitionend", function () { clearTimeout(timer); done(); }, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeOverlay() {
|
|
||||||
if (!state) return;
|
|
||||||
var prev = state.originNode;
|
|
||||||
if (state.overlay && state.overlay.parentNode) {
|
|
||||||
state.overlay.parentNode.removeChild(state.overlay);
|
|
||||||
}
|
|
||||||
state = null;
|
|
||||||
// 焦点归还:.blur-img 是 span 不可聚焦,让其内部 full img 获得焦点
|
|
||||||
if (prev) {
|
|
||||||
var f = prev.querySelector(".blur-img-full");
|
|
||||||
if (f) {
|
|
||||||
f.setAttribute("tabindex", "-1");
|
|
||||||
f.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 图集切换 ============
|
|
||||||
|
|
||||||
// 图集切换:淡入淡出,不飞行。newIndex 循环(首尾衔接)。
|
|
||||||
function gotoIndex(newIndex) {
|
|
||||||
if (!state || state.isSingle) return;
|
|
||||||
var s = state;
|
|
||||||
if (!s.gallery || s.gallery.length === 0) return;
|
|
||||||
if (newIndex < 0) newIndex = s.gallery.length - 1;
|
|
||||||
if (newIndex >= s.gallery.length) newIndex = 0;
|
|
||||||
if (newIndex === s.index) return;
|
|
||||||
|
|
||||||
var newNode = s.gallery[newIndex];
|
|
||||||
var fullImg = newNode.querySelector(".blur-img-full");
|
|
||||||
if (!fullImg) return;
|
|
||||||
var origSrc = originalUrl(fullImg.getAttribute("data-src") || "");
|
|
||||||
var altText = fullImg.getAttribute("alt") || "";
|
|
||||||
|
|
||||||
// 淡出当前图
|
|
||||||
s.img.style.transition = "opacity 150ms ease-out";
|
|
||||||
s.img.style.opacity = "0";
|
|
||||||
|
|
||||||
// 150ms 后换图淡入
|
|
||||||
var swap = function () {
|
|
||||||
if (!state) return; // 切换中可能已关闭
|
|
||||||
var fade = function () {
|
|
||||||
if (!state) return;
|
|
||||||
s.img.style.transition = "opacity 150ms ease-out";
|
|
||||||
s.img.style.opacity = "1";
|
|
||||||
};
|
|
||||||
if (s.img.complete && s.img.naturalWidth) {
|
|
||||||
// 缓存命中,直接淡入
|
|
||||||
s.img.src = origSrc;
|
|
||||||
fade();
|
|
||||||
} else {
|
|
||||||
s.img.addEventListener("load", fade, { once: true });
|
|
||||||
s.img.src = origSrc;
|
|
||||||
}
|
|
||||||
s.caption.textContent = altText;
|
|
||||||
s.caption.style.display = altText ? "" : "none";
|
|
||||||
s.counter.textContent = newIndex + 1 + " / " + s.gallery.length;
|
|
||||||
// 更新 originNode 为新图,使后续关闭/滚动关闭飞回新图位置
|
|
||||||
s.originNode = newNode;
|
|
||||||
s.index = newIndex;
|
|
||||||
s.openScrollY = window.scrollY; // 重置滚动关闭基线
|
|
||||||
};
|
|
||||||
setTimeout(swap, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 交互绑定 ============
|
|
||||||
|
|
||||||
function bindInteractions() {
|
|
||||||
var s = state;
|
|
||||||
if (!s) return;
|
|
||||||
|
|
||||||
// 点背景关闭(点图片本身不关,因箭头在图上、避免误关)
|
|
||||||
s.overlay.addEventListener("click", function (ev) {
|
|
||||||
if (state && ev.target === state.overlay) closeLightbox(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。
|
|
||||||
// 关键:逐帧读 originNode 实时 rect,文章滚多少图就回多少。
|
|
||||||
s.scrollHandler = function () {
|
|
||||||
if (!state) return;
|
|
||||||
var st = state;
|
|
||||||
if (st.closing) return;
|
|
||||||
var dy = Math.abs(window.scrollY - st.openScrollY);
|
|
||||||
if (st.reduced) {
|
|
||||||
// reduced-motion:立即关
|
|
||||||
closeLightbox(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var progress = Math.min(dy / 120, 1);
|
|
||||||
var target = st.target;
|
|
||||||
var baseW = st.baseW || (target ? target.w : 1);
|
|
||||||
var baseH = st.baseH || (target ? target.h : 1);
|
|
||||||
var originRect = rectOf(st.originNode);
|
|
||||||
// 在 originRect 与居中 target 之间按 progress 线性插值
|
|
||||||
var cur = {
|
|
||||||
x: target.x + (originRect.x - target.x) * progress,
|
|
||||||
y: target.y + (originRect.y - target.y) * progress,
|
|
||||||
w: target.w + (originRect.w - target.w) * progress,
|
|
||||||
h: target.h + (originRect.h - target.h) * progress,
|
|
||||||
};
|
|
||||||
st.img.style.transition = "none";
|
|
||||||
st.img.style.transform = transformFor(cur, baseW, baseH);
|
|
||||||
st.img.style.opacity = String(1 - progress);
|
|
||||||
st.overlay.style.opacity = String(1 - progress);
|
|
||||||
if (progress >= 1) {
|
|
||||||
// 已飞回原位:文章停在当前滚动位置,移除灯箱
|
|
||||||
st.closing = true;
|
|
||||||
cleanupInteractions();
|
|
||||||
removeOverlay();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("scroll", s.scrollHandler, { passive: true });
|
|
||||||
|
|
||||||
// 键盘:Esc 关;图集模式 ←→ 切换
|
|
||||||
s.keyHandler = function (ev) {
|
|
||||||
if (!state) return;
|
|
||||||
if (ev.key === "Escape") {
|
|
||||||
closeLightbox(false);
|
|
||||||
} else if (!state.isSingle && state.gallery.length > 1) {
|
|
||||||
if (ev.key === "ArrowLeft") {
|
|
||||||
ev.preventDefault();
|
|
||||||
gotoIndex(state.index - 1);
|
|
||||||
} else if (ev.key === "ArrowRight") {
|
|
||||||
ev.preventDefault();
|
|
||||||
gotoIndex(state.index + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", s.keyHandler);
|
|
||||||
|
|
||||||
// 图集导航箭头点击(stopPropagation 防止冒泡到 overlay 触发关闭)
|
|
||||||
if (s.prevBtn) {
|
|
||||||
s.prevBtn.addEventListener("click", function (ev) {
|
|
||||||
ev.stopPropagation();
|
|
||||||
if (state) gotoIndex(state.index - 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (s.nextBtn) {
|
|
||||||
s.nextBtn.addEventListener("click", function (ev) {
|
|
||||||
ev.stopPropagation();
|
|
||||||
if (state) gotoIndex(state.index + 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupInteractions() {
|
|
||||||
if (!state) return;
|
|
||||||
if (state.scrollHandler) {
|
|
||||||
window.removeEventListener("scroll", state.scrollHandler);
|
|
||||||
state.scrollHandler = null;
|
|
||||||
}
|
|
||||||
if (state.keyHandler) {
|
|
||||||
document.removeEventListener("keydown", state.keyHandler);
|
|
||||||
state.keyHandler = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 初始化入口 ============
|
|
||||||
|
|
||||||
window.__initLightbox = function (selectors) {
|
|
||||||
// selectors 可以是字符串、字符串数组
|
|
||||||
var sels = Array.isArray(selectors) ? selectors : [selectors];
|
|
||||||
var roots = [];
|
|
||||||
for (var i = 0; i < sels.length; i++) {
|
|
||||||
var found = document.querySelectorAll(sels[i]);
|
|
||||||
for (var j = 0; j < found.length; j++) roots.push(found[j]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先对所有图片做懒加载(图集与单张都做)
|
|
||||||
var collected = collectImages(roots);
|
|
||||||
var everyone = collected.gallery.concat(collected.singles);
|
|
||||||
for (var k = 0; k < everyone.length; k++) {
|
|
||||||
initLazyLoad(everyone[k]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 正文图:带 index
|
|
||||||
var gallery = collected.gallery;
|
|
||||||
for (var g = 0; g < gallery.length; g++) {
|
|
||||||
(function (node, idx) {
|
|
||||||
node.addEventListener("click", function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
openLightbox(node, gallery, idx);
|
|
||||||
});
|
|
||||||
})(gallery[g], g);
|
|
||||||
}
|
|
||||||
// 单张图(封面):index = null,gallery 传空数组表示单张
|
|
||||||
for (var si = 0; si < collected.singles.length; si++) {
|
|
||||||
(function (node) {
|
|
||||||
node.addEventListener("click", function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
openLightbox(node, [], null);
|
|
||||||
});
|
|
||||||
})(collected.singles[si]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@ -10,17 +10,22 @@ use dioxus::prelude::*;
|
|||||||
/// - `content_html`:服务端渲染的文章 HTML 字符串
|
/// - `content_html`:服务端渲染的文章 HTML 字符串
|
||||||
///
|
///
|
||||||
/// 关键行为:
|
/// 关键行为:
|
||||||
/// - 在 `target_arch = "wasm32"` 环境下执行 `post-content.js`(代码块复制)与
|
/// - 在 `target_arch = "wasm32"` 环境下执行 `post-content.js`(代码块复制)。
|
||||||
/// `lightbox.js`(图片灯箱 + 懒加载),并调用各自的初始化函数。
|
/// 灯箱(图片灯箱 + 懒加载)改由 `Dioxus.toml` 全局注入 `lightbox.js`,
|
||||||
|
/// 这里仅设置其初始化配置 `__lightboxSelectors` 并兜底调用。
|
||||||
#[component]
|
#[component]
|
||||||
pub fn PostContent(content_html: String) -> Element {
|
pub fn PostContent(content_html: String) -> Element {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let _ = js_sys::eval(include_str!("../../../public/js/post-content.js"));
|
let _ = js_sys::eval(include_str!("../../../public/js/post-content.js"));
|
||||||
let _ = js_sys::eval(include_str!("../../../public/js/lightbox.js"));
|
|
||||||
let _ = js_sys::eval("window.__initPostContent('.post-content')");
|
let _ = js_sys::eval("window.__initPostContent('.post-content')");
|
||||||
// 正文图组成图集;封面(.entry-cover,带 lightbox-single class)单张模式。
|
// lightbox 改由 Dioxus.toml 全局 <script src> 加载(不再 include_str!)。
|
||||||
let _ = js_sys::eval("window.__initLightbox(['.post-content', '.entry-cover'])");
|
// 双保险契约:先设配置,若 lightbox.js 已加载则立即调用;
|
||||||
|
// 否则 lightbox.js 加载完后其 IIFE 尾部读到配置自启动。
|
||||||
|
let _ = js_sys::eval(
|
||||||
|
"window.__lightboxSelectors = ['.post-content', '.entry-cover']; \
|
||||||
|
if (window.__initLightbox) window.__initLightbox(window.__lightboxSelectors);"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user