| | --- |
| | |
| | import { Image } from 'astro:assets'; |
| |
|
| | interface Props { |
| | |
| | src: any; |
| | |
| | alt: string; |
| | |
| | caption?: string; |
| | |
| | figureClass?: string; |
| | |
| | zoomable?: boolean; |
| | |
| | downloadable?: boolean; |
| | |
| | downloadName?: string; |
| | |
| | downloadSrc?: string; |
| | |
| | linkHref?: string; |
| | |
| | linkTarget?: string; |
| | |
| | linkRel?: string; |
| | |
| | [key: string]: any; |
| | } |
| |
|
| | const { caption, figureClass, zoomable, downloadable, downloadName, downloadSrc, linkHref, linkTarget, linkRel, ...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; |
| | const hasLink = typeof linkHref === 'string' && linkHref.length > 0; |
| | const resolvedTarget = hasLink ? (linkTarget || '_blank') : undefined; |
| | const resolvedRel = hasLink ? (linkRel || 'noopener noreferrer') : undefined; |
| | --- |
| | <div class="ri-root" data-ri-root={uid}> |
| | {hasCaption ? ( |
| | <figure class={(figureClass || '') + (dataDownloadable ? ' has-dl-btn' : '')}> |
| | {dataDownloadable ? ( |
| | <span class="img-dl-wrap"> |
| | {hasLink ? ( |
| | <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}> |
| | <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} /> |
| | </a> |
| | ) : ( |
| | <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> |
| | ) : ( |
| | hasLink ? ( |
| | <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}> |
| | <Image {...imgProps} data-zoomable={dataZoomable} /> |
| | </a> |
| | ) : ( |
| | <Image {...imgProps} data-zoomable={dataZoomable} /> |
| | ) |
| | )} |
| | <figcaption> |
| | {hasCaptionSlot ? ( |
| | <slot name="caption" /> |
| | ) : ( |
| | caption && <span set:html={caption} /> |
| | )} |
| | </figcaption> |
| | </figure> |
| | ) : ( |
| | dataDownloadable ? ( |
| | <span class="img-dl-wrap"> |
| | {hasLink ? ( |
| | <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}> |
| | <Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} /> |
| | </a> |
| | ) : ( |
| | <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> |
| | ) : ( |
| | hasLink ? ( |
| | <a class="ri-link" href={linkHref} target={resolvedTarget} rel={resolvedRel}> |
| | <Image {...imgProps} data-zoomable={dataZoomable} /> |
| | </a> |
| | ) : ( |
| | <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; |
| | |
| | |
| | const ensureMediumZoomReady = (cb) => { |
| | |
| | if (window.mediumZoom) return cb(); |
| | const retry = () => { |
| | |
| | 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(() => { |
| | |
| | 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'] }); |
| | }); |
| | }; |
| | |
| | |
| | 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; } |
| | figcaption { background: var(--page-bg); position: relative; z-index: var(--z-elevated); display: block; width: 100%; } |
| | .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; } |
| | |
| | |
| | [data-zoom-overlay], |
| | .zoom-overlay { |
| | position: fixed; |
| | inset: 0; |
| | z-index: var(--z-overlay); |
| | } |
| | |
| | |
| | figure .download-link { position: relative; z-index: var(--z-elevated); } |
| | |
| | |
| | img[data-zoomable] { cursor: zoom-in; } |
| | .medium-zoom--opened img[data-zoomable] { cursor: zoom-out; } |
| | |
| | |
| | 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; } |
| | [data-theme="dark"] .img-dl-btn { background: rgba(255,255,255,0.15); color: white; border-color: rgba(255,255,255,0.25); } |
| | [data-theme="dark"] .img-dl-btn:hover { background: rgba(255,255,255,0.25); } |
| | </style> |