feat(gallery): 灯箱 v2.2 定版——一律侧栏 200px+长文策略+六条 polish

布局统一(视觉轨像素实测裁定 A 改窄变体:侧栏方案让照片比底
栏方案大 39%/22%):≥820 全比例右侧栏 200px(舞台恰 1080×720
零信箱)、竖横顶部统一 26px;<820 底部抽屉。EXIF 两行折行+分段
span(字段级 nowrap+::before 绑后段符号——一刀治悬空/断点丢符/
间距不对称三症状);caption clamp 4 行+展开(桌面 60dvh/移动
40dvh 内滚,锁 --fig-h 保证展开前后图区几何不变);移动底条
flex 复活+箭头同排+EXIF 两行;提示胶囊移动挂顶+触屏文案;820
计数间隙 14px;全量左对齐(孤字居中终止)。

车道:op-artalk(专项 8/8×3 轮+E2E 37/37);视觉轨道三轮裁定
(需修→复检→定版),EXIF 间距自验 5.2px 对称、换行对 1(镜头
名独占行,非缺陷)

验证: E2E 37/37;EXIF 三段宽 measured(5.2px 对称/符号 ::before
/无悬空);移动 flex+提示顶挂实测;五档几何(1280 横竖/820/768/390)

Spec: none (灯箱 v2.2 定版,车道级交付)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 10:30:35 +08:00
1 parent 3b951d3de6
commit 0e06c25f67
2 files changed
+193 -37

No files matched your search

