Spaces:
Running
Running
| --- | |
| // @ts-ignore - types provided by Astro at runtime | |
| import { Image } from 'astro:assets'; | |
| interface Props { | |
| /** Source image imported via astro:assets */ | |
| src: any; | |
| /** Alt text for accessibility */ | |
| alt: string; | |
| /** Optional HTML string caption (use slot caption for rich content) */ | |
| caption?: string; | |
| /** Optional class to apply on the <figure> wrapper when caption is used */ | |
| figureClass?: string; | |
| /** Enable medium-zoom behavior on this image */ | |
| zoomable?: boolean; | |
| /** Show a download button overlay and enable download flow */ | |
| downloadable?: boolean; | |
| /** Optional explicit file name to use on download */ | |
| downloadName?: string; | |
| /** Optional explicit source URL to download instead of currentSrc */ | |
| downloadSrc?: string; | |
| /** Any additional attributes should be forwarded to the underlying <Image> */ | |
| []: any; | |
| } | |
| const { caption, figureClass, zoomable, downloadable, downloadName, downloadSrc, ...imgProps } = Astro.props as Props; | |
| const hasCaptionSlot = Astro.slots.has('caption'); | |
| const hasCaption = hasCaptionSlot || (typeof caption === 'string' && caption.length > 0); | |
| const uid = `ri_${Math.random().toString(36).slice(2)}`; | |
| const dataZoomable = (zoomable === true || (imgProps as any)['data-zoomable']) ? '1' : undefined; | |
| const dataDownloadable = (downloadable === true || (imgProps as any)['data-downloadable']) ? '1' : undefined; | |
| --- | |
| <div class="ri-root" data-ri-root={uid}> | |
| {hasCaption ? ( | |
| <figure class={(figureClass || '') + (dataDownloadable ? ' has-dl-btn' : '')}> | |
| {dataDownloadable ? ( | |
| <span class="img-dl-wrap"> | |
| <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} /> | |
| <button type="button" class="button button--ghost img-dl-btn" aria-label="Download image" title={downloadName ? `Download ${downloadName}` : 'Download image'}> | |
| <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z"/></svg> | |
| </button> | |
| </span> | |
| ) : ( | |
| <Image {...imgProps} data-zoomable={dataZoomable} /> | |
| )} | |
| <figcaption> | |
| {hasCaptionSlot ? ( | |
| <slot name="caption" /> | |
| ) : ( | |
| caption && <span set:html={caption} /> | |
| )} | |
| </figcaption> | |
| </figure> | |
| ) : ( | |
| dataDownloadable ? ( | |
| <span class="img-dl-wrap"> | |
| <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} /> | |
| <button type="button" class="button button--ghost img-dl-btn" aria-label="Download image" title={downloadName ? `Download ${downloadName}` : 'Download image'}> | |
| <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z"/></svg> | |
| </button> | |
| </span> | |
| ) : ( | |
| <Image {...imgProps} data-zoomable={dataZoomable} /> | |
| ) | |
| )} | |
| </div> | |
| <script is:inline> | |
| (() => { | |
| const scriptEl = document.currentScript; | |
| const root = scriptEl ? scriptEl.previousElementSibling : null; | |
| if (!root) return; | |
| const img = (root.tagName === 'IMG' ? root : (root.querySelector ? root.querySelector('img') : null)); | |
| if (!img) return; | |
| // medium-zoom integration scoped to this image only | |
| const ensureMediumZoomReady = (cb) => { | |
| // @ts-ignore | |
| if (window.mediumZoom) return cb(); | |
| const retry = () => { | |
| // @ts-ignore | |
| if (window.mediumZoom) cb(); else setTimeout(retry, 30); | |
| }; | |
| retry(); | |
| }; | |
| const initZoomIfNeeded = () => { | |
| if (img.getAttribute('data-zoomable') !== '1') return; | |
| const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; | |
| const background = isDark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)'; | |
| ensureMediumZoomReady(() => { | |
| // @ts-ignore | |
| const instance = window.mediumZoom ? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 }) : null; | |
| if (!instance) return; | |
| let onScrollLike; | |
| const attachCloseOnScroll = () => { | |
| if (onScrollLike) return; | |
| onScrollLike = () => { try { instance.close && instance.close(); } catch {} }; | |
| window.addEventListener('wheel', onScrollLike, { passive: true }); | |
| window.addEventListener('touchmove', onScrollLike, { passive: true }); | |
| window.addEventListener('scroll', onScrollLike, { passive: true }); | |
| }; | |
| const detachCloseOnScroll = () => { | |
| if (!onScrollLike) return; | |
| window.removeEventListener('wheel', onScrollLike); | |
| window.removeEventListener('touchmove', onScrollLike); | |
| window.removeEventListener('scroll', onScrollLike); | |
| onScrollLike = null; | |
| }; | |
| try { instance.on && instance.on('open', attachCloseOnScroll); } catch {} | |
| try { instance.on && instance.on('close', detachCloseOnScroll); } catch {} | |
| const themeObserver = new MutationObserver(() => { | |
| const dark = document.documentElement.getAttribute('data-theme') === 'dark'; | |
| try { instance.update && instance.update({ background: dark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)' }); } catch {} | |
| }); | |
| themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); | |
| }); | |
| }; | |
| // Download button handler | |
| const dlBtn = (root.querySelector ? root.querySelector('.img-dl-btn') : null); | |
| if (dlBtn) { | |
| dlBtn.addEventListener('click', async (ev) => { | |
| try { | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| const pickHrefAndName = () => { | |
| const current = img.currentSrc || img.src || ''; | |
| let href = img.getAttribute('data-download-src') || current; | |
| const deriveName = () => { | |
| try { | |
| const u = new URL(current, location.href); | |
| const rawHref = u.searchParams.get('href'); | |
| const candidate = rawHref ? decodeURIComponent(rawHref) : u.pathname; | |
| const last = String(candidate).split('/').pop() || ''; | |
| const base = last.split('?')[0].split('#')[0]; | |
| const m = base.match(/^(.+?\.(?:png|jpe?g|webp|avif|gif|svg))(?:[._-].*)?$/i); | |
| if (m && m[1]) return m[1]; | |
| return base || 'image'; | |
| } catch { return 'image'; } | |
| }; | |
| const name = img.getAttribute('data-download-name') || deriveName(); | |
| return { href, name }; | |
| }; | |
| const picked = pickHrefAndName(); | |
| const res = await fetch(picked.href, { credentials: 'same-origin' }); | |
| const blob = await res.blob(); | |
| const objectUrl = URL.createObjectURL(blob); | |
| const tmp = document.createElement('a'); | |
| tmp.href = objectUrl; | |
| tmp.download = picked.name || 'image'; | |
| tmp.target = '_self'; | |
| tmp.rel = 'noopener'; | |
| tmp.style.display = 'none'; | |
| document.body.appendChild(tmp); | |
| tmp.click(); | |
| setTimeout(() => { URL.revokeObjectURL(objectUrl); tmp.remove(); }, 1000); | |
| } catch {} | |
| }); | |
| } | |
| if (document.readyState === 'complete') initZoomIfNeeded(); | |
| else window.addEventListener('load', initZoomIfNeeded, { once: true }); | |
| })(); | |
| </script> | |
| <style> | |
| figure { margin: var(--block-spacing-y) 0; } | |
| figcaption { text-align: left; font-size: 0.9rem; color: var(--muted-color); margin-top: 6px; } | |
| .image-credit { display: block; margin-top: 4px; font-size: 12px; color: var(--muted-color); } | |
| .image-credit a { color: inherit; text-decoration: underline; text-underline-offset: 2px; } | |
| /* Zoomable overlay container (if used by any lightbox implementation) */ | |
| [], | |
| .zoom-overlay { | |
| position: fixed; | |
| inset: 0; | |
| z-index: var(--z-overlay); | |
| } | |
| /* Download link inside figures */ | |
| figure .download-link { position: relative; z-index: var(--z-elevated); } | |
| /* Opt-in zoomable images */ | |
| img[data-zoomable] { cursor: zoom-in; } | |
| .medium-zoom--opened img[data-zoomable] { cursor: zoom-out; } | |
| /* Download button for img[data-downloadable] */ | |
| figure.has-dl-btn { position: relative; } | |
| .dl-host { position: relative; } | |
| .img-dl-wrap { position: relative; display: inline-block; } | |
| .img-dl-btn { | |
| position: absolute; | |
| right: 8px; | |
| bottom: 8px; | |
| align-items: center; | |
| justify-content: center; | |
| width: 30px; | |
| height: 30px; | |
| border-radius: 6px; | |
| color: white; | |
| text-decoration: none; | |
| border: 1px solid rgba(255,255,255,0.25); | |
| z-index: var(--z-elevated); | |
| display: none; | |
| } | |
| .img-dl-btn svg { width: 18px; height: 18px; fill: currentColor; } | |
| .img-dl-wrap:hover .img-dl-btn { display: inline-flex; } | |
| [] .img-dl-btn { background: rgba(255,255,255,0.15); color: white; border-color: rgba(255,255,255,0.25); } | |
| [] .img-dl-btn:hover { background: rgba(255,255,255,0.25); } | |
| </style> |