Files
laoyang f3583e07c5 perf(lightbox): 点开图片不再白屏干等,先糊后清 #10
用户反馈:点一张图之后先是白背景渲染出来,图片迟迟不出现,感觉特别慢。

实测归因(_v40_slow.py,自建慢速 HTTP 服务,3.2 Mbps):
  · 遮罩可见 31ms      —— 纯 CSS 底色,一帧就画完,从来不是瓶颈
  · 图片有像素 6 秒后仍然没有
线上原图最大 3.2MB(PUA的两面性与界定-scaled.png),3Mbps 下要 8 秒以上,
这段时间屏幕上是整片白 + 一个 0x0 的空 img。

三层改造:
1. 缩略图占位(blur-up):点开瞬间把正文里已经解码过的 currentSrc 顶上,
   0 延迟就有像素;原图到了再淡入覆盖。
2. 原图淡入:is-ready 后 opacity 0→1,避免看到渐进渲染的半张图。
3. 空闲预取:requestIdleCallback 里串行预取视口上下 1.5 屏内的原图,
   用 new Image() 不塞 DOM;尊重 saveData / slow-2g。

CSS 的 .yang-lb-stage 从 flex 改 grid,占位层/原图/转圈都 grid-area:1/1
叠在同一个格子(flex 会让子元素并排,absolute 又不参与居中)。

实测(_v40_opt2.py,5/0):
  立刻点(最坏)  遮罩 25ms · 首帧有像素 25ms · 原图 15012ms
  读 12 秒后点    遮罩 25ms · 首帧有像素 25ms · 原图 25ms(预取命中)
服务端各收到 1 次请求,预取一次命中、没有重复下载。

验证:交互 245/0(新增 J 段占位层与加载态 45 条)· 服务端 55/0(8.4+8.5)
· 演示页 34/0 · 反向验证 27/27(新增 U11~U14)

Refs #10
2026-10-25 23:03:13 +08:00