+192 -37
View File
@@ -23,10 +23,13 @@ function formatExif(exif: PhotoExif | undefined): string {
: exif.exposureTime >= 1
? `${exif.exposureTime}s`
: `1/${Math.round(1 / exif.exposureTime)}s`;
// 镜头名常内嵌光圈档位(如「28-200mm F2.8-5.6 A071」):此时不再追加 fNumber,
// 否则同一条里出现两组光圈读数(视觉终审 ①)
const lensHasAperture = !!exif.lens && /[fF][//]?\d/.test(exif.lens);
return [
[exif.make, exif.model].filter(Boolean).join(' ') || undefined,
exif.lens,
exif.fNumber === undefined ? undefined : `f/${exif.fNumber}`,
exif.fNumber === undefined || lensHasAperture ? undefined : `f/${exif.fNumber}`,
shutter,
exif.iso === undefined ? undefined : `ISO ${exif.iso}`,
]
@@ -120,7 +123,10 @@ const first = cards[0];
<dialog id="lb" aria-label={t('gallery_lightbox_label', locale)}>
<span id="lb-count" class="lb-count lb-l1">1 / {cards.length}</span>
<p id="lb-hint" class="lb-hint lb-l1" hidden>{t('lb_hint', locale)}</p>
<p id="lb-hint" class="lb-hint lb-l1" hidden>
<span class="hint-desktop">{t('lb_hint', locale)}</span>
<span class="hint-touch">{t('lb_hint_touch', locale)}</span>
</p>
<span id="lb-zoombadge" class="lb-zoombadge" aria-hidden="true"></span>
<button id="lb-close" class="tap lbnav lb-l1" type="button" aria-label={t('gallery_lightbox_close', locale)}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
@@ -138,6 +144,14 @@ const first = cards[0];
</button>
<div class="lb-meta lb-l2">
<p id="lb-caption" class="lb-caption">{first?.caption ?? ''}</p>
<button
id="lb-cap-more"
class="lb-cap-more"
type="button"
data-more={locale === 'en' ? 'More' : '展开'}
data-less={locale === 'en' ? 'Less' : '收起'}
hidden
>{locale === 'en' ? 'More' : '展开'}</button>
<div class="lb-row">
<strong id="lb-title">{first?.title}</strong>
<span id="lb-exif" class="lb-exif">{first?.exif}</span>
@@ -386,14 +400,7 @@ const first = cards[0];
}
}
// ── ⑧ 长图侧置 meta:竖图(ratio<0.9)且视口≥820 ──
function applySideMeta() {
const btn = shots[idx];
if (!btn) return;
const w = Number(btn.dataset.w) || 1, h = Number(btn.dataset.h) || 1;
lb.classList.toggle('side-meta', w / h < 0.9 && innerWidth >= 820);
}
addEventListener('resize', () => lb.open && applySideMeta(), { passive: true });
// ⑧(v2.2 退役)比例判定→一律侧栏由 CSS 媒体查询接管,见样式段 820px 块
// ── ④ 相邻预载(克制档 ±1,idle 后) ──
const idle: (f: () => void) => void =
@@ -443,6 +450,40 @@ const first = cards[0];
setTimeout(() => img.classList.remove('slide-in'), 320);
}
// caption 长文 clamp(v2.2):窄栏 4 行截断,溢出才现「展开」钮;换图复位
const capMore = document.getElementById('lb-cap-more') as HTMLButtonElement | null;
function resetCaptionClamp() {
if (!captionEl || !capMore) return;
// 先施加 clamp 再测:未 clamp 时 scrollHeight==clientHeight 恒不溢出(顺序反了会永不出钮)
captionEl.classList.add('clamp');
capMore.hidden = true;
capMore.textContent = capMore.dataset.more ?? capMore.textContent;
requestAnimationFrame(() => {
if (!captionEl) return;
if (captionEl.scrollHeight <= captionEl.clientHeight + 2) {
captionEl.classList.remove('clamp'); // 短 caption 不截断
} else {
capMore.hidden = false;
}
});
}
capMore?.addEventListener('click', () => {
const figEl = lb.querySelector('.lb-figure') as HTMLElement | null;
const willExpand = captionEl?.classList.contains('clamp') ?? false;
// 锁高必须在切换 clamp 之前量:切完 caption 立刻变高、figure 已被挤压(量到错误值)
if (willExpand && figEl) {
lb.style.setProperty('--fig-h', `${Math.round(figEl.getBoundingClientRect().height)}px`);
}
const clamped = captionEl?.classList.toggle('clamp');
capMore.textContent = clamped ? (capMore.dataset.more ?? '展开') : (capMore.dataset.less ?? '收起');
lb.classList.toggle('cap-open', !clamped);
if (!clamped) {
captionEl?.scrollTo?.({ top: 0 });
} else {
lb.style.removeProperty('--fig-h');
}
});
const show = (i: number) => {
const n = shots.length;
const prevIdx = idx;
@@ -459,8 +500,22 @@ const first = cards[0];
captionEl.textContent = btn.dataset.caption ?? '';
captionEl.style.display = captionEl.textContent ? '' : 'none';
}
exifEl.textContent = btn.dataset.exif ?? '';
exifEl.style.display = btn.dataset.exif ? '' : 'none';
// EXIF 字段级渲染:每段 nowrap(分隔符由 CSS ::after 粘在段尾,永不悬空行首/行尾)
const exifText = btn.dataset.exif ?? '';
exifEl.replaceChildren(
...exifText
.split(' · ')
.filter(Boolean)
.map((seg) => {
const s = document.createElement('span');
s.className = 'exif-seg';
s.textContent = seg;
return s;
}),
);
exifEl.style.display = exifText ? '' : 'none';
exifEl.title = exifText; // 窄栏两行折行后全串仍可悬停查看
resetCaptionClamp();
const countEl = document.getElementById('lb-count');
if (countEl) countEl.textContent = `${idx + 1} / ${n}`;
const orig = btn.dataset.original ?? '';
@@ -469,11 +524,11 @@ const first = cards[0];
dl.href = orig;
dl.setAttribute('download', btn.dataset.title ?? 'photo');
resetZoom();
applySideMeta();
if (firstOpen) {
// ① 起飞:先摆 contain 真身再克隆从缩略图矩形起飞盖过渡
img.src = btn.dataset.full ?? '';
lb.classList.remove('chrome-in', 'chrome-off');
lb.classList.remove('chrome-in', 'chrome-off', 'cap-open');
lb.style.removeProperty('--fig-h');
lb.showModal();
requestAnimationFrame(() => lb.classList.add('chrome-in'));
const thumb = btn.querySelector('img');
@@ -494,7 +549,8 @@ const first = cards[0];
lb.addEventListener('close', () => {
flyBack(); // ① 回落到当前缩略图(实时矩形,列表滚动后仍准)
resetZoom();
lb.classList.remove('chrome-in', 'chrome-off', 'side-meta');
lb.classList.remove('chrome-in', 'chrome-off', 'cap-open');
lb.style.removeProperty('--fig-h');
});
// 触屏快滑翻页(v2 保持):横滑翻页与 ⑩ 竖拖关正交
let x0: number | null = null;
@@ -668,6 +724,9 @@ const first = cards[0];
}
.lb-exif { color: #98a2b3; }
.lb-bottom {
display: flex; /* 视觉终审 ③:此前缺 flex,order/flex-basis 全是死代码 */
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4) var(--space-4);
color: var(--code-ink);
font-family: var(--font-mono);
@@ -702,10 +761,11 @@ const first = cards[0];
.lb-meta {
flex: 1;
min-width: 0;
text-align: center;
text-align: left; /* 视觉终审 ①:窄栏/移动条统一左对齐,长文孤字不再居中 */
display: flex;
flex-direction: column;
gap: 2px;
align-items: flex-start;
}
.lb-meta { text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); }
.lb-meta strong {
@@ -716,10 +776,18 @@ const first = cards[0];
}
.lb-meta span {
opacity: 0.82;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* EXIF 分段(视觉终审 ②):每段 nowrap、分隔符 ::after 粘在段尾——
换行只发生在段间,`·` 永不悬空行首/行尾;容器允许两行折行 */
.lb-exif { white-space: normal; }
/* EXIF 分隔符一刀修三(视觉终审复检方案):`·` 绑到**后一段**作 ::before 前缀并禁止其后断行——
悬空行尾消失、断点处符号不丢、两侧间距对称(.45em / .45em,此前 19-20px vs 3-4px)。
段为 JS 动态创建,仍须 :global(scoped 够不着,同类坑第四次的延续)。 */
:global(.exif-seg) { display: inline-block; white-space: nowrap; }
:global(.exif-seg + .exif-seg) { margin-left: 0.45em; }
:global(.exif-seg + .exif-seg)::before { content: '·'; margin-right: 0.45em; opacity: 0.55; }
/* 计数保持单行 */
.lb-count { white-space: nowrap; }
.lbnav {
flex: none;
width: 44px;
@@ -737,10 +805,14 @@ const first = cards[0];
top: var(--space-3);
right: var(--space-3);
}
@media (max-width: 560px) {
.lb-bottom { flex-wrap: wrap; justify-content: center; }
.lb-orig { margin-left: 0; }
.lb-meta { order: 3; flex-basis: 100%; }
@media (max-width: 819px) {
/* 触屏条:‹ 图 › 一行,caption/meta 整行在下(flex 生效后 order 才有意义) */
.lb-bottom { flex-wrap: wrap; justify-content: flex-start; align-items: center; }
.lb-orig { margin-left: auto; }
.lb-meta { order: 3; flex-basis: 100%; }
.lb-meta strong, .lb-meta .lb-caption { text-align: left; }
/* 移动端 EXIF 允许两行折行(撤 nowrap/ellipsis 截断) */
.lb-exif { white-space: normal; }
}
/* ── v2.1 chrome 双层淡入(③):层1 +140ms、层2 +300ms,仅 opacity 无位移 ── */
@@ -768,7 +840,8 @@ const first = cards[0];
letter-spacing: 0.06em;
}
/* 首开提示胶囊(⑦):底部居中 meta 上方,2.6s 自隐由 JS 控制 */
/* 首开提示胶囊(⑦):桌面底部居中 meta 上方(移动挂顶部,见下段),2.6s 自隐 */
.lb-hint .hint-touch { display: none; }
.lb-hint {
position: absolute;
bottom: 118px;
@@ -802,20 +875,102 @@ const first = cards[0];
}
.lb-zoombadge.on { opacity: 0.9; }
/* 长图侧置 meta(⑧):竖图宽视口时层2 挪进右侧 letterbox,横图维持底部 */
#lb.side-meta .lb-l2 {
position: absolute;
right: 20px;
top: 50%;
translate: 0 -50%;
width: 216px;
max-height: 70vh;
overflow-y: auto;
scrollbar-width: none;
border-left: 1px solid rgba(255, 255, 255, 0.08);
padding-left: 16px;
/* ── v2.2 一律侧栏(方案 A 窄变体,视觉裁定 §4):桌面 ≥820 全比例信息进右侧 200px 栏,
舞台恰 1080×720(3:2 横图零信箱);竖/横内容顶统一 26px(figure 高=视口-54,顶 26 → 两种比例
内容顶都落在 26px,不再一贴顶一留白)。移动 <820 维持底部条(两种形态是裁定接受的代价) ── */
@media (min-width: 820px) {
#lb .lb-figure { padding: 0; }
/* 舞台 = (1280-200) × (视口-26-54) = 1080×720(3:2 横图零信箱)。
替换元素(img)absolute 下 width:auto 取 intrinsic 宽而非 left/right 约束——须显式 calc */
#lb #lb-img {
left: 0;
right: auto;
top: 26px;
bottom: auto;
width: calc(100% - 200px);
height: calc(100dvh - 80px);
}
/* 底部 54px 视觉余量:让 figure 高=视口-26-54,内容顶恒 26 */
#lb .lb-bottom { padding: 0; min-height: 54px; display: block; }
#lb .lb-l2 {
position: absolute;
right: 0;
top: 0;
bottom: 0;
left: auto;
translate: none;
width: 200px;
max-height: none;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 0 18px 24px;
border-left: 1px solid rgba(255, 255, 255, 0.08);
overflow-y: auto;
scrollbar-width: none;
}
#lb .lb-l2 .lb-row { flex-direction: column; align-items: flex-start; gap: 8px; }
#lb .lb-l2 .lb-orig { margin-left: 0; }
/* 200px 栏宽:EXIF 允许两行折行(裁定补丁 3:不再硬裁「…A071 · 1」) */
#lb .lb-exif { white-space: normal; font-size: 11.5px; line-height: 1.5; color: #98a2b3; }
/* 计数与关闭钮落栏顶(栏内容底部对齐,顶部空区做面板头) */
/* 视觉终审 ⑤:计数与 × 间隙 ≥6px(62px 时实测仅 1px) */
#lb .lb-count { right: 74px; }
}
#lb.side-meta .lb-l2 .lb-row { flex-direction: column; align-items: flex-start; }
/* caption 长文策略(v2.2 追加):折叠 clamp 桌面 4 行 / 移动 2 行;
展开态给 max-height 上限 + 内部细滚动,数百字也不推爆舞台(图区几何不变量) */
.lb-caption {
white-space: pre-wrap; /* 段落感:描述里的换行原样渲染 */
}
.lb-caption.clamp {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
}
.lb-caption:not(.clamp) {
max-height: 60dvh;
overflow-y: auto;
scrollbar-width: thin;
line-height: 1.7;
font-size: 14px;
}
@media (max-width: 819px) {
.lb-caption.clamp { -webkit-line-clamp: 2; }
.lb-caption:not(.clamp) { max-height: 40dvh; }
/* 移动端展开态:底部条浮层化(出流),图区 flex 尺寸展开前后不变——
这是长描述的关键不变量(桌面侧栏天然成立,移动须显式出流) */
#lb.cap-open .lb-figure { height: var(--fig-h, auto); flex: none; }
/* 视觉终审 ④:移动提示胶囊挂顶部(bottom:118px 会压在底部条/EXIF 上),
文案切触屏手势(滚轮在手机上不存在) */
.lb-hint { bottom: auto; top: 76px; white-space: normal; max-width: calc(100% - 32px); text-align: center; }
.lb-hint .hint-desktop { display: none; }
.lb-hint .hint-touch { display: inline; }
#lb.cap-open .lb-bottom {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: rgba(8, 10, 14, 0.92);
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 16px;
}
}
.lb-cap-more {
align-self: flex-start;
margin: 2px 0 6px;
padding: 0;
border: none;
background: none;
color: #98a2b3;
font-family: var(--font-mono);
font-size: 11px;
text-decoration: underline dotted;
cursor: pointer;
}
.lb-cap-more:hover { color: #dfe6f3; }
/* FLIP 起飞/回落克隆(①):dialog 内 absolute(dialog 全屏故 viewport 坐标等价);
关闭回落克隆挂 body 需 fixed(dialog 关闭即离 top-layer,回落动画要活到 dialog 之外) */