fix(lightbox): correct zoom origin, scroll-close, blur-up rendering

several bugs found via in-browser debugging, all in lightbox.js + the
blur-up CSS:

1. DOMRect attribute mismatch: getBoundingClientRect returns .width/
   .height, but code read .w/.h -> NaN transforms. map rectOf() to a
   uniform {x,y,w,h}.

2. zoom scale base was the huge original natural size (2942px), so the
   centered state collapsed to a tiny scale and the open animation ran
   backwards (shrink). base the scale on originRect instead: first
   frame is the in-article image at scale 1, centered state scales to
   target.w/originRect.w. open is always grow, close always shrink.

3. scroll-to-close fired on a phantom scroll jump: the oversized
   original img (layout size = naturalW) expanded the document's
   scrollable area. set img layout size explicitly and add
   overflow:hidden on the (position:fixed) overlay so the img cannot
   expand the body.

4. first open animation jumped from the wrong position: a single rAF
   was not enough to commit the no-transition first frame. use a forced
   reflow (offsetHeight) + double-rAF before starting the transition.

5. blur-up full layer stuck at opacity 0 until a forced reflow (opening
   the lightbox) repainted it. drop the opacity transition on .blur-img-
   full and set opacity:1 from JS on load (deterministic, no repaint
   race). mark the container .is-loaded and hide the placeholder via
   the container class.

6. a 16px grey gap above in-article images: .md-content img { margin:
   1rem 0 } applied to the absolute .blur-img-full too, pushing it
   down. reset margin:0 / max-width:none on the blur-up img layers.
This commit is contained in:
xfy 2026-06-23 17:30:37 +08:00
parent 940236d84f
commit 8bdc3553bd
2 changed files with 102 additions and 38 deletions

View File

