forked from cms/tibi-svelte-starter
221 lines
7.4 KiB
Svelte
221 lines
7.4 KiB
Svelte
<script lang="ts">
|
|
import { apiBaseURL } from "../config"
|
|
import { currentLanguage, DEFAULT_LANGUAGE } from "../lib/i18n"
|
|
import { apiBaseOverride } from "../lib/store"
|
|
import { resolveApiAssetUrl } from "../lib/utils"
|
|
|
|
interface Props {
|
|
id?: string
|
|
entry?: MedialibEntry | null
|
|
filter?: string | null
|
|
noPlaceholder?: boolean
|
|
alt?: string
|
|
caption?: string
|
|
showCaption?: boolean
|
|
minWidth?: number
|
|
widthMultiplier?: number
|
|
lazy?: boolean
|
|
class?: string
|
|
style?: string
|
|
}
|
|
|
|
let {
|
|
id = "",
|
|
entry = null,
|
|
filter = null,
|
|
noPlaceholder = false,
|
|
alt = "",
|
|
caption = "",
|
|
showCaption = false,
|
|
minWidth = 0,
|
|
widthMultiplier = 1,
|
|
lazy = false,
|
|
class: className = "",
|
|
style = "",
|
|
}: Props = $props()
|
|
|
|
let imgEl = $state<HTMLImageElement | null>(null)
|
|
let currentFilter = $state<string>("l-webp")
|
|
const effectiveId = $derived(entry?.id || id || "")
|
|
const fileSrc = $derived(resolveFileSrc(entry?.file?.src, entry?.id || effectiveId))
|
|
const placeholderSrc = $derived(
|
|
resolveApiAssetUrl("/assets/img/placeholder-image.svg") || "/assets/img/placeholder-image.svg"
|
|
)
|
|
|
|
$effect(() => {
|
|
if (filter) {
|
|
currentFilter = filter
|
|
return
|
|
}
|
|
|
|
if (minWidth) {
|
|
currentFilter = getFilterForWidth(minWidth * (widthMultiplier || 1))
|
|
}
|
|
})
|
|
|
|
function getMeasuredWidth(node: HTMLImageElement): number {
|
|
const pictureElement = node.closest("picture") as HTMLElement | null
|
|
const figureElement = node.closest("figure") as HTMLElement | null
|
|
const widthCandidates = [
|
|
node.getBoundingClientRect().width,
|
|
node.width,
|
|
node.parentElement?.getBoundingClientRect().width || 0,
|
|
pictureElement?.getBoundingClientRect().width || 0,
|
|
figureElement?.getBoundingClientRect().width || 0,
|
|
]
|
|
const measuredWidth = Math.max(...widthCandidates)
|
|
const width = minWidth ? Math.max(measuredWidth, minWidth) : measuredWidth
|
|
return width * (widthMultiplier || 1)
|
|
}
|
|
|
|
function getFilterForWidth(imgWidth: number): string {
|
|
const effectiveWidth = imgWidth
|
|
|
|
if (effectiveWidth <= 90) return "xs-webp"
|
|
if (effectiveWidth <= 300) return "s-webp"
|
|
if (effectiveWidth <= 600) return "m-webp"
|
|
if (effectiveWidth <= 1200) return "l-webp"
|
|
if (effectiveWidth <= 2000) return "xl-webp"
|
|
return "xxl-webp"
|
|
}
|
|
|
|
function clampFocalPoint(value?: number): number | null {
|
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
return null
|
|
}
|
|
|
|
return Math.min(1, Math.max(0, value))
|
|
}
|
|
|
|
function getFocalPoint(entry: MedialibEntry | null): { x?: number; y?: number } | null {
|
|
const fileWithFocalPoint = entry?.file as { focalPoint?: { x?: number; y?: number } } | undefined
|
|
return fileWithFocalPoint?.focalPoint || null
|
|
}
|
|
|
|
function getObjectPosition(entry: MedialibEntry | null): string | null {
|
|
const focalPoint = getFocalPoint(entry)
|
|
const x = clampFocalPoint(focalPoint?.x)
|
|
const y = clampFocalPoint(focalPoint?.y)
|
|
|
|
if (x === null || y === null) {
|
|
return "50% 50%"
|
|
}
|
|
|
|
return `${x * 100}% ${y * 100}%`
|
|
}
|
|
|
|
function isRasterImage(entry: MedialibEntry | null): boolean {
|
|
if (entry?.file?.type?.match(/^image\/(png|jpe?g|webp)/)) return true
|
|
// Fallback: check file extension when MIME type is missing (e.g. public projection)
|
|
if (entry?.file?.src?.match(/\.(jpe?g|png|webp)$/i)) return true
|
|
return false
|
|
}
|
|
|
|
function resolveFileSrc(src: string | undefined, entryId: string | undefined): string | null {
|
|
if (!src) return null
|
|
if (
|
|
/^(?:https?:)?\/\//.test(src) ||
|
|
src.startsWith("/") ||
|
|
src.startsWith("data:") ||
|
|
src.startsWith("blob:")
|
|
) {
|
|
return src
|
|
}
|
|
if (!entryId) return null
|
|
const normalizedApiBase = ($apiBaseOverride || apiBaseURL).replace(/\/+$/, "")
|
|
return `${normalizedApiBase}/medialib/${entryId}/${src.replace(/^\/+/, "")}`
|
|
}
|
|
|
|
$effect(() => {
|
|
const el = imgEl
|
|
if (!el || filter || !entry || !isRasterImage(entry)) return
|
|
if (typeof ResizeObserver === "undefined") return
|
|
|
|
let maxObservedWidth = 0
|
|
let deferredUpdateFrame: number | null = null
|
|
|
|
const updateFilter = () => {
|
|
const width = getMeasuredWidth(el)
|
|
if (width <= maxObservedWidth) return
|
|
maxObservedWidth = width
|
|
currentFilter = getFilterForWidth(width)
|
|
}
|
|
|
|
const observer = new ResizeObserver(updateFilter)
|
|
const observedElements = [el, el.parentElement, el.closest("picture"), el.closest("figure")].filter(
|
|
(element, index, all) => element && all.indexOf(element) === index
|
|
) as Element[]
|
|
|
|
observedElements.forEach((element) => observer.observe(element))
|
|
updateFilter()
|
|
deferredUpdateFrame = requestAnimationFrame(() => {
|
|
deferredUpdateFrame = null
|
|
updateFilter()
|
|
})
|
|
|
|
return () => {
|
|
observer.disconnect()
|
|
if (deferredUpdateFrame !== null) {
|
|
cancelAnimationFrame(deferredUpdateFrame)
|
|
}
|
|
}
|
|
})
|
|
|
|
function getSrc(src: string | null, entry: MedialibEntry | null): string {
|
|
if (!src) return placeholderSrc
|
|
if (!isRasterImage(entry)) return src
|
|
if (filter) return src + `?filter=${filter}`
|
|
return src + `?filter=${currentFilter}`
|
|
}
|
|
|
|
function resolveLocalizedText(value: string | LocalizedText | undefined, lang: string): string {
|
|
if (!value) return ""
|
|
if (typeof value === "string") return value
|
|
|
|
return value[lang] || value[DEFAULT_LANGUAGE] || Object.values(value).find((entry) => !!entry) || ""
|
|
}
|
|
|
|
function getAltText(entry: MedialibEntry | null): string {
|
|
return alt || resolveLocalizedText(entry?.alt, $currentLanguage)
|
|
}
|
|
</script>
|
|
|
|
{#if entry && fileSrc}
|
|
{#if showCaption && caption}
|
|
<figure>
|
|
<picture>
|
|
<img
|
|
bind:this={imgEl}
|
|
src={getSrc(fileSrc, entry)}
|
|
alt={getAltText(entry)}
|
|
data-entry-id={effectiveId}
|
|
class={className}
|
|
style:object-position={getObjectPosition(entry)}
|
|
loading={lazy ? "lazy" : undefined}
|
|
{style}
|
|
/>
|
|
</picture>
|
|
<figcaption class="mt-2 text-sm text-gray-500 text-center italic">
|
|
{@html caption}
|
|
</figcaption>
|
|
</figure>
|
|
{:else}
|
|
<picture>
|
|
<img
|
|
bind:this={imgEl}
|
|
src={getSrc(fileSrc, entry)}
|
|
alt={getAltText(entry)}
|
|
data-entry-id={effectiveId}
|
|
class={className}
|
|
style:object-position={getObjectPosition(entry)}
|
|
loading={lazy ? "lazy" : undefined}
|
|
{style}
|
|
/>
|
|
</picture>
|
|
{/if}
|
|
{:else if !noPlaceholder && (effectiveId || entry)}
|
|
<picture>
|
|
<img src={placeholderSrc} alt="not found" data-entry-id={effectiveId} class={className} />
|
|
</picture>
|
|
{/if}
|