Skip to content

Commit 1a5dbfc

Browse files
imguoguoclaude
andauthored
feat: add image lightbox to blog articles (#33)
- Click any blog image to open fullscreen lightbox - Scroll wheel to zoom (0.5x–8x), drag to pan when zoomed - Prev/next navigation via arrows, keyboard ←→, or thumbnail clicks - Thumbnail strip at bottom with active indicator - Press Escape to close, 0 to reset zoom - Logic extracted to src/lib/lightbox.ts, events cleaned up via AbortController Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 335d081 commit 1a5dbfc

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

src/lib/lightbox.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Blog image lightbox — zoom, drag, navigation, thumbnails
2+
3+
export function initLightbox(signal: AbortSignal) {
4+
const lightbox = document.getElementById('lightbox');
5+
const lbImg = document.getElementById('lightbox-img') as HTMLImageElement;
6+
const thumbsEl = document.getElementById('lightbox-thumbs');
7+
if (!lightbox || !lbImg) return;
8+
9+
const images = Array.from(document.querySelectorAll<HTMLImageElement>('.article-body img'));
10+
if (!images.length) return;
11+
12+
let idx = 0;
13+
let scale = 1, tx = 0, ty = 0;
14+
let dragging = false, hasDragged = false;
15+
let dragStartX = 0, dragStartY = 0, startTx = 0, startTy = 0;
16+
17+
// ── Thumbnails ──
18+
if (thumbsEl) {
19+
thumbsEl.innerHTML = '';
20+
if (images.length > 1) {
21+
images.forEach((img, i) => {
22+
const thumb = document.createElement('img');
23+
thumb.src = img.src;
24+
thumb.alt = '';
25+
thumb.className = 'lb-thumb';
26+
thumb.addEventListener('click', (e) => { e.stopPropagation(); showImage(i); }, { signal });
27+
thumbsEl.appendChild(thumb);
28+
});
29+
}
30+
}
31+
32+
function updateThumbs() {
33+
if (!thumbsEl) return;
34+
thumbsEl.querySelectorAll('.lb-thumb').forEach((t, i) => {
35+
t.classList.toggle('active', i === idx);
36+
});
37+
const active = thumbsEl.children[idx] as HTMLElement;
38+
if (active) active.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
39+
}
40+
41+
// ── Transform ──
42+
function apply() {
43+
lbImg.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`;
44+
}
45+
function reset() {
46+
scale = 1; tx = 0; ty = 0;
47+
lbImg.style.transform = '';
48+
lbImg.style.cursor = 'zoom-in';
49+
}
50+
const isZoomed = () => scale > 1.05;
51+
52+
// ── Navigation ──
53+
function showImage(i: number) {
54+
idx = i;
55+
lbImg.src = images[i].src;
56+
lbImg.alt = images[i].alt;
57+
reset();
58+
updateThumbs();
59+
const counter = document.getElementById('lightbox-counter');
60+
if (counter) {
61+
if (images.length > 1) {
62+
counter.textContent = `${idx + 1} / ${images.length}`;
63+
counter.style.display = '';
64+
} else {
65+
counter.style.display = 'none';
66+
}
67+
}
68+
// Show/hide nav arrows
69+
const prev = document.getElementById('lightbox-prev');
70+
const next = document.getElementById('lightbox-next');
71+
if (prev) prev.style.display = images.length > 1 ? '' : 'none';
72+
if (next) next.style.display = images.length > 1 ? '' : 'none';
73+
}
74+
75+
function open(i: number) {
76+
showImage(i);
77+
lightbox.classList.add('open');
78+
document.body.style.overflow = 'hidden';
79+
}
80+
81+
function close() {
82+
lightbox.classList.remove('open');
83+
document.body.style.overflow = '';
84+
}
85+
86+
function goPrev() { if (idx > 0) showImage(idx - 1); }
87+
function goNext() { if (idx < images.length - 1) showImage(idx + 1); }
88+
89+
// ── Wheel zoom ──
90+
lightbox.addEventListener('wheel', (e) => {
91+
if (!lightbox.classList.contains('open')) return;
92+
e.preventDefault();
93+
const factor = e.deltaY > 0 ? 0.9 : 1.1;
94+
scale = Math.min(Math.max(scale * factor, 0.5), 8);
95+
const rect = lbImg.getBoundingClientRect();
96+
tx += (e.clientX - rect.left - rect.width / 2) * (1 - factor);
97+
ty += (e.clientY - rect.top - rect.height / 2) * (1 - factor);
98+
if (!isZoomed()) { tx = 0; ty = 0; scale = 1; }
99+
lbImg.style.cursor = isZoomed() ? 'grab' : 'zoom-in';
100+
apply();
101+
}, { passive: false, signal });
102+
103+
// ── Drag pan ──
104+
lbImg.addEventListener('pointerdown', (e) => {
105+
if (!isZoomed()) return;
106+
e.preventDefault();
107+
dragging = true; hasDragged = false;
108+
dragStartX = e.clientX; dragStartY = e.clientY;
109+
startTx = tx; startTy = ty;
110+
lbImg.style.cursor = 'grabbing';
111+
lbImg.setPointerCapture(e.pointerId);
112+
}, { signal });
113+
114+
window.addEventListener('pointermove', (e) => {
115+
if (!dragging) return;
116+
const dx = e.clientX - dragStartX, dy = e.clientY - dragStartY;
117+
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasDragged = true;
118+
tx = startTx + dx; ty = startTy + dy;
119+
apply();
120+
}, { signal });
121+
122+
window.addEventListener('pointerup', () => {
123+
if (!dragging) return;
124+
dragging = false;
125+
lbImg.style.cursor = isZoomed() ? 'grab' : 'zoom-in';
126+
}, { signal });
127+
128+
// ── Bind article images ──
129+
images.forEach((img, i) => {
130+
img.style.cursor = 'zoom-in';
131+
img.addEventListener('click', () => open(i), { signal });
132+
});
133+
134+
// ── Prev / Next buttons ──
135+
document.getElementById('lightbox-prev')?.addEventListener('click', (e) => { e.stopPropagation(); goPrev(); }, { signal });
136+
document.getElementById('lightbox-next')?.addEventListener('click', (e) => { e.stopPropagation(); goNext(); }, { signal });
137+
138+
// ── Backdrop click ──
139+
lightbox.addEventListener('click', (e) => {
140+
if (hasDragged) { hasDragged = false; return; }
141+
const target = e.target as HTMLElement;
142+
// Close if clicking backdrop, image (when not zoomed), or counter
143+
if (target === lightbox || target.classList.contains('lightbox-main') || (target === lbImg && !isZoomed())) close();
144+
}, { signal });
145+
146+
document.getElementById('lightbox-close')?.addEventListener('click', close, { signal });
147+
148+
// ── Keyboard ──
149+
document.addEventListener('keydown', (e) => {
150+
if (!lightbox.classList.contains('open')) return;
151+
if (e.key === 'Escape') close();
152+
if (e.key === 'ArrowLeft') goPrev();
153+
if (e.key === 'ArrowRight') goNext();
154+
if (e.key === '0') { reset(); apply(); }
155+
}, { signal });
156+
}

src/pages/blog/[...slug].astro

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,26 @@ function formatDate(date: Date): string {
6161
</footer>
6262
</article>
6363

64+
<!-- Image lightbox -->
65+
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Image preview">
66+
<button class="lightbox-close" id="lightbox-close" aria-label="Close">&times;</button>
67+
<div class="lightbox-main">
68+
<button class="lightbox-nav lightbox-prev" id="lightbox-prev" aria-label="Previous">&#8249;</button>
69+
<img class="lightbox-img" id="lightbox-img" src="" alt="" />
70+
<button class="lightbox-nav lightbox-next" id="lightbox-next" aria-label="Next">&#8250;</button>
71+
</div>
72+
<div class="lightbox-bottom">
73+
<div class="lightbox-thumbs" id="lightbox-thumbs"></div>
74+
<div class="lightbox-counter" id="lightbox-counter"></div>
75+
</div>
76+
</div>
77+
6478
<Footer />
6579
</Base>
6680

6781
<script>
82+
import { initLightbox } from '../../lib/lightbox';
83+
6884
document.addEventListener('astro:page-load', () => {
6985
const article = document.querySelector<HTMLElement>('.blog-article[data-lang]');
7086
if (!article) return;
@@ -82,6 +98,9 @@ function formatDate(date: Date): string {
8298

8399
// Remove listener when navigating away from this article page
84100
document.addEventListener('astro:before-swap', () => controller.abort(), { once: true });
101+
102+
// ── Image lightbox ──
103+
initLightbox(controller.signal);
85104
});
86105
</script>
87106

@@ -170,4 +189,89 @@ function formatDate(date: Date): string {
170189
margin-top: 40px; padding-top: 24px; border-top: 1px solid var(--border);
171190
}
172191

192+
/* ─── Lightbox ─── */
193+
.lightbox {
194+
position: fixed; inset: 0; z-index: 1000;
195+
display: flex; flex-direction: column;
196+
background: rgba(0, 0, 0, .88);
197+
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
198+
opacity: 0; pointer-events: none;
199+
transition: opacity .25s ease;
200+
}
201+
.lightbox.open { opacity: 1; pointer-events: all; }
202+
203+
.lightbox-close {
204+
position: absolute; top: 12px; right: 16px;
205+
background: none; border: none; color: rgba(255,255,255,.6);
206+
font-size: 2rem; line-height: 1; cursor: pointer; z-index: 2;
207+
transition: color .15s;
208+
}
209+
.lightbox-close:hover { color: #fff; }
210+
211+
.lightbox-main {
212+
flex: 1; display: flex; align-items: center; justify-content: center;
213+
position: relative; min-height: 0; padding: 16px 60px;
214+
}
215+
216+
.lightbox-img {
217+
max-width: 90vw; max-height: calc(100vh - 120px);
218+
border-radius: 8px;
219+
box-shadow: 0 12px 48px rgba(0,0,0,.5);
220+
object-fit: contain;
221+
transform-origin: center center;
222+
will-change: transform;
223+
cursor: zoom-in;
224+
user-select: none;
225+
-webkit-user-drag: none;
226+
}
227+
228+
.lightbox-nav {
229+
position: absolute; top: 50%; transform: translateY(-50%);
230+
background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.12);
231+
color: rgba(255,255,255,.7); font-size: 1.6rem; line-height: 1;
232+
width: 40px; height: 40px; border-radius: 50%;
233+
display: flex; align-items: center; justify-content: center;
234+
cursor: pointer; z-index: 2;
235+
transition: background .15s, color .15s;
236+
}
237+
.lightbox-nav:hover { background: rgba(255,255,255,.18); color: #fff; }
238+
.lightbox-prev { left: 12px; }
239+
.lightbox-next { right: 12px; }
240+
241+
.lightbox-bottom {
242+
flex-shrink: 0;
243+
display: flex; flex-direction: column; align-items: center;
244+
gap: 8px; padding: 10px 16px 14px;
245+
}
246+
247+
.lightbox-thumbs {
248+
display: flex; gap: 6px; overflow-x: auto;
249+
max-width: 90vw; padding: 2px;
250+
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.15) transparent;
251+
}
252+
.lightbox-thumbs::-webkit-scrollbar { height: 4px; }
253+
.lightbox-thumbs::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
254+
:global(.lb-thumb) {
255+
width: 48px; height: 36px; object-fit: cover;
256+
border-radius: 4px; cursor: pointer; flex-shrink: 0;
257+
border: none; outline: 2px solid transparent; outline-offset: -2px;
258+
opacity: .45;
259+
transition: opacity .15s, outline-color .15s;
260+
}
261+
:global(.lb-thumb:hover) { opacity: .7; }
262+
:global(.lb-thumb.active) { opacity: 1; outline-color: var(--accent); }
263+
264+
.lightbox-counter {
265+
font-size: 0.72rem; color: rgba(255,255,255,.4);
266+
pointer-events: none;
267+
}
268+
269+
@media (max-width: 600px) {
270+
.lightbox-main { padding: 12px 44px; }
271+
.lightbox-nav { width: 32px; height: 32px; font-size: 1.2rem; }
272+
.lightbox-prev { left: 6px; }
273+
.lightbox-next { right: 6px; }
274+
:global(.lb-thumb) { width: 40px; height: 30px; }
275+
}
276+
173277
</style>

0 commit comments

Comments
 (0)