From ecf7ca7c60becc679729a8461dcbe2e4af26f969 Mon Sep 17 00:00:00 2001 From: weiwei83 Date: Tue, 26 May 2026 15:16:03 +0800 Subject: [PATCH] feat: add prev/next navigation to image lightbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- CHANGELOG.md | 4 ++ static/style.css | 7 +++ static/ui.js | 92 ++++++++++++++++++++++++++-- tests/test_composer_chip_lightbox.py | 2 +- 4 files changed, 99 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af702c4..69667375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/style.css b/static/style.css index 7ff52d11..3e93cfff 100644 --- a/static/style.css +++ b/static/style.css @@ -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);} diff --git a/static/ui.js b/static/ui.js index 81152feb..f3d7ef82 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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; } }); diff --git a/tests/test_composer_chip_lightbox.py b/tests/test_composer_chip_lightbox.py index dbf04334..36549db0 100644 --- a/tests/test_composer_chip_lightbox.py +++ b/tests/test_composer_chip_lightbox.py @@ -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):