Merge branch 'feat/lightbox-typescript-build'

灯箱 TypeScript 化与独立构建:将 public/js/lightbox.js (540 行 JS) 迁移为
libs/lightbox/ 独立 TS 工程,镜像 libs/tiptap-editor/ 结构。Vite 构建到
public/lightbox/,经 Dioxus.toml 全局注入,Rust 端去 include_str! 改用
__lightboxSelectors 配置 + IIFE 自启动双保险契约。

- geometry.ts (纯函数 + 12 Vitest 用例) / index.ts (DOM 胶水 + state)
- .lightbox-* CSS 搬入 lightbox.css;.blur-img* 留 input.css (列表页依赖)
- 依赖: typescript 6.0.3 / vite 8.1.0 / vitest 4.1.9 (npm latest)
- 修复关闭灯箱时 focus() 触发的页面滚动 (preventScroll)
This commit is contained in:
xfy 2026-06-24 11:31:48 +08:00
commit ffaf191398
16 changed files with 1709 additions and 184 deletions

1
.gitignore vendored
View File

@ -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/

View File

@ -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"]

View File

@ -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

View File

@ -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
View File

@ -0,0 +1 @@
node_modules

1290
libs/lightbox/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
{
"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": {
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vitest": "^4.1.9"
}
}

View File

@ -0,0 +1,86 @@
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.43478260869565216JS 浮点完整精度)
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('');
});
});

View 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见 CSStranslate 到 rect 左上角后 scale。
// - 居中态scale=1base 就是居中尺寸)
// - 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];
}

View File

