Files
blog/src/components/pages/GalleryPage.astro
T
wait4xandGLM-5.3 127f349fb2 feat(gallery): 灯箱 v2.1 十项全量——起飞开合/隐层/侧置 meta/HD
①FLIP 原位起飞/回落(克隆双态矩形插值,关闭回落取缩略图实时
矩形)②背景点击关(containRect 几何判定——img 盒铺满 figure 的
元素链判定失效坑)③chrome 双层淡入+i 键/zoom 自动隐(换片重置)
④相邻预载 ⑤双指缩放 ⑥换片侧向过渡 ⑦缩放指示+首开提示 ⑧竖
图侧置 meta ⑨>1.5x HD 原图交换+404 兜底 ⑩竖拖关闭。reduced-
motion 全豁免;钳制数学保留。

验收三连修:chrome-in 动画 both 填充锁死 opacity(animation:none
掐填充——动画填充压制普通声明的级联陷阱);× 钮独立于双层
chrome 补隐(全隐口径=只剩图,Esc/i 逃逸);视觉终审四问修复
后全过(起飞连贯/侧置成立/隐层纯净/亮暗同判)。

车道:op-artalk(专项逐项+E2E 34/34);收编验收+视觉终审双绿

验证: E2E 34/34;三态隐层 computed 实测(i 隐/复原/zoom 隐全
过);全隐残留零(×/caption/箭头);Esc 逃逸 ✓

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

Co-authored-by: GLM-5.3
2026-11-01 13:21:15 +08:00

