FineVision / app /src /components /ResponsiveImage.astro
thibaud frere
fix charts, update html embed and responsive image description background, fix pdf generation
e063015
---
// @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> */
[key: string]: 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; }
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; }
/* Zoomable overlay container (if used by any lightbox implementation) */
[data-zoom-overlay],
.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; }
[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>