feat: add prev/next navigation to image lightbox

When multiple images appear in the same message, clicking any image
now opens a lightbox with prev/next navigation buttons (‹ / ›) and
keyboard support (← / →). An image counter (e.g. '3 / 5') is shown at
the bottom of the overlay.

- _openImgLightbox now receives the clicked <img> element to find
  sibling images within the same message container
- New _openImgLightboxWithNav, _navigateLightbox, _updateLightboxCounter
- CSS: .img-lightbox-nav (prev/next buttons), .img-lightbox-counter
- Close button (×), Escape key, and click-outside-to-close preserved
This commit is contained in:
weiwei83
2026-05-26 15:16:03 +08:00
parent 48a2e79224
commit ecf7ca7c60
4 changed files with 99 additions and 6 deletions
+4
View File
@@ -3,6 +3,10 @@
## [Unreleased]
### Added
- Image lightbox now supports prev/next navigation when multiple images are present in the same message. Click `` / `` buttons or use `←` / `→` keyboard arrows to browse; an image counter (`1 / 5`) is shown at the bottom. (#PR)
## [v0.51.137] — 2026-05-25 — Release DI (stage-batch19 — 6-PR medium-risk batch)
### Added
+7
View File
@@ -334,6 +334,8 @@
/* Image lightbox close */
:root[data-skin="nous"] .img-lightbox-close:hover{background:rgba(70,130,180,.4);}
:root.dark[data-skin="nous"] .img-lightbox-close:hover{background:rgba(70,130,180,.4);}
:root[data-skin="nous"] .img-lightbox-nav:hover{background:rgba(70,130,180,.5);}
:root.dark[data-skin="nous"] .img-lightbox-nav:hover{background:rgba(70,130,180,.5);}
/* Diff blocks */
:root[data-skin="nous"] .diff-block .diff-plus{color:#4682B4;}
:root.dark[data-skin="nous"] .diff-block .diff-plus{color:#7EB6E0;}
@@ -1323,6 +1325,11 @@
.img-lightbox img{max-width:90vw;max-height:90vh;object-fit:contain;border-radius:8px;box-shadow:0 8px 48px rgba(0,0,0,.6);cursor:default;}
.img-lightbox-close{position:absolute;top:16px;right:20px;width:36px;height:36px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s;}
.img-lightbox-close:hover{background:rgba(255,255,255,.22);}
.img-lightbox-nav{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;border:none;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:28px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,opacity .15s;z-index:1;opacity:.6;}
.img-lightbox-nav:hover{background:rgba(255,255,255,.28);opacity:1;}
.img-lightbox-nav-prev{left:16px;}
.img-lightbox-nav-next{right:16px;}
.img-lightbox-counter{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,.5);color:#fff;font-size:14px;padding:4px 14px;border-radius:12px;pointer-events:none;}
.msg-media-link{display:inline-flex;align-items:center;gap:5px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);border-radius:6px;padding:4px 10px;font-size:13px;color:var(--accent-text);text-decoration:none;}
.msg-media-link:hover{background:var(--accent-bg-strong);}
+87 -5
View File
@@ -497,7 +497,34 @@ if(document.readyState==='complete'){
}
/* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */
function _openImgLightbox(src, alt) {
function _openImgLightbox(imgEl) {
// Backward-compat: if called with (src, alt) from cached old code, convert.
if(typeof imgEl==='string'){
const src=imgEl, alt=arguments[1]||'';
const oldEl=document.querySelector(`.img-lightbox[aria-label="${esc(alt||'Image')}"]`);
_openImgLightboxWithNav(src, alt, [], 0);
return;
}
if(!imgEl || !imgEl.src) return;
const src=imgEl.src, alt=imgEl.alt||'';
// Find sibling images in the same message for prev/next navigation.
// Walk up from the clicked image to find the message container, then
// collect all .msg-media-img within it.
let allImages = [];
let startIndex = 0;
let container = imgEl.closest('.msg-row, .assistant-turn-blocks, .assistant-turn, .user-turn');
if(!container) container = imgEl.parentElement;
if(container){
const siblings = container.querySelectorAll('.msg-media-img');
if(siblings.length>1){
allImages = Array.from(siblings);
startIndex = allImages.indexOf(imgEl);
if(startIndex===-1) startIndex=0;
}
}
_openImgLightboxWithNav(src, alt, allImages, startIndex);
}
function _openImgLightboxWithNav(src, alt, images, index) {
const lb = document.createElement('div');
lb.className = 'img-lightbox';
lb.setAttribute('role', 'dialog');
@@ -513,12 +540,67 @@ function _openImgLightbox(src, alt) {
cls.onclick = () => _closeImgLightbox(lb);
lb.appendChild(img);
lb.appendChild(cls);
// Prev/Next navigation
const hasNav = images && images.length>1;
let counter = null;
if(hasNav){
const prevBtn = document.createElement('button');
prevBtn.className = 'img-lightbox-nav img-lightbox-nav-prev';
prevBtn.setAttribute('aria-label', 'Previous image');
prevBtn.innerHTML = '';
prevBtn.onclick = e => { e.stopPropagation(); _navigateLightbox(lb, images, index, -1); };
lb.appendChild(prevBtn);
const nextBtn = document.createElement('button');
nextBtn.className = 'img-lightbox-nav img-lightbox-nav-next';
nextBtn.setAttribute('aria-label', 'Next image');
nextBtn.innerHTML = '';
nextBtn.onclick = e => { e.stopPropagation(); _navigateLightbox(lb, images, index, 1); };
lb.appendChild(nextBtn);
counter = document.createElement('div');
counter.className = 'img-lightbox-counter';
lb.appendChild(counter);
_updateLightboxCounter(counter, index, images.length);
}
lb.onclick = () => _closeImgLightbox(lb);
document.body.appendChild(lb);
// Close on Escape
lb._escHandler = e => { if(e.key==='Escape') _closeImgLightbox(lb); };
// Keyboard navigation + close
lb._escHandler = e => {
if(e.key==='Escape'){ _closeImgLightbox(lb); return; }
if(hasNav){
if(e.key==='ArrowLeft'){ e.preventDefault(); _navigateLightbox(lb, images, index, -1); }
if(e.key==='ArrowRight'){ e.preventDefault(); _navigateLightbox(lb, images, index, 1); }
}
};
document.addEventListener('keydown', lb._escHandler);
}
function _navigateLightbox(lb, images, currentIndex, direction) {
const newIndex = currentIndex + direction;
if(newIndex<0 || newIndex>=images.length) return;
const nextImg = images[newIndex];
const lbImg = lb.querySelector('img');
lbImg.src = nextImg.src;
lbImg.alt = nextImg.alt || '';
lb.setAttribute('aria-label', nextImg.alt || 'Image');
// Update navigation onclick handlers by replacing them
const prevBtn = lb.querySelector('.img-lightbox-nav-prev');
const nextBtn = lb.querySelector('.img-lightbox-nav-next');
if(prevBtn) prevBtn.onclick = e => { e.stopPropagation(); _navigateLightbox(lb, images, newIndex, -1); };
if(nextBtn) nextBtn.onclick = e => { e.stopPropagation(); _navigateLightbox(lb, images, newIndex, 1); };
// Update counter
const counter = lb.querySelector('.img-lightbox-counter');
if(counter) _updateLightboxCounter(counter, newIndex, images.length);
// Rebind keyboard handler
document.removeEventListener('keydown', lb._escHandler);
lb._escHandler = e => {
if(e.key==='Escape'){ _closeImgLightbox(lb); return; }
if(e.key==='ArrowLeft'){ e.preventDefault(); _navigateLightbox(lb, images, newIndex, -1); }
if(e.key==='ArrowRight'){ e.preventDefault(); _navigateLightbox(lb, images, newIndex, 1); }
};
document.addEventListener('keydown', lb._escHandler);
}
function _updateLightboxCounter(el, index, total) {
el.textContent = (index+1) + ' / ' + total;
}
function _closeImgLightbox(lb) {
if(!lb || !lb.parentNode) return;
document.removeEventListener('keydown', lb._escHandler);
@@ -530,14 +612,14 @@ document.addEventListener('click', e => {
if(!e.target || !e.target.closest) return;
// Message-attached images (already wired since v0.50.x).
let img = e.target.closest('.msg-media-img');
if(img){ _openImgLightbox(img.src, img.alt); return; }
if(img){ _openImgLightbox(img); return; }
// Composer attach-tray image thumbnails — click any pasted/dropped image
// chip to lightbox-zoom it before sending. Excludes audio/video chips,
// which keep their inline media controls. SVG thumbnails (.attach-thumb--svg)
// are still images visually, so they qualify.
img = e.target.closest('.attach-thumb');
if(img && img.tagName === 'IMG'){
_openImgLightbox(img.src, img.alt || img.title || 'Attached image');
_openImgLightbox(img);
return;
}
});
+1 -1
View File
@@ -46,7 +46,7 @@ class TestComposerChipLightboxDelegate:
src = UI.read_text(encoding="utf-8")
# The message-image branch must come first (so _openImgLightbox
# fires for them without falling through to the .attach-thumb check).
msg_branch = "let img = e.target.closest('.msg-media-img');\n if(img){ _openImgLightbox(img.src, img.alt); return; }"
msg_branch = "let img = e.target.closest('.msg-media-img');\n if(img){ _openImgLightbox(img); return; }"
assert msg_branch in src
def test_delegate_excludes_audio_video_chips(self):