865 lines
33 KiB
Plaintext
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.
---
import { Image, getImage } from 'astro:assets';
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { t } from '../../i18n/ui';
import type { Locale } from '../../lib/locale';
import type { PhotoExif } from '../../lib/exif';
// /en/ 侧由 T4 薄页传 locale='en';zh 路由无人传参,默认 zh 零回归
interface Props {
locale?: Locale;
}
const { locale = 'zh' } = Astro.props;
const photos = await getCollection('photos');
// EXIF 参数条(机身/镜头/光圈/快门/ISO)构建期拼好整串,灯箱运行时只做文本替换
function formatExif(exif: PhotoExif | undefined): string {
if (!exif) return '';
const shutter =
exif.exposureTime === undefined
? undefined
: exif.exposureTime >= 1
? `${exif.exposureTime}s`
: `1/${Math.round(1 / exif.exposureTime)}s`;
return [
[exif.make, exif.model].filter(Boolean).join(' ') || undefined,
exif.lens,
exif.fNumber === undefined ? undefined : `f/${exif.fNumber}`,
shutter,
exif.iso === undefined ? undefined : `ISO ${exif.iso}`,
]
.filter(Boolean)
.join(' · ');
}
/* masonry:行高/列距用容器宽百分比(0.92cqw/1.62cqw,在 864px 版心即 8px/14px 档),
行高随断点等比缩放,构建期一份 row-span 在 2/3 列全断点精确成立——
px 定值行高会让格高随列宽漂移,窄屏裁切失控 */
const ROW = 0.0092;
const GAP = 0.0162;
const spanFor = (w: number, h: number, cols: number) =>
Math.round(((1 - GAP * (cols - 1)) / (cols * (w / h)) + GAP) / (ROW + GAP));
const cards = await Promise.all(
photos.map(async (photo) => {
// 宽高比来源:photos loader 产出 ImageMetadata 的 width/height(orientation 已修正)
const w = photo.data.image.width ?? 4;
const h = photo.data.image.height ?? 3;
const full = await getImage({ src: photo.data.image, width: 1440 });
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),
fullSrc: full.src,
// 原图走仓外 store(构建时独立 rsync 到 /photos-originals/),git 不背 196M
original: `/photos-originals/${photo.id}.jpg`,
w,
h,
s3: spanFor(w, h, 3),
s2: spanFor(w, h, 2),
};
}),
);
const first = cards[0];
---
<BaseLayout title={`${t('gallery_title', locale)} · wait4x`}>
<h1 class="page-title">{t('gallery_title', locale)}</h1>
<div class="g-main">
<fieldset class="filters">
<legend class="sr-only">{t('gallery_filter_legend', locale)}</legend>
<input class="f-input" type="radio" name="gallery-group" id="f-all" checked />
<label class="tap chip" for="f-all">{t('gallery_filter_all', locale)}</label>
<input class="f-input" type="radio" name="gallery-group" id="f-street" />
<label class="tap chip" for="f-street">{t('gallery_filter_street', locale)}</label>
<input class="f-input" type="radio" name="gallery-group" id="f-landscape" />
<label class="tap chip" for="f-landscape">{t('gallery_filter_landscape', locale)}</label>
</fieldset>
<div class="stage">
<ul class="wall">
{cards.map((card, i) => (
<li
class="shot"
data-group={card.group}
style={`--s3:${card.s3}; --s2:${card.s2}`}
>
<button
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) : '')}
>
<Image
src={card.image}
alt={card.alt}
widths={[480, 800, 1200]}
sizes="(min-width: 820px) 30vw, 46vw"
loading={i < 3 ? 'eager' : 'lazy'}
fetchpriority={i < 3 ? 'high' : undefined}
/>
</button>
</li>
))}
</ul>
</div>
<p class="g-note">{t('gallery_count', locale).replace('{n}', String(cards.length))}</p>
</div>
<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>
<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">
<path d="m5 5 14 14M19 5 5 19"></path>
</svg>
</button>
<div class="lb-figure">
<img id="lb-img" src={first?.fullSrc} alt={first?.alt ?? ''} />
</div>
<div class="lb-bottom">
<button id="lb-prev" class="tap lbnav lb-l1" type="button" aria-label={t('gallery_lightbox_prev', 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">
<path d="m14.5 5.5-6.5 6.5 6.5 6.5"></path>
</svg>
</button>
<div class="lb-meta lb-l2">
<p id="lb-caption" class="lb-caption">{first?.caption ?? ''}</p>
<div class="lb-row">
<strong id="lb-title">{first?.title}</strong>
<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)}
</a>
<a id="lb-download" class="lb-act" href={first?.original} download>
<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="M12 3v12m0 0 4-4m-4 4-4-4M4 21h16"></path></svg>
{t('gallery_lightbox_download', locale)}
</a>
</span>
</div>
</div>
<button id="lb-next" class="tap lbnav lb-l1" 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">
<path d="m9.5 5.5 6.5 6.5-6.5 6.5"></path>
</svg>
</button>
</div>
</dialog>
</BaseLayout>
<script>
// 灯箱 v2.1:原生 dialog + FLIP 起飞 + 滚轮锚点缩放 + 双指 + chrome 召唤 + HD 交换。
// 缩放引擎(applyZoom 钳制/zoomAt 锚点)为 44626f8 定稿数学,v2.1 全部叠加不动其核心。
// ClientRouter 下模块脚本只执行一次,接线挂在 astro:page-load 按当前 DOM 重装。
document.addEventListener('astro:page-load', () => {
const lb = document.getElementById('lb') as HTMLDialogElement | null;
const shots = [...document.querySelectorAll<HTMLButtonElement>('.shot-btn')];
if (!lb || shots.length === 0) return;
const img = document.getElementById('lb-img') as HTMLImageElement;
const fig = lb.querySelector('.lb-figure') as HTMLElement | null;
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 badge = document.getElementById('lb-zoombadge');
const hintEl = document.getElementById('lb-hint');
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
let idx = 0;
// ── 缩放引擎(v2 定稿,钳制数学不得回退) ──
const zoom = { s: 1, tx: 0, ty: 0 };
const applyZoom = () => {
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);
applyChrome(zoom.s > 1); // ③ zoom 驱动 chrome 自动隐
scheduleBadge();
applyHD();
};
const resetZoom = () => { zoom.s = 1; zoom.tx = 0; zoom.ty = 0; applyZoom(); };
const zoomAt = (next: number, cx: number, cy: number) => {
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();
const factor = Math.exp(-e.deltaY * 0.0015);
const next = Math.min(5, Math.max(1, zoom.s * factor));
if (next === zoom.s) return;
zoomAt(next, e.clientX, e.clientY);
}, { passive: false });
// ── ③ chrome 双层:i 键召唤 + zoom 自动隐(回 1x 淡回用户偏好态) ──
let userHideChrome = false;
function applyChrome(autoHide: boolean) {
lb.classList.toggle('chrome-off', userHideChrome || autoHide);
}
lb.addEventListener('keydown', (e) => {
if (e.key === 'i' || e.key === 'I') {
e.preventDefault();
userHideChrome = !userHideChrome;
applyChrome(zoom.s > 1);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
show(idx - 1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
show(idx + 1);
}
});
// ── ⑦ 缩放指示:停稳 400ms 后亮 900ms 淡出 ──
let badgeTimer: number | undefined;
function scheduleBadge() {
if (!badge) return;
badge.textContent = `${Math.round(zoom.s * 100)}%`;
clearTimeout(badgeTimer);
if (zoom.s === 1) { badge.classList.remove('on'); return; }
badgeTimer = window.setTimeout(() => {
badge.classList.add('on');
badgeTimer = window.setTimeout(() => badge?.classList.remove('on'), 900);
}, 400);
}
// ── ① FLIP 起飞/回落:克隆 cover 双态矩形插值(PS data-cropped 路线) ──
const containRect = () => {
const f = fig!.getBoundingClientRect();
const iw = img.naturalWidth || 1, ih = img.naturalHeight || 1;
const k = Math.min(f.width / iw, f.height / ih);
const w = iw * k, h = ih * k;
return { left: f.left + (f.width - w) / 2, top: f.top + (f.height - h) / 2, width: w, height: h };
};
const setBox = (el: HTMLElement, r: { left: number; top: number; width: number; height: number }) => {
el.style.left = `${r.left}px`; el.style.top = `${r.top}px`;
el.style.width = `${r.width}px`; el.style.height = `${r.height}px`;
};
function flyFrom(from: DOMRect) {
if (reduced.matches || !fig) return;
const c = containRect();
const cl = document.createElement('img');
cl.className = 'lb-flip-img';
cl.src = img.src;
cl.alt = '';
setBox(cl, from);
lb.appendChild(cl);
requestAnimationFrame(() => requestAnimationFrame(() => setBox(cl, c)));
setTimeout(() => cl.remove(), 340);
}
function flyBack() {
if (reduced.matches) return;
const thumb = shots[idx]?.querySelector('img');
if (!thumb) return;
const c = containRect();
const cl = document.createElement('img');
cl.className = 'lb-flip-back';
cl.src = img.src;
cl.alt = '';
setBox(cl, c);
document.body.appendChild(cl);
requestAnimationFrame(() => requestAnimationFrame(() => setBox(cl, thumb.getBoundingClientRect())));
setTimeout(() => cl.remove(), 340);
}
// ── ② 背景点击关闭:contain 布局下 img 元素盒铺满 figure,视觉空白也在 img 上——
// 背景判定不能看 e.target,须按 contain 内容盒几何(盒外=关,盒内 8px 容差=图区)
let justDragged = false;
lb.addEventListener('click', (e) => {
if (!lb.open) return;
const t = e.target as Element;
if (t.closest('button, a, .lb-l2, .lb-hint')) return;
if (justDragged) { justDragged = false; return; }
const c = containRect();
const inside =
e.clientX >= c.left - 8 && e.clientX <= c.left + c.width + 8 &&
e.clientY >= c.top - 8 && e.clientY <= c.top + c.height + 8;
if (!inside) lb.close();
});
// ── ⑤ 双指缩放 + ⑩ 竖拖关闭(pointer 统一;单指拖拽 v2 保持) ──
const pts = new Map<number, { x: number; y: number }>();
let pinch: { d: number; s: number } | null = null;
let tap: { x: number; y: number; closed: boolean } | null = null;
let drag: { x: number; y: number; tx: number; ty: number; moved: boolean } | null = null;
img.addEventListener('pointerdown', (e) => {
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) {
drag = null;
img.classList.remove('grabbing');
const [a, b] = [...pts.values()];
pinch = { d: Math.hypot(a!.x - b!.x, a!.y - b!.y), s: zoom.s };
return;
}
if (pts.size === 1) tap = { x: e.clientX, y: e.clientY, closed: false };
if (zoom.s > 1) {
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 (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pinch && pts.size === 2) {
const [a, b] = [...pts.values()];
const d = Math.hypot(a!.x - b!.x, a!.y - b!.y);
const mid = { x: (a!.x + b!.x) / 2, y: (a!.y + b!.y) / 2 };
const next = Math.min(5, Math.max(1, pinch.s * (d / pinch.d)));
if (next !== zoom.s) zoomAt(next, mid.x, mid.y);
return;
}
if (drag) {
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();
return;
}
// ⑩ zoom=1 竖向拖 >80px 且竖向主导 → 关闭回落
if (tap && !tap.closed && zoom.s === 1) {
const dy = e.clientY - tap.y, dx = e.clientX - tap.x;
if (Math.abs(dy) > 80 && Math.abs(dy) > Math.abs(dx) * 1.2) {
tap.closed = true;
lb.close();
}
}
});
const endPointer = (e: PointerEvent) => {
pts.delete(e.pointerId);
if (pts.size < 2) pinch = null;
if (drag?.moved) justDragged = true;
drag = null;
tap = null;
img.classList.remove('grabbing');
if (justDragged) setTimeout(() => (justDragged = false), 120);
};
img.addEventListener('pointerup', endPointer);
img.addEventListener('pointercancel', endPointer);
img.addEventListener('dblclick', (e) => {
if (zoom.s === 1) zoomAt(2.5, e.clientX, e.clientY);
else resetZoom();
});
// ── ⑨ HD 交换:>1.5x 懒换原图(decode 后无闪白),回落 1.5x 以下回 1440 ──
let hd = { on: false, failed: false, gen: 0 };
function applyHD() {
const btn = shots[idx];
if (!btn) return;
const full = btn.dataset.full ?? '';
const orig = btn.dataset.original ?? '';
if (zoom.s > 1.5 && !hd.on && !hd.failed && orig) {
const gen = hd.gen;
const probe = new Image();
probe.src = orig;
probe.decode?.().then(() => {
if (hd.gen === gen && zoom.s > 1.5 && !hd.on) { img.src = orig; hd.on = true; }
}).catch(() => { hd.failed = true; }); // 404/解码失败:留 1440 兜底
} else if (zoom.s <= 1.5 && hd.on) {
img.src = full;
hd.on = false;
}
}
// ── ⑧ 长图侧置 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 });
// ── ④ 相邻预载(克制档 ±1,idle 后) ──
const idle: (f: () => void) => void =
'requestIdleCallback' in window
? (f) => requestIdleCallback(f)
: (f) => setTimeout(f, 200);
function preloadNeighbors() {
idle(() => {
for (const j of [idx + 1, idx - 1]) {
const b = shots[(j + shots.length) % shots.length];
if (b?.dataset.full) { const pre = new Image(); pre.src = b.dataset.full; }
}
});
}
// ── ⑦ 首开提示:每会话一次,2.6s 自隐 ──
let hintShown = false;
function maybeHint() {
if (hintShown || !hintEl || sessionStorage.getItem('lb-hint') === '1') return;
hintShown = true;
sessionStorage.setItem('lb-hint', '1');
hintEl.hidden = false;
setTimeout(() => { hintEl!.hidden = true; }, 2600);
}
// ── ⑥ 换片过渡:旧图 140ms 淡出 → 新图 decode 后侧向 10% 位移淡入 ──
let lastIdx = 0;
async function transitionSwap(btn: HTMLButtonElement, dir: 1 | -1) {
const next = btn.dataset.full ?? '';
if (reduced.matches) { img.src = next; return; }
img.style.opacity = '0'; // 旧图淡出(transition 160ms 已有)
await new Promise<void>((done) => {
const probe = new Image();
probe.src = next;
probe.decode ? probe.decode().then(() => done()).catch(() => done()) : (probe.onload = () => done());
setTimeout(done, 1200); // 网络兜底
});
img.src = next;
img.classList.add('slide-in');
img.style.transform = `translateX(${dir * 10}%)`;
img.style.opacity = '0';
void img.offsetWidth; // 强制 reflow 再进终态
requestAnimationFrame(() => {
img.style.transform = 'none';
img.style.opacity = '1';
});
setTimeout(() => img.classList.remove('slide-in'), 320);
}
const show = (i: number) => {
const n = shots.length;
const prevIdx = idx;
idx = (i + n) % n;
const btn = shots[idx]!;
const firstOpen = !lb.open;
const dir: 1 | -1 = ((idx - prevIdx + n) % n === 1 ? 1 : -1);
hd = { on: false, failed: false, gen: hd.gen + 1 }; // ⑨ 换图重置 HD 态
img.alt = btn.ariaLabel ?? '';
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';
}
exifEl.textContent = btn.dataset.exif ?? '';
exifEl.style.display = btn.dataset.exif ? '' : 'none';
const countEl = document.getElementById('lb-count');
if (countEl) countEl.textContent = `${idx + 1} / ${n}`;
const orig = btn.dataset.original ?? '';
(document.getElementById('lb-original') as HTMLAnchorElement).href = orig;
const dl = document.getElementById('lb-download') as HTMLAnchorElement;
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.showModal();
requestAnimationFrame(() => lb.classList.add('chrome-in'));
const thumb = btn.querySelector('img');
if (thumb) flyFrom(thumb.getBoundingClientRect());
maybeHint();
} else {
void transitionSwap(btn, dir); // ⑥ 换片过渡
}
lastIdx = idx;
preloadNeighbors();
userHideChrome = false; // 换片重置召唤偏好(新图默认亮 chrome)
};
shots.forEach((btn, i) => btn.addEventListener('click', () => show(i)));
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', () => {
flyBack(); // ① 回落到当前缩略图(实时矩形,列表滚动后仍准)
resetZoom();
lb.classList.remove('chrome-in', 'chrome-off', 'side-meta');
});
// 触屏快滑翻页(v2 保持):横滑翻页与 ⑩ 竖拖关正交
let x0: number | null = null;
lb.addEventListener('touchstart', (e) => {
x0 = e.touches[0]!.clientX;
}, { passive: true });
lb.addEventListener('touchend', (e) => {
if (x0 === null) return;
const dx = e.changedTouches[0]!.clientX - x0;
if (Math.abs(dx) > 40) show(idx + (dx < 0 ? 1 : -1));
x0 = null;
}, { passive: true });
});
</script>
<style>
/* 版心放行:图库加宽到 measure+176(丙路线 §3;BaseLayout 默认 720) */
:global(main#main:has(.g-main)) {
max-width: calc(var(--measure) + 176px);
}
.page-title {
font-size: var(--text-h1);
line-height: var(--leading-heading);
color: var(--ink-1);
margin-top: var(--space-5);
text-wrap: balance;
}
/* 分组筛选:纯 CSS :checked + :has,radio 原生键盘可达(方向键切换) */
.filters {
border: none;
padding: 0;
margin: var(--space-3) 0 var(--space-4);
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.sr-only,
.f-input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
.chip {
padding-inline: 13px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 13px;
color: var(--ink-3);
cursor: pointer;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.chip:hover { color: var(--ink-1); border-color: var(--ink-4); }
.f-input:focus-visible + .chip { outline: 2px solid var(--accent); outline-offset: 2px; }
.f-input:checked + .chip { color: var(--ink-1); border-color: var(--ink-3); font-weight: 500; }
.g-main:has(#f-street:checked) .shot:not([data-group='street']) { display: none; }
.g-main:has(#f-landscape:checked) .shot:not([data-group='landscape']) { display: none; }
/* masonry:stage 是容器查询锚,行高/列距以 stage 宽为基(cqw) */
.stage { container-type: inline-size; }
.wall {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 0.92cqw; /* 8px 档 @ 864px 版心 */
grid-auto-flow: dense;
gap: 1.62cqw; /* 14px 档 @ 864px 版心 */
}
@media (min-width: 820px) {
.wall { grid-template-columns: repeat(3, 1fr); }
}
.shot {
grid-row-end: span var(--s2, 10);
border-radius: 10px;
overflow: hidden;
}
/* 高度唯一来源=行跨度;图片封面式填满格子——消除 aspect-ratio 与
span 的双约束竞争(视觉复核发现的列洞/丢图根因面) */
.shot img {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (min-width: 820px) {
.shot { grid-row-end: span var(--s3, 12); }
}
.shot-btn {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: none;
cursor: zoom-in;
}
.shot-btn :global(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.g-note {
margin-top: var(--space-3);
font-size: 12.5px;
color: var(--ink-4);
}
/* 灯箱:全屏原生 dialog,高度用 dvh(规避 100vh 移动端地址栏陷阱) */
/* 关闭态显式 none:UA 对 dialog:not([open]) 的默认隐藏会被上面的
display:flex 覆盖——灯箱页面加载即全屏盖住图库的根因 */
#lb:not([open]) { display: none; }
#lb {
inset: 0;
width: 100%;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
border: none;
border-radius: 0;
background: transparent;
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;
position: relative; /* img 绝对填满:grid/flex item 的 % 高存在 auto 行高循环失效坑 */
padding: var(--space-5) var(--space-3) var(--space-2);
}
#lb-img {
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-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);
font-size: 12.5px;
}
.lb-orig {
display: inline-flex;
gap: var(--space-3);
margin-left: auto; /* 胶囊组右推:行内 title/count/exif 在左 */
}
/* 动作胶囊:与 lbnav 同语言(暗底/描边/阴影),不再是裸链 */
.lb-act {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(20, 24, 30, 0.55);
color: #dfe6f3;
font-family: var(--font-mono);
font-size: 12px;
text-decoration: none;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.lb-act:hover {
background: rgba(40, 48, 60, 0.75);
color: #fff;
}
.lb-meta {
flex: 1;
min-width: 0;
text-align: center;
display: flex;
flex-direction: column;
gap: 2px;
}
.lb-meta { text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); }
.lb-meta strong {
color: #fff;
font-size: 13px;
font-family: var(--font-sans);
font-weight: 600;
}
.lb-meta span {
opacity: 0.82;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lbnav {
flex: none;
width: 44px;
height: 44px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(20, 24, 30, 0.55);
color: #e6e9ee;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
}
.lbnav:hover { background: rgba(255, 255, 255, 0.12); }
#lb-close {
position: absolute;
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%; }
}
/* ── v2.1 chrome 双层淡入(③):层1 +140ms、层2 +300ms,仅 opacity 无位移 ── */
.lb-l1, .lb-l2 { transition: opacity 200ms var(--ease); }
#lb:not(.chrome-in) .lb-l1, #lb:not(.chrome-in) .lb-l2 { opacity: 0; }
#lb.chrome-in .lb-l1 { animation: lbFade 300ms cubic-bezier(0.4, 0, 0.22, 1) 140ms both; }
#lb.chrome-in .lb-l2 { animation: lbFade 300ms cubic-bezier(0.4, 0, 0.22, 1) 300ms both; }
@keyframes lbFade { from { opacity: 0; } to { opacity: 1; } }
/* 召唤态(i 键)与 zoom>1 自动隐:opacity 归零不接交互 */
#lb.chrome-off .lb-l1, #lb.chrome-off .lb-l2 { opacity: 0; pointer-events: none; animation: none; }
/* × 独立于双层 chrome(层1 只包箭头/计数)——全隐口径=只剩图;逃逸路径 Esc/i 保持 */
#lb.chrome-off #lb-close { opacity: 0; pointer-events: none; }
/* animation:none 掐掉 chrome-in 的 both 填充——动画填充压制普通声明,隐层曾被锁死 opacity:1 */
#lb.chrome-off { cursor: zoom-in; }
/* 层1 计数:右上角 close 旁(原底行挪出) */
.lb-count {
position: absolute;
top: 16px;
right: 64px;
z-index: 11;
font-family: var(--font-mono);
font-size: 12px;
color: #98a2b3;
letter-spacing: 0.06em;
}
/* 首开提示胶囊(⑦):底部居中 meta 上方,2.6s 自隐由 JS 控制 */
.lb-hint {
position: absolute;
bottom: 118px;
left: 50%;
translate: -50% 0;
z-index: 11;
margin: 0;
padding: 7px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(20, 24, 30, 0.72);
color: #dfe6f3;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.03em;
white-space: nowrap;
}
/* 缩放指示(⑦):右下 mono 小字 */
.lb-zoombadge {
position: absolute;
right: 18px;
bottom: 14px;
z-index: 11;
font-family: var(--font-mono);
font-size: 12px;
color: #dfe6f3;
opacity: 0;
transition: opacity 240ms var(--ease);
pointer-events: none;
}
.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;
}
#lb.side-meta .lb-l2 .lb-row { flex-direction: column; align-items: flex-start; }
/* FLIP 起飞/回落克隆(①):dialog 内 absolute(dialog 全屏故 viewport 坐标等价);
关闭回落克隆挂 body 需 fixed(dialog 关闭即离 top-layer,回落动画要活到 dialog 之外) */
:global(.lb-flip-img) {
position: absolute;
z-index: 6; /* 盖真身、让位 chrome(11) */
object-fit: cover;
border-radius: 6px;
pointer-events: none;
transition: left 300ms cubic-bezier(0.4, 0, 0.22, 1),
top 300ms cubic-bezier(0.4, 0, 0.22, 1),
width 300ms cubic-bezier(0.4, 0, 0.22, 1),
height 300ms cubic-bezier(0.4, 0, 0.22, 1);
}
:global(.lb-flip-back) {
position: fixed;
z-index: 2147483000;
object-fit: cover;
border-radius: 6px;
pointer-events: none;
transition: left 300ms cubic-bezier(0.4, 0, 0.22, 1),
top 300ms cubic-bezier(0.4, 0, 0.22, 1),
width 300ms cubic-bezier(0.4, 0, 0.22, 1),
height 300ms cubic-bezier(0.4, 0, 0.22, 1);
}
/* 换片过渡(⑥):新图侧向 10% 位移淡入由 JS inline 过渡驱动 */
#lb-img.slide-in { transition: opacity 280ms var(--ease), transform 280ms var(--ease); }
/* reduced-motion:v2.1 所有新动效全豁免(缩放/拖拽本体不在此列,由 v2 保持) */
@media (prefers-reduced-motion: reduce) {
#lb.chrome-in .lb-l1, #lb.chrome-in .lb-l2 { animation: none; opacity: 1; }
.lb-l1, .lb-l2, .lb-zoombadge { transition: none; }
.lb-flip-img, :global(.lb-flip-back) { display: none; }
#lb-img.slide-in { transition: none; }
}
</style>
<style>
/* 5f T8(视觉裁定 §6-4):亮色直贴不动;暗色 4% 轻衬底救夜景融底;衬底圆角≤照片防框中框 */
:global([data-theme='dark']) .stage {
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
}
.shot img, .thumb img { border-radius: var(--radius-img); }
</style>