414 lines
15 KiB
CSS
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* 图片灯箱样式
* Yongquan Yang <yangyq.net>
*
* ── 配色为什么做成两套 ──────────────────────────────────────
* 深色底适合看照片:周围全黑时人眼对图里颜色的判断更准,也不会
* 被白底反射光干扰。浅色底适合看截图和文档图:黑底会让白底截图的
* 边缘发灰、像蒙了一层雾,而且深色底下一张 A4 白文档刺眼。
* 用户明确说「哪种效果好看」,所以两套都做出来、后台可选。
*
* ── 为什么用 CSS 变量 ───────────────────────────────────────
* 两套配色只差几个颜色值,用变量可以只写一遍布局。
* 注意:主题**没有**全局 CSS 变量(主色各处硬编码),所以这里的
* 变量全部**定义在 .yang-lb 自己身上**,不依赖外部。
*
* ── 层级 ────────────────────────────────────────────────────
* z-index 取 99999:主题导航栏用的是 1000 级别,layer.js 弹层是
* 19891015 以下。灯箱必须盖住**所有**内容(用户要求「把页面其他
* 内容都盖住」),所以取一个明显高于常规的整数,同时避开 2147483647
* 这类极端值(部分浏览器上会有渲染 bug)。
*/
.yang-lb {
/* ── 浅色(默认)────────────────────────────────────────────
* 用户明确选了白色底,并要求遮罩**半透明**(约 80%),
* 不把底下正文完全盖死,而是「压暗一档、突出照片」。
*
* 🔴 半透明带来的真问题(必须一起解决,否则一开就废):
* 透明度 < 1 时,遮罩颜色会与**底下透上来的正文**复合。
* 本站正文是白底黑字,透上来的是白 + 黑字。
* · 白底透上来 → 遮罩几乎还是白,底部灰字(--lb-fg-dim)对比度够;
* · 但**黑字/深色图片块透上来**时,那一片会变暗,
* 灰字压在上面就可能掉到 4.5:1 以下,看不清「3 / 4」。
* 所以不能只调 alpha 就完事:底部说明条与控件必须自己
* 带一块**更实的底**(见 --lb-ctl-bg 与 .yang-lb-foot),
* 让文字永远有确定的可读底色,不依赖底下恰好是什么。
*
* ⚠️ 透明度用「白色 80% 不透明」= rgba(255,255,255,0.82),
* 不是 `opacity: 0.82` —— 用 opacity 会让**整层**(含图片、
* 箭头、文字)一起变半透明,图片会跟着发灰、看不清,
* 这不是用户要的效果。用户要的是「底透、图清楚」。
*/
--lb-bg: rgba(255, 255, 255, 0.82);
--lb-fg: #1f2430;
--lb-fg-dim: rgba(31, 36, 48, 0.76);
/* 控件底必须**接近不透明**:它盖在图片上方,底下可能是任意颜色 */
--lb-ctl-bg: rgba(255, 255, 255, 0.94);
--lb-ctl-bg-hover: #ffffff;
--lb-ctl-bd: rgba(31, 36, 48, 0.16);
--lb-ctl-fg: #1f2430;
--lb-shadow: 0 10px 40px rgba(31, 36, 48, 0.26);
/* 底部说明条自己的底色:比遮罩更实,保证文字可读 */
--lb-foot-bg: rgba(255, 255, 255, 0.9);
position: fixed;
inset: 0;
z-index: 99999;
display: none;
align-items: center;
justify-content: center;
background-color: var(--lb-bg);
/* 背景模糊:让底下页面「退后」但不完全消失,用户仍知道自己没离开。
半透明遮罩下这一条更重要 —— 它把底下文字糊掉,
减少「字压在图上」的视觉干扰,同时保住「我还在这页」的空间感。 */
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
color: var(--lb-fg);
/* 覆盖层自己吃掉所有指针事件,底下的链接不会被误点 */
overscroll-behavior: contain;
opacity: 0;
transition: opacity 0.18s ease;
}
/* ── 深色遮罩(可选)─────────────────────────────────────────
* 看照片时用这套:周围全黑,人眼对图里颜色的判断更准,
* 不会被白底反射光干扰。透明度同样留一点,不全盖死。
*/
.yang-lb[data-theme='dark'] {
--lb-bg: rgba(12, 14, 18, 0.86);
--lb-fg: #f2f4f7;
--lb-fg-dim: rgba(242, 244, 247, 0.7);
--lb-ctl-bg: rgba(40, 44, 52, 0.94);
--lb-ctl-bg-hover: rgba(58, 63, 73, 0.98);
--lb-ctl-bd: rgba(255, 255, 255, 0.2);
--lb-ctl-fg: #f2f4f7;
/* 图片外发光:深色底上没有它,深色图片的边界会糊在背景里 */
--lb-shadow: 0 12px 48px rgba(0, 0, 0, 0.62);
--lb-foot-bg: rgba(12, 14, 18, 0.9);
}
.yang-lb.is-open {
display: flex;
opacity: 1;
}
/* 覆盖层打开时禁止底部页面滚动。
⚠️ 不用 `body { overflow: hidden }` 写死在 CSS 里:那样关闭后
恢复不可控(要和 JS 里记住的原值配合)。JS 负责设置,
这里只兜一层「万一 JS 没设」的情况。 */
body.yang-lb-lock {
overflow: hidden;
}
/* ── 图片舞台 ─────────────────────────────────────────────── */
.yang-lb-stage {
/* 🔴 这里从 flex 改成 grid:要让「占位图」和「原图」**叠在同一个格子**里。
用 flex 的话两个子元素会并排(一个在左一个在右),必须绝对定位才能叠,
而 absolute 就不参与居中 → 又要手动算偏移,脆且难调。
grid-area 叠放是最省事的:两图都居中,cell 尺寸由两者较大者决定。 */
display: grid;
place-items: center;
/* 给左右箭头留出空间,避免大图被箭头压住 */
width: calc(100% - 132px);
height: calc(100% - 96px);
pointer-events: none;
}
/* ── 两层图片:占位层 + 原图层 ─────────────────────────────
*
* ── 为什么要两层 ────────────────────────────────────────────
* 用户反馈:「点一张图之后,先把这个白色背景渲染出来,然后图再出来,
* 感觉速度特别慢」。实测(_v40_slow.py,自建慢速 HTTP 服务):
*
* 遮罩可见 31 ms ← 纯 CSS 底色,一帧就画完,从来不是瓶颈
* 图片有像素 6 秒后仍然没有
*
* 线上原图实测最大 3.2 MB(`PUA的两面性与界定-scaled.png`),
* 3 Mbps 下要 8 秒以上。这 8 秒里屏幕上是**整片白 + 一个 0×0 的空 img**,
* 用户看到的就是「白背景先出来,图后到」。
*
* 解法:点开的瞬间先把**正文里那张已经在缓存里的缩略图**放上去。
* 它早就下载并解码过了,0 延迟就有像素 —— 屏幕立刻「有东西」,
* 只是糊一点;原图到了再淡入覆盖。这是业界通行的 blur-up,
* 视觉上是「先糊后清」的连续过程,而不是「先空后突然出现」。
*
* ⚠️ 占位图必须 `pointer-events: none`:它只是一层视觉垫底,
* 点击应该落在原图上(点图不关闭),不能被垫底挡住。
*/
.yang-lb-ph,
.yang-lb-img {
/* 叠在同一个格子里 —— 这是两层方案能成立的关键一行 */
grid-area: 1 / 1;
display: block;
max-width: 100%;
max-height: 100%;
/* 原图可能小于屏幕:不要为了填满而放大,放大只会变糊 */
width: auto;
height: auto;
object-fit: contain;
}
.yang-lb-ph {
/* 垫底:放大后自然发糊,不额外加 blur 滤镜
(滤镜在大图上要额外一次全图离屏合成,慢网下雪上加霜) */
pointer-events: none;
/* 没拿到缩略图时(比如外链图床)不留一个空框 */
display: none;
}
.yang-lb-ph.is-on {
display: block;
}
.yang-lb-img {
position: relative; /* 压住占位层,确保点击落在原图上 */
z-index: 1;
box-shadow: var(--lb-shadow);
border-radius: 2px;
background-color: transparent;
/* 图片本身要能接收点击(点图不关闭),但舞台是 none,
所以这里必须显式改回来 */
pointer-events: auto;
/* 原图未就绪时先隐藏:直接显示会看到「半张图」的渐进渲染过程。
用 opacity 而不是 display,是为了让它**参与布局**,
该占的尺寸提前占住,避免 ready 那一刻整层跳动。 */
opacity: 0;
}
/* 原图已解码完成 → 淡入覆盖占位图 */
.yang-lb-img.is-ready {
opacity: 1;
transition: opacity 0.18s ease;
}
/* ⚠️ 占位图在原图就绪后不能立刻 display:none —— 那样 cell 尺寸会
瞬间由「占位图尺寸」变成「原图尺寸」,如果两者不等就有一次跳动。
让它淡出,淡出期间 cell 不会变(两个都在布局里)。 */
.yang-lb-ph.is-fade {
opacity: 0;
transition: opacity 0.18s ease;
}
/* ── 加载指示:慢网下必须让用户知道「在加载」而不是「坏了」─────
* 3.2 MB 的原图在 3 Mbps 下要 8 秒。这 8 秒里如果只是「糊图慢慢等」,
* 用户会怀疑是不是点坏了。一个很轻的脉冲点就够表达「还在来」。
* 用 animationend 之外的纯 CSS,不额外占 JS。 */
.yang-lb-spin {
grid-area: 1 / 1;
z-index: 2;
width: 34px;
height: 34px;
border-radius: 50%;
border: 2px solid var(--lb-ctl-bd);
border-top-color: var(--lb-fg);
animation: yang-lb-rot 0.8s linear infinite;
display: none;
pointer-events: none;
}
.yang-lb.is-loading .yang-lb-spin {
display: block;
}
@keyframes yang-lb-rot {
to { transform: rotate(360deg); }
}
/* ── 左右箭头 ─────────────────────────────────────────────── */
.yang-lb-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 52px;
height: 52px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--lb-ctl-bd);
border-radius: 50%;
background-color: var(--lb-ctl-bg);
color: var(--lb-ctl-fg);
cursor: pointer;
/* 触屏上不要 300ms 延迟 */
touch-action: manipulation;
transition: background-color 0.15s ease, transform 0.15s ease;
}
.yang-lb-prev { left: 24px; }
.yang-lb-next { right: 24px; }
.yang-lb-nav:hover {
background-color: var(--lb-ctl-bg-hover);
}
.yang-lb-nav:active {
transform: translateY(-50%) scale(0.94);
}
.yang-lb-nav:focus-visible,
.yang-lb-close:focus-visible {
outline: 2px solid #00a2ff;
outline-offset: 2px;
}
/* ── 关闭按钮 ─────────────────────────────────────────────── */
.yang-lb-close {
position: absolute;
top: 18px;
right: 18px;
width: 44px;
height: 44px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--lb-ctl-bd);
border-radius: 50%;
background-color: var(--lb-ctl-bg);
color: var(--lb-ctl-fg);
cursor: pointer;
touch-action: manipulation;
transition: background-color 0.15s ease, transform 0.15s ease;
}
.yang-lb-close:hover {
background-color: var(--lb-ctl-bg-hover);
transform: rotate(90deg);
}
/* ── 底部说明与计数 ───────────────────────────────────────── */
/* 🔴 这一条是「半透明遮罩」逼出来的改动。
原来用 `linear-gradient(to top, var(--lb-bg) 30%, transparent)`:
遮罩不透明时看着没问题,但遮罩只有 80% 时,
渐变到 transparent 的那一段**完全没有底**,
说明文字会直接压在透上来的正文上 —— 底下是深色图片或粗标题时
就彻底读不清了。
改成「一块实底 + 上沿渐变过渡」,文字永远落在确定底色上。 */
.yang-lb-foot {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 14px 76px 18px;
display: flex;
align-items: baseline;
justify-content: center;
gap: 14px;
background-color: var(--lb-foot-bg);
/* 上沿做一小段渐变,避免出现一条生硬的分界线 */
box-shadow: inset 0 1px 0 var(--lb-ctl-bd);
pointer-events: none;
}
.yang-lb-cap {
font-size: 13px;
line-height: 1.5;
color: var(--lb-fg-dim);
max-width: 76%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.yang-lb-count {
font-size: 13px;
line-height: 1.5;
color: var(--lb-fg-dim);
font-variant-numeric: tabular-nums;
flex: none;
}
/* ── 正文里「可点」的视觉提示 ─────────────────────────────── */
/* 默认加一个放大光标。不用「图片上盖一个放大镜图标」那种做法:
它会在每张图角落加一个浮层,正文一多图就显得很脏。 */
.content a.yang-lb-item {
cursor: zoom-in;
display: inline-block;
/* <a> 默认是 inline,包着块级图时会让父级出现基线空隙 */
line-height: 0;
}
.content a.yang-lb-item > img {
transition: opacity 0.15s ease;
}
.content a.yang-lb-item:hover > img {
opacity: 0.9;
}
/* ── 窄屏 ─────────────────────────────────────────────────── */
@media (max-width: 720px) {
.yang-lb-stage {
/* 手机上左右箭头改成半透明压在图上,图片尽量占满宽 */
width: 100%;
height: calc(100% - 88px);
padding: 0 8px;
}
.yang-lb-nav {
width: 42px;
height: 42px;
/* 压到图上,给图片腾出宽度。
⚠️ 这里必须用**接近不透明**的底:箭头此刻直接压在图片上,
底下可能是任意颜色(白纸扫图 / 纯黑夜景都有可能)。
半透明底会随图片内容变化,对比度不可控。
用 0.88 的实底,两套配色各自保证 ≥ 3:1。 */
background-color: rgba(255, 255, 255, 0.88);
border-color: rgba(31, 36, 48, 0.18);
color: #1f2430;
}
.yang-lb[data-theme='dark'] .yang-lb-nav {
background-color: rgba(24, 27, 33, 0.88);
border-color: rgba(255, 255, 255, 0.24);
color: #f2f4f7;
}
.yang-lb-prev { left: 6px; }
.yang-lb-next { right: 6px; }
.yang-lb-foot {
padding: 10px 16px 14px;
flex-direction: column;
align-items: center;
gap: 2px;
}
.yang-lb-cap {
max-width: 100%;
font-size: 12px;
}
}
/* ── 尊重「减少动态效果」系统设置 ─────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.yang-lb,
.yang-lb-img,
.yang-lb-ph,
.yang-lb-nav,
.yang-lb-close {
transition: none;
animation: none;
}
/* 转圈是唯一的「还在加载」提示,减少动效下不能整个删掉,
否则慢网用户看不到任何反馈。改成静止的文字色圆环(不那么晃眼)。 */
.yang-lb-spin {
animation: none;
border-top-color: var(--lb-ctl-bd);
border-right-color: var(--lb-fg);
}
.yang-lb-close:hover {
transform: none;
}
}