@ -531,12 +531,17 @@
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 实现原地缩放 */
transform-origin top left配合 translate+scale 实现原地缩放
显式 width/height JS 设置为原图尺寸确保布局尺寸确定
transform 只做视觉缩放不改变占位 */
.lightbox-img {
position: absolute;
left: 0;
@ -722,9 +727,13 @@
width: 100%;
height: 100%;
object-fit: cover;
/* 重置 .md-content img 的 margin/max-width/height避免把 absolute 图层推偏 */
margin: 0;
max-width: none;
/* 20px 占位图放大后模糊scale 遮边缘 */
filter: blur(20px) saturate(1.2);
transform: scale(1.1);
transition: opacity 0.4s ease;
}
.blur-img-full {
position: absolute;
@ -732,13 +741,25 @@
width: 100%;
height: 100%;
object-fit: cover;
/* 重置 .md-content img 的 margin/max-width/height避免把 absolute 图层推偏 */
margin: 0;
max-width: none;
opacity: 0;
transition: opacity 0.4s ease;
z-index: 1;
}
.blur-img-full.is-loaded {
/* full 加载完成后由 JS 直接设 opacity:1不依赖 CSS transition
避免合成层重绘时机导致 opacity 卡在 0 */
.blur-img-full.is-loaded,
.blur-img.is-loaded .blur-img-full {
opacity: 1;
}
/* 容器标记 loaded 后:显式隐藏占位图 + 去掉灰底,避免重绘时机导致残留/灰边 */
.blur-img.is-loaded .blur-img-placeholder {
opacity: 0;
}
.blur-img.is-loaded {
background: transparent;
}
/* 暗色模式灰底 */
.dark .blur-img {

View File

@ -11,8 +11,17 @@
}
// 读取元素当前在视口里的 rect用于飞行起点/终点)。
// 统一映射成 {x,y,w,h}getBoundingClientRect 返回的 DOMRect 用
// left/top/width/height而 fitCentered/transformFor 用 x/y/w/h
// 这里转成一致格式,避免 .w 读到 undefined。
function rectOf(el) {
return el.getBoundingClientRect();
var r = el.getBoundingClientRect();
return {
x: r.left,
y: r.top,
w: r.width,
h: r.height,
};
}
// 计算图片在视口居中、contain 适配后的目标 rect。
@ -31,11 +40,14 @@
};
}
// 把目标 rect 转成 transform 字符串(基于 naturalW/H 做缩放)。
// 把目标 rect 转成 transform 字符串。
// baseW/baseH 是 img 元素的布局尺寸(=居中目标尺寸scale 相对它缩放。
// transform-origin 为 top left见 CSStranslate 到 rect 左上角后 scale。
function transformFor(rect, naturalW, naturalH) {
var sx = rect.w / naturalW;
var sy = rect.h / naturalH;
// - 居中态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 +
@ -67,9 +79,21 @@
var fullSrc = fullImg.getAttribute("data-src");
if (!fullSrc) return;
fullImg.addEventListener("load", function () {
this.classList.add("is-loaded");
});
var onFullLoaded = function () {
// 给容器加 is-loadedCSS 据此显式隐藏 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(
@ -141,6 +165,10 @@
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";
@ -211,11 +239,25 @@
var naturalH = img.naturalHeight || img.clientHeight || 1;
var originRect = rectOf(originNode);
// 基准 = originRect文章里图片的实际尺寸
// img 的布局尺寸固定为 originRecttransform 的 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";
var target = fitCentered(naturalW, naturalH, vw, vh);
img.style.transform = transformFor(target, naturalW, naturalH);
img.style.transform = transformFor(target, baseW, baseH);
img.style.left = "0";
img.style.top = "0";
overlay.style.opacity = "0";
@ -230,25 +272,30 @@
return;
}
// 首帧:放到 originRect 位置与尺寸,透明
// 首帧:文章位置 + 原尺寸scale=1透明且关闭 transition
img.style.transition = "none";
img.style.left = "0";
img.style.top = "0";
img.style.transform = transformFor(originRect, naturalW, naturalH);
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;
var tgt = fitCentered(naturalW, naturalH, vw, vh);
img.style.transition =
"transform 250ms ease-out, opacity 250ms ease-out";
overlay.style.transition = "opacity 250ms ease-out";
img.style.transform = transformFor(tgt, naturalW, naturalH);
img.style.transform = transformFor(target, baseW, baseH);
img.style.opacity = "1";
overlay.style.opacity = "1";
});
});
};
if (img.complete && img.naturalWidth) {
@ -266,8 +313,9 @@
var s = state;
var naturalW = s.img.naturalWidth || s.img.clientWidth || 1;
var naturalH = s.img.naturalHeight || s.img.clientHeight || 1;
// 基准 = 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) {
@ -275,11 +323,11 @@
return;
}
// 飞回 originRect
// 飞回 originRectscale 从 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, naturalW, naturalH);
s.img.style.transform = transformFor(originRect, baseW, baseH);
s.img.style.opacity = "0";
s.overlay.style.opacity = "0";
@ -287,8 +335,8 @@
removeOverlay();
};
// 250ms 兜底,避免 transitionend 不触发
setTimeout(done, 280);
s.img.addEventListener("transitionend", done, { once: true });
var timer = setTimeout(done, 280);
s.img.addEventListener("transitionend", function () { clearTimeout(timer); done(); }, { once: true });
}
function removeOverlay() {
@ -380,15 +428,10 @@
return;
}
var progress = Math.min(dy / 120, 1);
var naturalW = st.img.naturalWidth || st.img.clientWidth || 1;
var naturalH = st.img.naturalHeight || st.img.clientHeight || 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);
var target = fitCentered(
naturalW,
naturalH,
window.innerWidth,
window.innerHeight
);
// 在 originRect 与居中 target 之间按 progress 线性插值
var cur = {
x: target.x + (originRect.x - target.x) * progress,
@ -397,7 +440,7 @@
h: target.h + (originRect.h - target.h) * progress,
};
st.img.style.transition = "none";
st.img.style.transform = transformFor(cur, naturalW, naturalH);
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) {