①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
865 lines
33 KiB
Plaintext
865 lines
33 KiB
Plaintext
---
|
||
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>
|