fix(lightbox): 回 1x 自动归中+平移四向钳制(用户实测 bug)

用户实测:缩放回 100% 图片不回中显示不全;另放大态可拖飞出舞台。
三修:zoomAt next<=1 归零 translate;applyZoom 平移钳制 |t|≤尺
寸差/2(放大态不变量=图盒覆盖舞台而非缩在视口内);滚轮下限
0.5x→1x(fit 即最小,0.5x 制造偏移态)。mockup 同步三修。

验证: 回 1x transform=none 实测;四角极限拖拽覆盖舞台不变量
全过;E2E 34/34

Spec: none (用户实测 bug 热修,线上 v2+mockup 双修)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-01 12:34:11 +08:00
1 parent 54769bcfdf
commit 44626f8edc
2 files changed
+111 -14

No files matched your search

+6 -2
View File
@@ -238,7 +238,11 @@ function show(i,dir){
}
/* ===== 缩放:滚轮锚点 / 双击 / 双指 / 拖拽(复用线上 v2 数学) ===== */
function applyZoom(){pic.style.transform=`translate(${zoom.tx}px,${zoom.ty}px) scale(${zoom.s})`;
function clampPan(){const mx=Math.max(0,pic.clientWidth*(zoom.s-1)/2),
my=Math.max(0,pic.clientHeight*(zoom.s-1)/2);
zoom.tx=Math.min(mx,Math.max(-mx,zoom.tx));zoom.ty=Math.min(my,Math.max(-my,zoom.ty));}
function applyZoom(){clampPan();
pic.style.transform=zoom.s===1?'none':`translate(${zoom.tx}px,${zoom.ty}px) scale(${zoom.s})`;
pic.classList.toggle('zoomed',zoom.s!==1);
lbx.classList.toggle('clean',zoom.s!==1);}
function instantResetZoom(){const keep=pic.style.transition;pic.style.transition='none';
@@ -246,7 +250,7 @@ function instantResetZoom(){const keep=pic.style.transition;pic.style.transition
lbx.classList.remove('clean');void pic.offsetWidth;pic.style.transition=keep||'';}
function zoomAt(next,cx,cy){const r=pic.getBoundingClientRect();
const ax=cx-r.left-r.width/2-zoom.tx,ay=cy-r.top-r.height/2-zoom.ty;
const k=next/zoom.s;zoom.tx-=ax*(k-1);zoom.ty-=ay*(k-1);zoom.s=next;applyZoom();zindShow();}
if(next<=1){zoom={s:1,tx:0,ty:0};applyZoom();zindShow();return;}const k=next/zoom.s;zoom.tx-=ax*(k-1);zoom.ty-=ay*(k-1);zoom.s=next;applyZoom();zindShow();}
let zt;function zindShow(){const z=$('zind');z.textContent=Math.round(zoom.s*100)+'%';
z.classList.add('show');clearTimeout(zt);zt=setTimeout(()=>z.classList.remove('show'),900);}
lbx.addEventListener('wheel',e=>{if(!open_)return;e.preventDefault();
+105 -12
View File
@@ -51,6 +51,7 @@ const cards = await Promise.all(
return {
group: photo.data.group,
title: photo.data.title,
caption: photo.data.caption ?? '',
alt: photo.data.alt ?? photo.data.title,
image: photo.data.image,
exif: formatExif(photo.data.exif),
@@ -92,9 +93,12 @@ const first = cards[0];
type="button"
class="shot-btn"
data-title={card.title}
data-caption={card.caption}
data-exif={card.exif}
data-full={card.fullSrc}
data-original={card.original}
data-w={card.w}
data-h={card.h}
aria-label={t('gallery_view_aria', locale).replace('{title}', card.title) +
(card.exif ? t('gallery_view_aria_exif', locale).replace('{exif}', card.exif) : '')}
>
@@ -130,10 +134,12 @@ const first = cards[0];
</svg>
</button>
<div class="lb-meta">
<strong id="lb-title">{first?.title}</strong>
<span id="lb-exif">{first?.exif}</span>
<span id="lb-count">1 / {cards.length}</span>
<span class="lb-orig">
<p id="lb-caption" class="lb-caption">{first?.caption ?? ''}</p>
<div class="lb-row">
<strong id="lb-title">{first?.title}</strong>
<span id="lb-count">1 / {cards.length}</span>
<span id="lb-exif" class="lb-exif">{first?.exif}</span>
<span class="lb-orig">
<a id="lb-original" class="lb-act" href={first?.original} target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6M21 3l-9 9M10 5H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-4"></path></svg>
{t('gallery_lightbox_original', locale)}
@@ -143,6 +149,7 @@ const first = cards[0];
{t('gallery_lightbox_download', locale)}
</a>
</span>
</div>
</div>
<button id="lb-next" class="tap lbnav" type="button" aria-label={t('gallery_lightbox_next', locale)}>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -164,9 +171,66 @@ const first = cards[0];
const img = document.getElementById('lb-img') as HTMLImageElement;
const titleEl = document.getElementById('lb-title') as HTMLElement;
const exifEl = document.getElementById('lb-exif') as HTMLElement;
const captionEl = document.getElementById('lb-caption') as HTMLElement | null;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
let idx = 0;
// ── v2 缩放态:滚轮光标锚点缩放(0.5x–5x)+ 放大后拖拽平移 + 双击 1x↔2.5x ──
const zoom = { s: 1, tx: 0, ty: 0 };
const applyZoom = () => {
// 平移钳制:缩放后的图盒不得超出舞台(|t| ≤ 尺寸差/2)——拖飞与回 1x 偏移残留同治
const mx = Math.max(0, (img.clientWidth * (zoom.s - 1)) / 2);
const my = Math.max(0, (img.clientHeight * (zoom.s - 1)) / 2);
zoom.tx = Math.min(mx, Math.max(-mx, zoom.tx));
zoom.ty = Math.min(my, Math.max(-my, zoom.ty));
img.style.transform = zoom.s === 1 ? 'none' : `translate(${zoom.tx}px, ${zoom.ty}px) scale(${zoom.s})`;
img.classList.toggle('zoomed', zoom.s !== 1);
};
const resetZoom = () => { zoom.s = 1; zoom.tx = 0; zoom.ty = 0; applyZoom(); };
const zoomAt = (next: number, cx: number, cy: number) => {
// 光标为不动点:先求图面上锚点,再按新 scale 反算平移量
const rect = img.getBoundingClientRect();
const ax = cx - rect.left - rect.width / 2 - zoom.tx;
const ay = cy - rect.top - rect.height / 2 - zoom.ty;
const k = next / zoom.s;
zoom.tx -= ax * (k - 1);
zoom.ty -= ay * (k - 1);
if (next <= 1) { zoom.s = 1; zoom.tx = 0; zoom.ty = 0; } else { zoom.s = next; }
applyZoom();
};
lb.addEventListener('wheel', (e) => {
if (!lb.open) return;
e.preventDefault(); // 滚轮在灯箱内一律缩放:含 Ctrl+滚轮(劫持页面缩放的预期在灯箱内成立)
const factor = Math.exp(-e.deltaY * 0.0015);
const next = Math.min(5, Math.max(1, zoom.s * factor)); // 下限 1:fit-contain 即最小(0.5x 制造偏移态)
if (next === zoom.s) return;
zoomAt(next, e.clientX, e.clientY);
}, { passive: false });
// 拖拽平移:仅放大态(=1 时不抢点击/翻页);pointer capture 全程跟随
let drag: { x: number; y: number; tx: number; ty: number; moved: boolean } | null = null;
img.addEventListener('pointerdown', (e) => {
if (zoom.s <= 1) return;
e.preventDefault();
img.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY, tx: zoom.tx, ty: zoom.ty, moved: false };
img.classList.add('grabbing');
});
img.addEventListener('pointermove', (e) => {
if (!drag) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
zoom.tx = drag.tx + dx;
zoom.ty = drag.ty + dy;
applyZoom();
});
const endDrag = () => { drag = null; img.classList.remove('grabbing'); };
img.addEventListener('pointerup', endDrag);
img.addEventListener('pointercancel', endDrag);
img.addEventListener('dblclick', (e) => {
if (zoom.s === 1) zoomAt(2.5, e.clientX, e.clientY);
else resetZoom();
});
const show = (i: number) => {
idx = (i + shots.length) % shots.length;
const btn = shots[idx]!;
@@ -175,6 +239,11 @@ const first = cards[0];
const title = btn.dataset.title ?? '';
titleEl.textContent = title;
titleEl.style.display = title ? '' : 'none';
if (captionEl) {
captionEl.textContent = btn.dataset.caption ?? '';
captionEl.style.display = captionEl.textContent ? '' : 'none';
}
resetZoom(); // 换图回正:缩放态不跨图残留
exifEl.textContent = btn.dataset.exif ?? '';
exifEl.style.display = btn.dataset.exif ? '' : 'none';
const countEl = document.getElementById('lb-count');
@@ -197,6 +266,7 @@ const first = cards[0];
document.getElementById('lb-prev')?.addEventListener('click', () => show(idx - 1));
document.getElementById('lb-next')?.addEventListener('click', () => show(idx + 1));
document.getElementById('lb-close')?.addEventListener('click', () => lb.close());
lb.addEventListener('close', resetZoom);
lb.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
e.preventDefault();
@@ -333,27 +403,50 @@ const first = cards[0];
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden; /* 灯箱内绝不出滚动条:图片完整可见交给 contain */
}
#lb::backdrop { background: rgba(8, 10, 14, 0.95); }
.lb-figure {
flex: 1;
min-height: 0;
display: grid;
place-items: center;
position: relative; /* img 绝对填满:grid/flex item 的 % 高存在 auto 行高循环失效坑 */
padding: var(--space-5) var(--space-3) var(--space-2);
}
#lb-img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain; /* 任意比例图在容器内完整可见,绝无溢出 */
border-radius: 6px;
transition: opacity 160ms var(--ease);
/* v2 缩放:transform 过渡仅非 reduced-motion(缩放本体是直接交互,不豁免) */
touch-action: none; /* 平移交给 pointer 事件,禁浏览器默认手势 */
will-change: transform;
cursor: zoom-in;
}
.lb-bottom {
#lb-img.zoomed { cursor: grab; }
#lb-img.grabbing { cursor: grabbing; }
@media (prefers-reduced-motion: no-preference) {
#lb-img { transition: opacity 160ms var(--ease), transform 90ms linear; }
}
/* caption 层:正文体(区别于 mono 元数据),恒深灯箱底上的浅字 */
.lb-caption {
margin: 0 0 6px;
font-family: var(--font-sans);
font-size: 13.5px;
line-height: 1.6;
color: #dfe6f3;
max-width: 640px;
}
.lb-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.lb-exif { color: #98a2b3; }
.lb-bottom {
padding: var(--space-2) var(--space-4) var(--space-4);
color: var(--code-ink);
font-family: var(--font-mono);
@@ -362,7 +455,7 @@ const first = cards[0];
.lb-orig {
display: inline-flex;
gap: var(--space-3);
margin-left: var(--space-2);
margin-left: auto; /* 胶囊组右推:行内 title/count/exif 在左 */
}
/* 动作胶囊:与 lbnav 同语言(暗底/描边/阴影),不再是裸链 */
.lb-act {