Merge pull request #1 from d0zingcat/feat/image-gallery

This commit is contained in:
2026-04-22 22:39:30 +08:00
committed by GitHub
3 changed files with 259 additions and 42 deletions

View File

@@ -110,3 +110,77 @@ const commentsClass = 'ml-[3px] border-l-2 border-line pb-6 pl-[15px] pt-[6px] s
)
}
</article>
<script>
function showGalleryImage(modal: HTMLElement, index: number): void {
const images = modal.querySelectorAll<HTMLElement>('.gallery__img')
images.forEach((img, i) => {
img.toggleAttribute('hidden', i !== index)
})
modal.dataset.current = String(index)
const counter = modal.querySelector('.gallery__counter')
if (counter) {
counter.textContent = `${index + 1} / ${images.length}`
}
}
function navigateGallery(modal: HTMLElement, dir: number): void {
const images = modal.querySelectorAll('.gallery__img')
const current = Number.parseInt(modal.dataset.current ?? '0', 10)
const next = (current + dir + images.length) % images.length
showGalleryImage(modal, next)
}
document.addEventListener('click', (e: MouseEvent) => {
const target = e.target as Element
const trigger = target.closest<HTMLElement>('.image-gallery__trigger')
if (trigger) {
const galleryId = trigger.dataset.gallery
const startIndex = Number.parseInt(trigger.dataset.index ?? '0', 10)
if (!galleryId) {
return
}
const modal = document.getElementById(galleryId) as HTMLElement | null
if (!modal) {
return
}
// Set active image before native popovertarget opens the modal
showGalleryImage(modal, startIndex)
return
}
const prevBtn = target.closest('.gallery__prev')
if (prevBtn) {
const modal = prevBtn.closest<HTMLElement>('.image-gallery__modal')
if (modal) {
navigateGallery(modal, -1)
}
return
}
const nextBtn = target.closest('.gallery__next')
if (nextBtn) {
const modal = nextBtn.closest<HTMLElement>('.image-gallery__modal')
if (modal) {
navigateGallery(modal, 1)
}
}
})
document.addEventListener('keydown', (e: KeyboardEvent) => {
const modal = document.querySelector<HTMLElement>('.image-gallery__modal:popover-open')
if (!modal) {
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
navigateGallery(modal, -1)
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
navigateGallery(modal, 1)
}
})
</script>

View File

