|
|
--- |
|
|
|
|
|
import { Image as AstroImage } 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, |
|
|
fullWidth, |
|
|
...imgProps |
|
|
} = Astro.props as Props; |
|
|
const hasCaptionSlot = Astro.slots.has("caption"); |
|
|
const hasCaption = |
|
|
hasCaptionSlot || (typeof caption === "string" && caption.length > 0); |
|
|
const hasTitle = Astro.slots.has("title"); |
|
|
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} |
|
|
data-has-title={hasTitle} |
|
|
data-has-caption={hasCaption} |
|
|
> |
|
|
{ |
|
|
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} |
|
|
> |
|
|
<AstroImage |
|
|
{...imgProps} |
|
|
data-zoomable={dataZoomable} |
|
|
data-downloadable={dataDownloadable} |
|
|
data-download-name={downloadName} |
|
|
data-download-src={downloadSrc} |
|
|
/> |
|
|
</a> |
|
|
) : ( |
|
|
<AstroImage |
|
|
{...imgProps} |
|
|
data-zoomable={dataZoomable} |
|
|
data-downloadable={dataDownloadable} |
|
|
data-download-name={downloadName} |
|
|
data-download-src={downloadSrc} |
|
|
/> |
|
|
)} |
|
|
<button |
|
|
type="button" |
|
|
class="button 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} |
|
|
> |
|
|
<AstroImage {...imgProps} data-zoomable={dataZoomable} /> |
|
|
</a> |
|
|
) : ( |
|
|
<AstroImage {...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} |
|
|
> |
|
|
<AstroImage |
|
|
{...imgProps} |
|
|
data-zoomable={dataZoomable} |
|
|
data-downloadable={dataDownloadable} |
|
|
data-download-name={downloadName} |
|
|
data-download-src={downloadSrc} |
|
|
/> |
|
|
</a> |
|
|
) : ( |
|
|
<AstroImage |
|
|
{...imgProps} |
|
|
data-zoomable={dataZoomable} |
|
|
data-downloadable={dataDownloadable} |
|
|
data-download-name={downloadName} |
|
|
data-download-src={downloadSrc} |
|
|
/> |
|
|
)} |
|
|
<button |
|
|
type="button" |
|
|
class="button 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} |
|
|
> |
|
|
<AstroImage {...imgProps} data-zoomable={dataZoomable} /> |
|
|
</a> |
|
|
) : ( |
|
|
<AstroImage {...imgProps} data-zoomable={dataZoomable} /> |
|
|
) |
|
|
} |
|
|
</div> |
|
|
|
|
|
<script is:inline> |
|
|
(() => { |
|
|
const scriptEl = document.currentScript; |
|
|
const root = scriptEl ? scriptEl.previousElementSibling : null; |
|
|
if (!root) { |
|
|
console.log("Figure script: No root element found, exiting"); |
|
|
return; |
|
|
} |
|
|
const img = |
|
|
root.tagName === "IMG" |
|
|
? root |
|
|
: root.querySelector |
|
|
? root.querySelector("img") |
|
|
: null; |
|
|
if (!img) { |
|
|
console.log("Figure script: No img element found, exiting"); |
|
|
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, |
|
|
container: { |
|
|
top: 0, |
|
|
right: 0, |
|
|
bottom: 0, |
|
|
left: 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 setupGlobalZoomBehavior = () => { |
|
|
img.addEventListener("click", () => { |
|
|
if (img.getAttribute("data-zoomable") === "1") { |
|
|
|
|
|
document |
|
|
.querySelectorAll(".ri-root.zoom-active") |
|
|
.forEach((el) => el.classList.remove("zoom-active")); |
|
|
|
|
|
|
|
|
root.classList.add("zoom-active"); |
|
|
} |
|
|
}); |
|
|
}; |
|
|
|
|
|
|
|
|
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 {} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
setupGlobalZoomBehavior(); |
|
|
|
|
|
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; |
|
|
background: var(--primary-color); |
|
|
} |
|
|
|
|
|
|
|
|
:global(.medium-zoom--opened) .ri-root { |
|
|
opacity: 0; |
|
|
z-index: calc(var(--z-base) - 1); |
|
|
transition: opacity 0.3s ease; |
|
|
} |
|
|
|
|
|
|
|
|
:global(.medium-zoom--opened) .ri-root:has(.medium-zoom--opened) { |
|
|
opacity: 1; |
|
|
z-index: var(--z-overlay); |
|
|
} |
|
|
|
|
|
|
|
|
:global(.medium-zoom--opened) .ri-root.zoom-active { |
|
|
opacity: 1 !important; |
|
|
z-index: var(--z-overlay) !important; |
|
|
} |
|
|
|
|
|
|
|
|
:global(.medium-zoom--opened) .img-dl-btn { |
|
|
opacity: 0; |
|
|
z-index: calc(var(--z-base) - 1); |
|
|
transition: opacity 0.3s ease; |
|
|
} |
|
|
|
|
|
:global(.medium-zoom--opened) figcaption { |
|
|
opacity: 0; |
|
|
z-index: calc(var(--z-base) - 1); |
|
|
transition: opacity 0.3s ease; |
|
|
} |
|
|
|
|
|
|
|
|
:global(.medium-zoom--opened) .ri-root.zoom-active .img-dl-btn { |
|
|
opacity: 0; |
|
|
z-index: calc(var(--z-base) - 1); |
|
|
} |
|
|
|
|
|
:global(.medium-zoom--opened) .ri-root.zoom-active figcaption { |
|
|
opacity: 0; |
|
|
z-index: calc(var(--z-base) - 1); |
|
|
} |
|
|
.img-dl-btn svg { |
|
|
width: 18px; |
|
|
height: 18px; |
|
|
fill: currentColor; |
|
|
} |
|
|
.img-dl-wrap:hover .img-dl-btn { |
|
|
display: inline-flex; |
|
|
} |
|
|
.img-dl-btn:hover { |
|
|
background: var(--primary-color-hover); |
|
|
} |
|
|
|
|
|
[data-theme="dark"] .img-dl-btn { |
|
|
background: var(--primary-color); |
|
|
color: var(--on-primary); |
|
|
border-color: var(--primary-color); |
|
|
} |
|
|
[data-theme="dark"] .img-dl-btn:hover { |
|
|
background: var(--primary-color-hover); |
|
|
} |
|
|
|
|
|
|
|
|
.ri-root:not([data-has-title="true"]) { |
|
|
margin-top: 20px; |
|
|
} |
|
|
|
|
|
.ri-root:not([data-has-caption="true"]) { |
|
|
margin-bottom: 20px; |
|
|
} |
|
|
|
|
|
|
|
|
.ri-root img { |
|
|
width: 100%; |
|
|
height: auto; |
|
|
max-width: 100%; |
|
|
} |
|
|
</style> |
|
|
|