mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-28 12:40:26 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user