@@ -235,59 +235,90 @@ function getImageStickers($: CheerioAPI, message: MessageSelection, options: Ind
function getImages($: CheerioAPI, message: MessageSelection, options: MessageAssetOptions): string {
const { staticProxy = '', id = '', index = 0, title = '' } = options
const fragments: string[] = []
const loading = getImageLoading(index)
const safeTitle = escapeHtmlAttribute(title || 'Image from post')
const safePreviewLabel = escapeHtmlAttribute(title ? `Open image preview: ${title}` : 'Open image preview')
const safeCloseLabel = 'Close image preview'
for (const [photoIndex, photoNode] of message.find('.tgme_widget_message_photo_wrap').toArray().entries()) {
interface PhotoData { imageUrl: string, width: number, height: number }
const photos: PhotoData[] = []
for (const photoNode of message.find('.tgme_widget_message_photo_wrap').toArray()) {
const imageUrl = $(photoNode).attr('style')?.match(STYLE_URL_REGEX)?.[1]
if (!imageUrl) {
if (!imageUrl)
continue
}
const popoverId = `modal-${id}-${photoIndex}`
const { width, height } = inferImageDimensions($, photoNode)
fragments.push(`
<button
type="button"
class="image-preview-button image-preview-wrap"
popovertarget="${popoverId}"
popovertargetaction="show"
aria-label="${safePreviewLabel}"
>
<img src="${staticProxy + imageUrl}" alt="${safeTitle}" width="${width}" height="${height}" loading="${loading}" />
</button>
<div class="modal" id="${popoverId}" popover aria-label="Image preview">
<button
type="button"
class="modal__backdrop"
popovertarget="${popoverId}"
popovertargetaction="hide"
aria-label="${safeCloseLabel}"
></button>
<button
type="button"
class="modal__close"
popovertarget="${popoverId}"
popovertargetaction="hide"
aria-label="${safeCloseLabel}"
>&times;</button>
<div class="modal__surface">
<img class="modal-img" src="${staticProxy + imageUrl}" alt="${safeTitle}" width="${width}" height="${height}" loading="lazy" />
</div>
</div>
`)
photos.push({ imageUrl, width, height })
}
if (!fragments.length) {
if (!photos.length) {
return ''
}
const layoutClass = fragments.length % 2 === 0 ? 'image-list-even' : 'image-list-odd'
return `<div class="image-list-container ${layoutClass}">${fragments.join('')}</div>`
// Single image: keep existing per-image popover behavior
if (photos.length === 1) {
const { imageUrl, width, height } = photos[0]
const popoverId = `modal-${id}-0`
const safePreviewLabel = escapeHtmlAttribute(title ? `Open image preview: ${title}` : 'Open image preview')
return `
<div class="image-list-container image-list-odd">
<button
type="button"
class="image-preview-button image-preview-wrap"
popovertarget="${popoverId}"
popovertargetaction="show"
aria-label="${safePreviewLabel}"
>
<img src="${staticProxy + imageUrl}" alt="${safeTitle}" width="${width}" height="${height}" loading="${loading}" />
</button>
<div class="modal" id="${popoverId}" popover aria-label="Image preview">
<button type="button" class="modal__backdrop" popovertarget="${popoverId}" popovertargetaction="hide" aria-label="${safeCloseLabel}"></button>
<button type="button" class="modal__close" popovertarget="${popoverId}" popovertargetaction="hide" aria-label="${safeCloseLabel}">&times;</button>
<div class="modal__surface">
<img class="modal-img" src="${staticProxy + imageUrl}" alt="${safeTitle}" width="${width}" height="${height}" loading="lazy" />
</div>
</div>
</div>
`
}
// Multiple images: single shared gallery modal with thumbnail grid
const galleryId = `gallery-${id}`
const count = photos.length
// 2 or 4 photos → 2-column; everything else → 3-column (matches Telegram)
const colClass = count === 2 || count === 4 ? 'image-gallery-2col' : 'image-gallery-3col'
const thumbnailButtons = photos.map(({ imageUrl, width, height }, i) => {
const safeGalleryLabel = escapeHtmlAttribute(`Open image gallery, image ${i + 1} of ${count}`)
return `
<button
type="button"
class="image-preview-button image-preview-wrap image-gallery__trigger"
popovertarget="${galleryId}"
popovertargetaction="show"
data-gallery="${galleryId}"
data-index="${i}"
aria-label="${safeGalleryLabel}"
>
<img class="gallery__thumb" src="${staticProxy + imageUrl}" alt="${safeTitle}" width="${width}" height="${height}" loading="${loading}" />
</button>`
}).join('')
const galleryImages = photos.map(({ imageUrl, width, height }, i) =>
`<img class="modal-img gallery__img" src="${staticProxy + imageUrl}" alt="${safeTitle}" width="${width}" height="${height}" loading="lazy"${i > 0 ? ' hidden' : ''} />`,
).join('')
return `
<div class="image-gallery ${colClass}">${thumbnailButtons}
</div>
<div class="modal image-gallery__modal" id="${galleryId}" data-current="0" popover aria-label="Image gallery">
<button type="button" class="modal__backdrop" popovertarget="${galleryId}" popovertargetaction="hide" aria-label="${safeCloseLabel}"></button>
<button type="button" class="modal__close" popovertarget="${galleryId}" popovertargetaction="hide" aria-label="${safeCloseLabel}">&times;</button>
<button type="button" class="gallery__nav gallery__prev" aria-label="Previous image">&#8249;</button>
<button type="button" class="gallery__nav gallery__next" aria-label="Next image">&#8250;</button>
<div class="modal__surface">${galleryImages}</div>
<p class="gallery__counter" aria-live="polite">1 / ${count}</p>
</div>
`
}
function getVideo($: CheerioAPI, message: MessageSelection, options: IndexedStaticProxyOptions): string {

View File

@@ -100,7 +100,7 @@
line-break: anywhere;
}
img:not(.tg-emoji):not(.sticker):not(.link_preview_image):not(.modal-img) {
img:not(.tg-emoji):not(.sticker):not(.link_preview_image):not(.modal-img):not(.gallery__thumb) {
width: calc(100% - var(--box-margin));
max-width: calc(100% - 1px);
max-height: initial;
@@ -250,6 +250,114 @@
margin-bottom: 0;
}
/* Compact Telegram-style photo grid */
.image-gallery {
display: grid;
gap: 2px;
border-radius: var(--radius-media);
overflow: hidden;
width: calc(100% - var(--box-margin));
max-width: calc(100% - 1px);
margin-bottom: 12px;
}
.image-gallery.image-gallery-2col {
grid-template-columns: repeat(2, 1fr);
}
.image-gallery.image-gallery-3col {
grid-template-columns: repeat(3, 1fr);
}
.image-gallery__trigger {
display: block;
overflow: hidden;
border-radius: 0;
margin-bottom: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
appearance: none;
}
.image-gallery__trigger img {
display: block;
width: 100%;
height: 100%;
aspect-ratio: 1;
object-fit: cover;
transition: transform 0.2s ease;
}
@media (prefers-reduced-motion: reduce) {
.image-gallery__trigger img {
transition: none;
}
}
.image-gallery__trigger:hover img,
.image-gallery__trigger:focus-visible img {
transform: scale(1.04);
}
.image-gallery__trigger:focus-visible {
outline: 2px solid var(--color-heading);
outline-offset: -2px;
}
.gallery__img[hidden] {
display: none;
}
.gallery__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 20;
width: 48px;
height: 48px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 36px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
user-select: none;
padding: 0;
}
.gallery__nav:hover {
background: rgba(0, 0, 0, 0.8);
}
.gallery__prev {
left: max(16px, env(safe-area-inset-left, 16px));
}
.gallery__next {
right: max(16px, env(safe-area-inset-right, 16px));
}
.gallery__counter {
position: absolute;
bottom: max(16px, env(safe-area-inset-bottom, 16px));
left: 50%;
transform: translateX(-50%);
z-index: 20;
color: #fff;
font-size: 13px;
background: rgba(0, 0, 0, 0.5);
padding: 3px 12px;
border-radius: 9999px;
margin: 0;
white-space: nowrap;
}
.tgme_widget_message_link_preview {
margin-top: 16px;
display: none;
@@ -548,6 +656,10 @@
cursor: pointer;
}
.image-preview-button.image-gallery__trigger {
margin-bottom: 0;
}
.image-preview-button:focus-visible {
outline: 2px solid var(--color-heading);
outline-offset: 4px;