Files
blog/design/lightbox-mockup.html
T
wait4xandGLM-5.3 44626f8edc 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
2026-11-01 12:34:11 +08:00

315 lines
18 KiB
HTML
Raw 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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>灯箱 v2.1 提案预览 · wait4x</title>
<style>
/* ===== 灰阶基底(对齐站 tokens.css:四档灰 + 唯一曲线族) ===== */
:root{
--bg:#fff; --bg-soft:#f7f7f8; --ink-1:#111113; --ink-2:#33343a; --ink-3:#6b6e78;
--ink-4:#a0a3ad; --line:#e6e7eb; --radius-img:10px;
--mono:"JetBrains Mono",ui-monospace,Consolas,monospace;
--sans:"Noto Sans SC Variable","Noto Sans SC",system-ui,-apple-system,"PingFang SC",sans-serif;
--ez:cubic-bezier(.4,0,.22,1); /* PhotoSwipe 333ms 节奏同族曲线 */
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink-2);font-family:var(--sans);}
body.locked{overflow:hidden}
.wrap{max-width:896px;margin:0 auto;padding:28px 20px 72px}
kbd{font-family:var(--mono);font-size:11.5px;border:1px solid var(--line);background:#fff;
border-radius:4px;padding:1px 6px;color:var(--ink-3)}
/* ===== 顶部说明:这是提案预览稿 ===== */
.note{background:var(--bg-soft);border:1px solid var(--line);border-radius:10px;
padding:14px 18px;font-size:13.5px;line-height:1.7;color:var(--ink-2);margin:18px 0 30px}
.note strong{color:var(--ink-1)}
.note ul{margin:6px 0 0;padding-left:18px}
.note li{margin:2px 0}
h1{font-size:27px;color:var(--ink-1);margin:8px 0 2px;font-weight:600}
.sub{font-family:var(--mono);font-size:12.5px;color:var(--ink-4)}
/* ===== 影像墙:cover 裁切(与线上一致,起飞动画的前提) ===== */
.stage{background:rgba(255,255,255,.04);border-radius:6px;padding:14px}
.wall{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(3,1fr);
grid-auto-rows:8px;grid-auto-flow:dense;gap:14px}
.shot{position:relative;overflow:hidden;border-radius:var(--radius-img);grid-row-end:span var(--sp,13)}
.shot button{display:block;width:100%;height:100%;padding:0;border:none;background:none;cursor:zoom-in}
.shot img{display:block;width:100%;height:100%;object-fit:cover}
@media (max-width:820px){.wall{grid-template-columns:repeat(2,1fr)}}
@media (max-width:560px){.wall{grid-template-columns:1fr 1fr;gap:10px}}
/* ===== 灯箱:fixed 覆盖层(生产将等价几何搬回原生 <dialog>) ===== */
.lbx{position:fixed;inset:0;z-index:80}
.lbx[hidden]{display:none}
.lbx-bg{position:absolute;inset:0;background:rgba(8,10,14,.96);opacity:0;
transition:opacity 300ms var(--ez)}
.lbx.on .lbx-bg{opacity:1}
/* 双盒:win=裁切窗(动画 left/top/w/h),pic=内图(动画几何 + 缩放 transform) */
.lbx-win{position:fixed;overflow:hidden;border-radius:6px;transform-origin:0 0;
box-shadow:0 12px 48px rgba(0,0,0,.5);touch-action:none}
.lbx-pic{position:absolute;transform-origin:50% 50%;user-select:none;-webkit-user-drag:none;
transition:transform 90ms linear;cursor:zoom-in;display:block}
.lbx.landing .lbx-win,.lbx.landing .lbx-pic{
transition:left 300ms var(--ez),top 300ms var(--ez),width 300ms var(--ez),
height 300ms var(--ez),transform 300ms var(--ez),opacity 300ms var(--ez)}
.lbx-pic.zoomed{cursor:grab}.lbx-pic.grabbing{cursor:grabbing}
.lbx-pic.swap{opacity:0}
/* chrome 层 1:关闭/箭头/计数(+140ms 淡入) */
.c1{opacity:0;transition:opacity 220ms var(--ez)}
.lbx.on .c1{opacity:1;transition-delay:140ms}
.lbx-btn{position:fixed;width:44px;height:44px;border-radius:10px;cursor:pointer;
border:1px solid rgba(255,255,255,.22);background:rgba(20,24,30,.55);color:#e6e9ee;
backdrop-filter:blur(4px);box-shadow:0 2px 10px rgba(0,0,0,.35);display:grid;place-items:center}
.lbx-btn:hover{background:rgba(255,255,255,.12)}
.lbx-close{top:16px;right:16px}
.lbx-prev{left:14px;top:50%;translate:0 -50%}
.lbx-next{right:14px;top:50%;translate:0 -50%}
.lbx-count{position:fixed;top:26px;left:20px;font-family:var(--mono);font-size:12.5px;color:#98a2b3}
/* chrome 层 2:caption / title+EXIF / 动作(+300ms 淡入) */
.c2{opacity:0;transition:opacity 260ms var(--ez)}
.lbx.on .c2{opacity:1;transition-delay:300ms}
.lbx-meta{position:fixed;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;
gap:3px;padding:14px 72px 20px;text-align:center;text-shadow:0 1px 4px rgba(0,0,0,.6)}
.lbx-caption{margin:0;font-family:var(--sans);font-size:13.5px;line-height:1.6;color:#dfe6f3;max-width:640px}
.lbx-row{display:flex;align-items:center;flex-wrap:wrap;justify-content:center;gap:14px;
font-family:var(--mono);font-size:12.5px}
.lbx-title{color:#fff;font-family:var(--sans);font-size:13px;font-weight:600}
.lbx-exif{color:#98a2b3}
.lbx-acts{display:inline-flex;gap:10px}
.lbx-act{display:inline-flex;align-items:center;gap:5px;padding:6px 11px;border-radius:10px;
border:1px solid rgba(255,255,255,.22);background:rgba(20,24,30,.55);color:#dfe6f3;
font-family:var(--mono);font-size:12px;text-decoration:none;backdrop-filter:blur(4px)}
.lbx-act:hover{background:rgba(40,48,60,.75);color:#fff}
/* 沉浸态:zoom>1 或按 i —— chrome 全隐,舞台纯净 */
.lbx.clean .c1,.lbx.clean .c2,.lbx.noui .c1,.lbx.noui .c2{opacity:0;transition-delay:0ms}
/* 首开提示 + 缩放指示 */
.lbx-hint{position:fixed;left:50%;bottom:128px;translate:-50% 0;padding:7px 13px;border-radius:10px;
border:1px solid rgba(255,255,255,.22);background:rgba(20,24,30,.55);backdrop-filter:blur(4px);
font-family:var(--mono);font-size:12px;color:#dfe6f3;opacity:0;transition:opacity 400ms var(--ez);
pointer-events:none}
.lbx-hint.show{opacity:1}
.lbx-zind{position:fixed;right:20px;bottom:128px;font-family:var(--mono);font-size:12px;color:#98a2b3;
opacity:0;transition:opacity 300ms var(--ez);pointer-events:none}
.lbx-zind.show{opacity:1}
@media (max-width:560px){
.lbx-prev{left:6px}.lbx-next{right:6px}
.lbx-meta{padding:12px 16px 16px}
.lbx-hint{bottom:150px}
}
@media (prefers-reduced-motion:reduce){
.lbx-bg,.c1,.c2,.lbx-pic,.lbx-win,.lbx-hint,.lbx-zind{transition:none!important}
}
</style>
</head>
<body>
<div class="wrap">
<h1>图库 · 灯箱 v2.1 提案预览</h1>
<p class="sub">预览稿 —— 交互为提案演示(非线上行为)· 图片为 content/photos 实拍,EXIF 为示例数据</p>
<div class="note">
<strong>这是给浏览器直接打开的交互演示</strong>,对应《09-灯箱美化探索》提案 #1/#2/#3/#6/#7:
<ul>
<li><strong>原位起飞开合</strong>:点击缩略图,照片从原位放大入舞台;关闭回落原位(<kbd>Esc</kbd> / 背景点击 / 关闭钮)</li>
<li><strong>chrome 双层淡入</strong>:开箱后层 1(箭头/计数)先入、层 2(caption/EXIF)随入;<kbd>i</kbd> 隐藏全部;放大后自动隐层</li>
<li><strong>连拍导航</strong>:<kbd>←</kbd> <kbd>→</kbd> / 边缘箭头 / 触屏快滑;计数 n / N</li>
<li><strong>缩放</strong>:滚轮锚点缩放 1–4x、<kbd>双击</kbd> 1x↔2.5x、放大后拖拽、双指缩放;右下角缩放指示</li>
</ul>
</div>
<div class="stage"><ul class="wall" id="wall"></ul></div>
</div>
<!-- 灯箱:fixed 覆盖层(生产等价搬回原生 <dialog>,focus-trap 由 dialog 免费提供) -->
<div class="lbx" id="lbx" hidden>
<div class="lbx-bg"></div>
<div class="lbx-win" id="win"><img class="lbx-pic" id="pic" alt=""></div>
<button class="lbx-btn lbx-close c1" id="bClose" aria-label="关闭(Esc)">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m5 5 14 14M19 5 5 19"/></svg></button>
<button class="lbx-btn lbx-prev c1" id="bPrev" aria-label="上一张(←)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m14.5 5.5-6.5 6.5 6.5 6.5"/></svg></button>
<button class="lbx-btn lbx-next c1" id="bNext" aria-label="下一张(→)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9.5 5.5 6.5 6.5-6.5 6.5"/></svg></button>
<div class="lbx-count c1" id="count">1 / 5</div>
<div class="lbx-meta c2">
<p class="lbx-caption" id="cap"></p>
<div class="lbx-row"><strong class="lbx-title" id="ttl"></strong><span class="lbx-exif" id="exif"></span>
<span class="lbx-acts"><a class="lbx-act" id="aOrig" href="#" target="_blank" rel="noopener">原图 ↗</a><a class="lbx-act" id="aDl" href="#" download>下载 ↓</a></span></div>
</div>
<div class="lbx-hint" id="hint">滚轮缩放 · 双击放大 · Esc 关闭 · i 隐藏界面</div>
<div class="lbx-zind" id="zind">100%</div>
</div>
<script>
/* ===== 数据:仓内实拍(含 2.36:1 长横幅与 2:3 竖图两种 letterbox 形态) ===== */
const PHOTOS=[
{src:'../content/photos/1766156983093.jpg',w:2400,h:1600,sp:13,title:'防波堤暮色',
cap:'落日把海与城一齐烫成金箔,海鸟的剪影恰好掠过日轮',exif:'X-T5 · XF35mmF1.4 · f/5.6 · 1/500s · ISO 200'},
{src:'../content/photos/mmexport1771816382115.jpg',w:2400,h:1018,sp:6,title:'长滩低云',
cap:'涨潮前的长滩,云层压得很低',exif:'X100VI · f/8 · 1/1000s · ISO 160'},
{src:'../content/photos/1766156998344.jpg',w:1600,h:2400,sp:20,title:'旗与塔',
cap:'旗在风里翻卷,塔身亮得像一支随时点火的火箭',exif:'GR III · f/2.8 · 1/250s · ISO 400'},
{src:'../content/photos/1766157004116.jpg',w:2400,h:1536,sp:9,title:'暮色撤离',
cap:'灯柱次第亮起,暮色像退潮一样撤离',exif:'X-T5 · XF56mmF1.2 · f/1.4 · 1/120s · ISO 800'},
{src:'../content/photos/mmexport1776564092433.jpg',w:2400,h:1600,sp:13,title:'夜航',
cap:'货轮的灯在雾里连成一条迟疑的线',exif:'X-T5 · XF70-300mm · f/4 · 1/60s · ISO 1250'}];
const N=PHOTOS.length;
const $=id=>document.getElementById(id);
const lbx=$('lbx'),win=$('win'),pic=$('pic'),wall=$('wall');
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
let idx=0,open_=false,hintShown=false;
let zoom={s:1,tx:0,ty:0};
/* ===== 影像墙 ===== */
PHOTOS.forEach((p,i)=>{
const li=document.createElement('li');
li.className='shot'; li.style.setProperty('--sp',p.sp);
const b=document.createElement('button'); b.type='button'; b.setAttribute('aria-label',p.title);
b.dataset.i=i;
const im=document.createElement('img'); im.src=p.src; im.alt=p.title; im.loading=i<3?'eager':'lazy';
b.appendChild(im); li.appendChild(b); wall.appendChild(li);
b.addEventListener('click',()=>openLbx(i));
});
const thumbImg=i=>wall.querySelector(`button[data-i="${i}"] img`);
/* ===== 几何:舞台 fit 矩形 / cover-contain 双盒(PhotoSwipe data-cropped 路线) ===== */
function stageBox(){const W=innerWidth,H=innerHeight;
const side=W>760?56:12,top=64,bot=innerWidth>560?124:150;
return{x:side,y:top,w:W-side*2,h:H-top-bot};}
function fitRect(p,box){const r=p.w/p.h,br=box.w/box.h;let w,h;
if(r>br){w=box.w;h=w/r;}else{h=box.h;w=h*r;}
return{x:box.x+(box.w-w)/2,y:box.y+(box.h-h)/2,w,h};}
function coverIn(p,winR){const r=p.w/p.h,wr=winR.w/winR.h;let w,h;
if(wr>r){h=winR.h;w=h*r;}else{w=winR.w;h=w/r;}
return{x:(winR.w-w)/2,y:(winR.h-h)/2,w,h};} /* 相对 win 的坐标 */
const setBox=(el,g)=>{el.style.left=g.x+'px';el.style.top=g.y+'px';
el.style.width=g.w+'px';el.style.height=g.h+'px';};
/* ===== 开箱:从缩略图原位起飞 ===== */
function fillMeta(p){$('cap').textContent=p.cap;$('ttl').textContent=p.title;
$('exif').textContent=p.exif;$('count').textContent=`${idx+1} / ${N}`;
$('aOrig').href=p.src;$('aDl').href=p.src;$('aDl').setAttribute('download',p.title+'.jpg');}
function openLbx(i){
idx=i;const p=PHOTOS[idx];pic.src=p.src;pic.alt=p.title;fillMeta(p);
const t=thumbImg(i).getBoundingClientRect();
const tr=fitRect(p,stageBox());
zoom={s:1,tx:0,ty:0};pic.style.transform='none';pic.classList.remove('zoomed','grabbing','swap');
lbx.hidden=false;open_=true;document.body.classList.add('locked');
lbx.classList.add('landing');
setBox(win,t);setBox(pic,coverIn(p,t)); /* 起点:与缩略图逐像素同框(cover 裁切) */
if(reduced){lbx.classList.add('on');lbx.classList.remove('landing');setBox(win,tr);setBox(pic,{x:0,y:0,w:tr.w,h:tr.h});}
else{
void win.offsetWidth; /* 强制 reflow,起点先落定 */
lbx.classList.add('on');
setBox(win,tr);setBox(pic,{x:0,y:0,w:tr.w,h:tr.h}); /* 终点:舞台 contain */
setTimeout(()=>lbx.classList.remove('landing'),330);
}
if(!hintShown){hintShown=true;const h=$('hint');h.classList.add('show');
setTimeout(()=>h.classList.remove('show'),2600);}
}
/* ===== 关闭:回落到缩略图原位 ===== */
function closeLbx(){
if(!open_)return;
const p=PHOTOS[idx],t=thumbImg(idx)?.getBoundingClientRect();
instantResetZoom();
if(reduced||!t){lbx.hidden=true;lbx.classList.remove('on','clean','noui');open_=false;
document.body.classList.remove('locked');return;}
lbx.classList.add('landing');
setBox(win,t);setBox(pic,coverIn(p,t));
lbx.classList.remove('on','clean','noui');
setTimeout(()=>{lbx.hidden=true;lbx.classList.remove('landing');open_=false;
document.body.classList.remove('locked');},320);
}
/* ===== 换片:旧图淡出、新图自侧向 10% 淡入(spacing 节奏) ===== */
function show(i,dir){
idx=((i%N)+N)%N;const p=PHOTOS[idx];instantResetZoom();fillMeta(p);
pic.src=p.src;pic.alt=p.title;
const tr=fitRect(p,stageBox());setBox(win,tr);setBox(pic,{x:0,y:0,w:tr.w,h:tr.h});
if(!reduced){pic.classList.add('swap');
pic.style.transform=`translateX(${dir*-10}%)`;
void pic.offsetWidth;pic.classList.remove('swap');
pic.style.transform='none';}
}
/* ===== 缩放:滚轮锚点 / 双击 / 双指 / 拖拽(复用线上 v2 数学) ===== */
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';
zoom={s:1,tx:0,ty:0};pic.style.transform='none';pic.classList.remove('zoomed');
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;
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();
const f=Math.exp(-e.deltaY*0.0015);
const next=Math.min(4,Math.max(1,zoom.s*f));
if(next!==zoom.s)zoomAt(next,e.clientX,e.clientY);},{passive:false});
win.addEventListener('dblclick',e=>{if(zoom.s===1)zoomAt(2.5,e.clientX,e.clientY);
else instantResetZoom();}); /* 挂 win:放大态 setPointerCapture 会把 click 派发给 win,pic 收不到 */
/* 拖拽平移(放大态)+ 双指缩放:pointer 事件统一 */
const pts=new Map();let drag=null,pinch=null;
win.addEventListener('pointerdown',e=>{
pts.set(e.pointerId,{x:e.clientX,y:e.clientY});
if(pts.size===2){const a=[...pts.values()];
pinch={d0:Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y),s0:zoom.s,
mx:(a[0].x+a[1].x)/2,my:(a[0].y+a[1].y)/2};drag=null;return;}
if(zoom.s>1){win.setPointerCapture(e.pointerId);
drag={x:e.clientX,y:e.clientY,tx:zoom.tx,ty:zoom.ty};pic.classList.add('grabbing');}
});
win.addEventListener('pointermove',e=>{
if(pts.has(e.pointerId))pts.set(e.pointerId,{x:e.clientX,y:e.clientY});
if(pinch&&pts.size===2){const a=[...pts.values()];
const d=Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y);
const next=Math.min(4,Math.max(1,pinch.s0*d/pinch.d0));
if(next!==zoom.s)zoomAt(next,pinch.mx,pinch.my);return;}
if(drag){zoom.tx=drag.tx+e.clientX-drag.x;zoom.ty=drag.ty+e.clientY-drag.y;applyZoom();}
});
const endPt=e=>{pts.delete(e.pointerId);if(pts.size<2)pinch=null;
if(drag){drag=null;pic.classList.remove('grabbing');}};
win.addEventListener('pointerup',endPt);win.addEventListener('pointercancel',endPt);
/* 触屏快滑翻页(zoom=1 时) */
let x0=null;lbx.addEventListener('touchstart',e=>{x0=e.touches[0].clientX;},{passive:true});
lbx.addEventListener('touchend',e=>{if(x0===null||zoom.s!==1){x0=null;return;}
const dx=e.changedTouches[0].clientX-x0;
if(Math.abs(dx)>40)show(idx+(dx<0?1:-1),dx<0?1:-1);x0=null;},{passive:true});
/* ===== 键盘 / 背景点击 / chrome 召唤 ===== */
document.addEventListener('keydown',e=>{
if(!open_)return;
if(e.key==='Escape')closeLbx();
else if(e.key==='ArrowLeft')show(idx-1,-1);
else if(e.key==='ArrowRight')show(idx+1,1);
else if(e.key==='i'||e.key==='I')lbx.classList.toggle('noui');
});
lbx.addEventListener('click',e=>{
if(e.target.closest('.lbx-win')||e.target.closest('.lbx-btn')||
e.target.closest('.lbx-meta')||e.target.closest('a'))return;
closeLbx(); /* 背景点击关闭(bgClickAction:'close' 同款) */
});
$('bClose').addEventListener('click',closeLbx);
$('bPrev').addEventListener('click',()=>show(idx-1,-1));
$('bNext').addEventListener('click',()=>show(idx+1,1));
/* 开箱中窗口变化:舞台即时重排(不做动画,避免与缩放打架) */
addEventListener('resize',()=>{if(!open_||lbx.classList.contains('landing'))return;
const p=PHOTOS[idx];const tr=fitRect(p,stageBox());
setBox(win,tr);setBox(pic,{x:0,y:0,w:tr.w,h:tr.h});});
</script>
</body>
</html>