@ -1,11 +1,46 @@
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 () { (function () {
"use strict"; "use strict";
// ============ 工具函数 ============ // ============ 工具函数 ============
function prefersReducedMotion() { function prefersReducedMotion(): boolean {
return ( return (
window.matchMedia && !!window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches window.matchMedia("(prefers-reduced-motion: reduce)").matches
); );
} }
@ -14,7 +49,7 @@
// 统一映射成 {x,y,w,h}getBoundingClientRect 返回的 DOMRect 用 // 统一映射成 {x,y,w,h}getBoundingClientRect 返回的 DOMRect 用
// left/top/width/height而 fitCentered/transformFor 用 x/y/w/h // left/top/width/height而 fitCentered/transformFor 用 x/y/w/h
// 这里转成一致格式,避免 .w 读到 undefined。 // 这里转成一致格式,避免 .w 读到 undefined。
function rectOf(el) { function rectOf(el: Element): Rect {
var r = el.getBoundingClientRect(); var r = el.getBoundingClientRect();
return { return {
x: r.left, x: r.left,
@ -24,62 +59,27 @@
}; };
} }
// 计算图片在视口居中、contain 适配后的目标 rect。 // 注意fitCentered / transformFor / originalUrl 已抽到 ./geometry.ts见 Task 2
// 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见 CSStranslate 到 rect 左上角后 scale。
// - 居中态scale=1base 就是居中尺寸)
// - 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 容器初始化高清图懒加载。 // 为单个 .blur-img 容器初始化高清图懒加载。
// IO 进入视口后把 data-src 写入 src加载完成加 is-loaded 触发 CSS 淡入。 // IO 进入视口后把 data-src 写入 src加载完成加 is-loaded 触发 CSS 淡入。
function initLazyLoad(container) { function initLazyLoad(container: Element): void {
var fullImg = container.querySelector(".blur-img-full"); var raw = container.querySelector(".blur-img-full");
if (!fullImg) return; if (!(raw instanceof HTMLImageElement)) return;
// 用 const + 显式类型锁住窄化结果var 在闭包内会被放宽回 Element | null
// 导致 onFullLoaded/IntersectionObserver 回调里访问 .style/.src 报错。
var fullImg: HTMLImageElement = raw;
if (container.getAttribute("data-blur-init")) return; if (container.getAttribute("data-blur-init")) return;
container.setAttribute("data-blur-init", "true"); container.setAttribute("data-blur-init", "true");
var fullSrc = fullImg.getAttribute("data-src"); var rawSrc = fullImg.getAttribute("data-src");
if (!fullSrc) return; if (!rawSrc) return;
// 同上const 锁住 string 窄化,避免闭包内放宽回 string | null。
var fullSrc: string = rawSrc;
var onFullLoaded = function () { var onFullLoaded = function (): void {
// 给容器加 is-loadedCSS 据此显式隐藏 placeholder。 // 给容器加 is-loadedCSS 据此显式隐藏 placeholder。
// 直接把 full 层 opacity 设为 1清掉 transition不依赖 CSS 的 opacity // 直接把 full 层 opacity 设为 1清掉 transition不依赖 CSS 的 opacity
// 过渡:合成层重绘时机不稳定,可能导致 full 层卡在 opacity:0直到一次 // 过渡:合成层重绘时机不稳定,可能导致 full 层卡在 opacity:0直到一次
@ -97,8 +97,8 @@
if ("IntersectionObserver" in window) { if ("IntersectionObserver" in window) {
var io = new IntersectionObserver( var io = new IntersectionObserver(
function (entries) { function (entries: IntersectionObserverEntry[]): void {
entries.forEach(function (entry) { entries.forEach(function (entry): void {
if (entry.isIntersecting) { if (entry.isIntersecting) {
fullImg.src = fullSrc; fullImg.src = fullSrc;
io.unobserve(container); io.unobserve(container);
@ -117,13 +117,14 @@
// 收集所有 selectors命中的 .blur-img 节点。 // 收集所有 selectors命中的 .blur-img 节点。
// gallery: 正文图组成图集singles: 带 lightbox-single class 的单张图(如封面)。 // gallery: 正文图组成图集singles: 带 lightbox-single class 的单张图(如封面)。
function collectImages(roots) { function collectImages(roots: Element[]): { gallery: HTMLElement[]; singles: HTMLElement[] } {
var gallery = []; var gallery: HTMLElement[] = [];
var singles = []; var singles: HTMLElement[] = [];
for (var i = 0; i < roots.length; i++) { for (var i = 0; i < roots.length; i++) {
var nodes = roots[i].querySelectorAll(".blur-img"); var nodes = roots[i].querySelectorAll(".blur-img");
for (var j = 0; j < nodes.length; j++) { for (var j = 0; j < nodes.length; j++) {
var n = nodes[j]; var n = nodes[j];
if (!(n instanceof HTMLElement)) continue;
if (n.classList.contains("lightbox-single")) { if (n.classList.contains("lightbox-single")) {
singles.push(n); singles.push(n);
} else { } else {
@ -137,16 +138,16 @@
// ============ 灯箱状态与开/关 ============ // ============ 灯箱状态与开/关 ============
// 当前灯箱状态(同时只允许一个灯箱)。 // 当前灯箱状态(同时只允许一个灯箱)。
var state = null; var state: LightboxState | null = null;
function openLightbox(originNode, gallery, index) { function openLightbox(originNode: HTMLElement, gallery: HTMLElement[], index: number | null): void {
if (state) closeLightbox(true); if (state) closeLightbox(true);
var fullImg = originNode.querySelector(".blur-img-full"); var fullImgEl = originNode.querySelector(".blur-img-full");
if (!fullImg) return; if (!(fullImgEl instanceof HTMLImageElement)) return;
var dataSrc = fullImg.getAttribute("data-src") || ""; var dataSrc = fullImgEl.getAttribute("data-src") || "";
var origSrc = originalUrl(dataSrc); var origSrc = originalUrl(dataSrc);
var altText = fullImg.getAttribute("alt") || ""; var altText = fullImgEl.getAttribute("alt") || "";
var isSingle = var isSingle =
originNode.classList.contains("lightbox-single") || originNode.classList.contains("lightbox-single") ||
gallery.length === 0; gallery.length === 0;
@ -183,12 +184,12 @@
if (isSingle || gallery.length === 0) { if (isSingle || gallery.length === 0) {
counter.style.display = "none"; counter.style.display = "none";
} else { } else {
counter.textContent = index + 1 + " / " + gallery.length; counter.textContent = (index ?? 0) + 1 + " / " + gallery.length;
} }
// 图集模式(>1 张)才加左右导航箭头;单张不显示。 // 图集模式(>1 张)才加左右导航箭头;单张不显示。
var prevBtn = null; var prevBtn: HTMLButtonElement | null = null;
var nextBtn = null; var nextBtn: HTMLButtonElement | null = null;
if (!isSingle && gallery.length > 1) { if (!isSingle && gallery.length > 1) {
prevBtn = document.createElement("button"); prevBtn = document.createElement("button");
prevBtn.className = "lightbox-nav lightbox-prev"; prevBtn.className = "lightbox-nav lightbox-prev";
@ -236,7 +237,7 @@
bindInteractions(); bindInteractions();
// 图片加载后再做动画naturalW/H 要等加载) // 图片加载后再做动画naturalW/H 要等加载)
var start = function () { var start = function (): void {
if (!state) return; // 加载前可能已被关闭 if (!state) return; // 加载前可能已被关闭
var naturalW = img.naturalWidth || img.clientWidth || 1; var naturalW = img.naturalWidth || img.clientWidth || 1;
var naturalH = img.naturalHeight || img.clientHeight || 1; var naturalH = img.naturalHeight || img.clientHeight || 1;
@ -265,7 +266,7 @@
img.style.top = "0"; img.style.top = "0";
overlay.style.opacity = "0"; overlay.style.opacity = "0";
// 下一帧淡入 // 下一帧淡入
requestAnimationFrame(function () { requestAnimationFrame(function (): void {
if (!state) return; if (!state) return;
overlay.style.transition = "opacity 200ms ease-out"; overlay.style.transition = "opacity 200ms ease-out";
img.style.transition = "opacity 200ms ease-out"; img.style.transition = "opacity 200ms ease-out";
@ -287,9 +288,9 @@
void img.offsetHeight; void img.offsetHeight;
// double-rAF第一帧绘制首帧无动画第二帧才启动 transition 到居中。 // double-rAF第一帧绘制首帧无动画第二帧才启动 transition 到居中。
requestAnimationFrame(function () { requestAnimationFrame(function (): void {
if (!state) return; if (!state) return;
requestAnimationFrame(function () { requestAnimationFrame(function (): void {
if (!state) return; if (!state) return;
img.style.transition = img.style.transition =
"transform 250ms ease-out, opacity 250ms ease-out"; "transform 250ms ease-out, opacity 250ms ease-out";
@ -309,7 +310,7 @@
img.src = origSrc; img.src = origSrc;
} }
function closeLightbox(immediate) { function closeLightbox(immediate: boolean): void {
if (!state || state.closing) return; if (!state || state.closing) return;
state.closing = true; state.closing = true;
cleanupInteractions(); cleanupInteractions();
@ -334,27 +335,29 @@
s.img.style.opacity = "0"; s.img.style.opacity = "0";
s.overlay.style.opacity = "0"; s.overlay.style.opacity = "0";
var done = function () { var done = function (): void {
removeOverlay(); removeOverlay();
}; };
// 250ms 兜底,避免 transitionend 不触发 // 250ms 兜底,避免 transitionend 不触发
var timer = setTimeout(done, 280); var timer = setTimeout(done, 280);
s.img.addEventListener("transitionend", function () { clearTimeout(timer); done(); }, { once: true }); s.img.addEventListener("transitionend", function (): void { clearTimeout(timer); done(); }, { once: true });
} }
function removeOverlay() { function removeOverlay(): void {
if (!state) return; if (!state) return;
var prev = state.originNode; var prev = state.originNode;
if (state.overlay && state.overlay.parentNode) { if (state.overlay && state.overlay.parentNode) {
state.overlay.parentNode.removeChild(state.overlay); state.overlay.parentNode.removeChild(state.overlay);
} }
state = null; state = null;
// 焦点归还:.blur-img 是 span 不可聚焦,让其内部 full img 获得焦点 // 焦点归还:.blur-img 是 span 不可聚焦,让其内部 full img 获得焦点。
// 用 preventScroll 抑制 focus() 默认的 scrollIntoView 行为——否则关闭灯箱后
// 页面会自动滚动把原图完整纳入视口(用户只点了一半露出的图时尤其明显)。
if (prev) { if (prev) {
var f = prev.querySelector(".blur-img-full"); var f = prev.querySelector(".blur-img-full");
if (f) { if (f instanceof HTMLImageElement) {
f.setAttribute("tabindex", "-1"); f.setAttribute("tabindex", "-1");
f.focus(); f.focus({ preventScroll: true });
} }
} }
} }
@ -362,7 +365,7 @@
// ============ 图集切换 ============ // ============ 图集切换 ============
// 图集切换淡入淡出不飞行。newIndex 循环(首尾衔接)。 // 图集切换淡入淡出不飞行。newIndex 循环(首尾衔接)。
function gotoIndex(newIndex) { function gotoIndex(newIndex: number): void {
if (!state || state.isSingle) return; if (!state || state.isSingle) return;
var s = state; var s = state;
if (!s.gallery || s.gallery.length === 0) return; if (!s.gallery || s.gallery.length === 0) return;
@ -371,19 +374,19 @@
if (newIndex === s.index) return; if (newIndex === s.index) return;
var newNode = s.gallery[newIndex]; var newNode = s.gallery[newIndex];
var fullImg = newNode.querySelector(".blur-img-full"); var fullImgEl = newNode.querySelector(".blur-img-full");
if (!fullImg) return; if (!(fullImgEl instanceof HTMLImageElement)) return;
var origSrc = originalUrl(fullImg.getAttribute("data-src") || ""); var origSrc = originalUrl(fullImgEl.getAttribute("data-src") || "");
var altText = fullImg.getAttribute("alt") || ""; var altText = fullImgEl.getAttribute("alt") || "";
// 淡出当前图 // 淡出当前图
s.img.style.transition = "opacity 150ms ease-out"; s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "0"; s.img.style.opacity = "0";
// 150ms 后换图淡入 // 150ms 后换图淡入
var swap = function () { var swap = function (): void {
if (!state) return; // 切换中可能已关闭 if (!state) return; // 切换中可能已关闭
var fade = function () { var fade = function (): void {
if (!state) return; if (!state) return;
s.img.style.transition = "opacity 150ms ease-out"; s.img.style.transition = "opacity 150ms ease-out";
s.img.style.opacity = "1"; s.img.style.opacity = "1";
@ -409,18 +412,18 @@
// ============ 交互绑定 ============ // ============ 交互绑定 ============
function bindInteractions() { function bindInteractions(): void {
var s = state; var s = state;
if (!s) return; if (!s) return;
// 点背景关闭(点图片本身不关,因箭头在图上、避免误关) // 点背景关闭(点图片本身不关,因箭头在图上、避免误关)
s.overlay.addEventListener("click", function (ev) { s.overlay.addEventListener("click", function (ev: MouseEvent): void {
if (state && ev.target === state.overlay) closeLightbox(false); if (state && ev.target === state.overlay) closeLightbox(false);
}); });
// 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。 // 滚动驱动关闭:任何 scroll 都触发,用 scrollY 偏移算进度。
// 关键:逐帧读 originNode 实时 rect文章滚多少图就回多少。 // 关键:逐帧读 originNode 实时 rect文章滚多少图就回多少。
s.scrollHandler = function () { s.scrollHandler = function (): void {
if (!state) return; if (!state) return;
var st = state; var st = state;
if (st.closing) return; if (st.closing) return;
@ -430,13 +433,14 @@
closeLightbox(true); closeLightbox(true);
return; return;
} }
var progress = Math.min(dy / 120, 1);
var target = st.target; var target = st.target;
var baseW = st.baseW || (target ? target.w : 1); var baseW = st.baseW || (target ? target.w : 1);
var baseH = st.baseH || (target ? target.h : 1); var baseH = st.baseH || (target ? target.h : 1);
var originRect = rectOf(st.originNode); var originRect = rectOf(st.originNode);
// 在 originRect 与居中 target 之间按 progress 线性插值 // 在 originRect 与居中 target 之间按 progress 线性插值
var cur = { var progress = Math.min(dy / 120, 1);
if (!target) return; // 无 target 时不插值(忠实原 JS 的兜底语义)
var cur: Rect = {
x: target.x + (originRect.x - target.x) * progress, x: target.x + (originRect.x - target.x) * progress,
y: target.y + (originRect.y - target.y) * progress, y: target.y + (originRect.y - target.y) * progress,
w: target.w + (originRect.w - target.w) * progress, w: target.w + (originRect.w - target.w) * progress,
@ -456,17 +460,17 @@
window.addEventListener("scroll", s.scrollHandler, { passive: true }); window.addEventListener("scroll", s.scrollHandler, { passive: true });
// 键盘Esc 关;图集模式 ←→ 切换 // 键盘Esc 关;图集模式 ←→ 切换
s.keyHandler = function (ev) { s.keyHandler = function (ev: KeyboardEvent): void {
if (!state) return; if (!state) return;
if (ev.key === "Escape") { if (ev.key === "Escape") {
closeLightbox(false); closeLightbox(false);
} else if (!state.isSingle && state.gallery.length > 1) { } else if (!state.isSingle && state.gallery.length > 1) {
if (ev.key === "ArrowLeft") { if (ev.key === "ArrowLeft") {
ev.preventDefault(); ev.preventDefault();
gotoIndex(state.index - 1); gotoIndex((state.index ?? 0) - 1);
} else if (ev.key === "ArrowRight") { } else if (ev.key === "ArrowRight") {
ev.preventDefault(); ev.preventDefault();
gotoIndex(state.index + 1); gotoIndex((state.index ?? 0) + 1);
} }
} }
}; };
@ -474,20 +478,20 @@
// 图集导航箭头点击stopPropagation 防止冒泡到 overlay 触发关闭) // 图集导航箭头点击stopPropagation 防止冒泡到 overlay 触发关闭)
if (s.prevBtn) { if (s.prevBtn) {
s.prevBtn.addEventListener("click", function (ev) { s.prevBtn.addEventListener("click", function (ev: MouseEvent): void {
ev.stopPropagation(); ev.stopPropagation();
if (state) gotoIndex(state.index - 1); if (state) gotoIndex((state.index ?? 0) - 1);
}); });
} }
if (s.nextBtn) { if (s.nextBtn) {
s.nextBtn.addEventListener("click", function (ev) { s.nextBtn.addEventListener("click", function (ev: MouseEvent): void {
ev.stopPropagation(); ev.stopPropagation();
if (state) gotoIndex(state.index + 1); if (state) gotoIndex((state.index ?? 0) + 1);
}); });
} }
} }
function cleanupInteractions() { function cleanupInteractions(): void {
if (!state) return; if (!state) return;
if (state.scrollHandler) { if (state.scrollHandler) {
window.removeEventListener("scroll", state.scrollHandler); window.removeEventListener("scroll", state.scrollHandler);
@ -501,10 +505,10 @@
// ============ 初始化入口 ============ // ============ 初始化入口 ============
window.__initLightbox = function (selectors) { window.__initLightbox = function (selectors: string | string[]): void {
// selectors 可以是字符串、字符串数组 // selectors 可以是字符串、字符串数组
var sels = Array.isArray(selectors) ? selectors : [selectors]; var sels = Array.isArray(selectors) ? selectors : [selectors];
var roots = []; var roots: Element[] = [];
for (var i = 0; i < sels.length; i++) { for (var i = 0; i < sels.length; i++) {
var found = document.querySelectorAll(sels[i]); var found = document.querySelectorAll(sels[i]);
for (var j = 0; j < found.length; j++) roots.push(found[j]); for (var j = 0; j < found.length; j++) roots.push(found[j]);
@ -520,8 +524,8 @@
// 正文图:带 index // 正文图:带 index
var gallery = collected.gallery; var gallery = collected.gallery;
for (var g = 0; g < gallery.length; g++) { for (var g = 0; g < gallery.length; g++) {
(function (node, idx) { (function (node: HTMLElement, idx: number): void {
node.addEventListener("click", function (e) { node.addEventListener("click", function (e: MouseEvent): void {
e.preventDefault(); e.preventDefault();
openLightbox(node, gallery, idx); openLightbox(node, gallery, idx);
}); });
@ -529,12 +533,22 @@
} }
// 单张图封面index = nullgallery 传空数组表示单张 // 单张图封面index = nullgallery 传空数组表示单张
for (var si = 0; si < collected.singles.length; si++) { for (var si = 0; si < collected.singles.length; si++) {
(function (node) { (function (node: HTMLElement): void {
node.addEventListener("click", function (e) { node.addEventListener("click", function (e: MouseEvent): void {
e.preventDefault(); e.preventDefault();
openLightbox(node, [], null); openLightbox(node, [], null);
}); });
})(collected.singles[si]); })(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);
}
})(); })();

View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View 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,
},
});

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});

View File

@ -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! {