40b2d406ae
`arValue` (height computation) and `arString` (URL `ar=` param) used to read from different sources, so the URL could request a different aspect than the `<img>` element reserved — caused a visible flicker when the transformed image loaded. Both now derive from `arString`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
146 lines
4.5 KiB
Svelte
146 lines
4.5 KiB
Svelte
<script lang="ts">
|
||
/**
|
||
* Smart CMS-Image-Component.
|
||
* - Automatisches AVIF→WebP→JPEG `<picture>`.
|
||
* - Retina-srcset per `densities` (Default [1, 2]).
|
||
* - CLS=0 via `width`+`height` (oder aus `aspect` abgeleitet).
|
||
* - Focal durchgereicht (cover-Crop am Motiv).
|
||
*
|
||
* `src` = rohe CMS-URL (ohne /cms-images-Wrapper). Die Component baut
|
||
* die transformierten /cms-images?…-URLs selbst.
|
||
*/
|
||
import { getCmsImageUrl, type RustyImageTransformParams } from "$lib/rusty-image";
|
||
|
||
interface Props {
|
||
/** Rohe CMS-URL (z.B. https://cms.pm86.de/api/assets/...). */
|
||
src: string;
|
||
/** Ziel-Renderbreite in CSS-px (für Attribut + srcset-Basis). */
|
||
width: number;
|
||
/** Ziel-Renderhöhe; alternativ `aspect`. Mindestens eins muss gegeben sein. */
|
||
height?: number;
|
||
/** Seitenverhältnis, z.B. "16/9", "3/2". Alternativ zu `height`. */
|
||
aspect?: string;
|
||
/** Focal-Point aus CMS-Image-Field. */
|
||
focal?: { x: number; y: number } | null;
|
||
alt?: string;
|
||
/** `sizes`-Attribut für Media-Queries; default `${width}px`. */
|
||
sizes?: string;
|
||
loading?: "lazy" | "eager";
|
||
fetchpriority?: "high" | "auto" | "low";
|
||
decoding?: "async" | "sync" | "auto";
|
||
class?: string;
|
||
style?: string;
|
||
/** Pixel-Dichten fürs srcset. Default [1, 2] (Retina). */
|
||
densities?: number[];
|
||
/** Format-Fallback-Kette. Default ['webp','jpeg']. AVIF bewusst ausgelassen — server-seitiger Encoder ist 5-10× langsamer als WebP, Bandbreitengewinn marginal. */
|
||
formats?: ("avif" | "webp" | "jpeg")[];
|
||
/** JPEG/WebP-Qualität. Default 75. */
|
||
quality?: number;
|
||
fit?: "cover" | "contain" | "fill";
|
||
/** Cache-Bust-Tag (z.B. post._updated) → `_v`-Param, bricht Browser-HTTP-Cache. */
|
||
version?: string;
|
||
}
|
||
|
||
let {
|
||
src,
|
||
width,
|
||
height,
|
||
aspect,
|
||
focal = null,
|
||
alt = "",
|
||
sizes,
|
||
loading = "lazy",
|
||
fetchpriority,
|
||
decoding = "async",
|
||
class: className = "",
|
||
style,
|
||
densities = [1, 2],
|
||
formats = ["webp", "jpeg"],
|
||
quality = 75,
|
||
fit = "cover",
|
||
version,
|
||
}: Props = $props();
|
||
|
||
function parseAr(s: string | undefined): number | null {
|
||
if (!s) return null;
|
||
const m = s.match(/^(\d+(?:\.\d+)?)\s*[/:x]\s*(\d+(?:\.\d+)?)$/);
|
||
if (!m) return null;
|
||
const w = Number(m[1]);
|
||
const h = Number(m[2]);
|
||
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
|
||
return w / h;
|
||
}
|
||
|
||
// Derive aspect from either the explicit `aspect` prop or the width/height pair.
|
||
// Both `arString` (URL `ar=` param) and `arValue` (height-derivation) must read
|
||
// from the SAME source — otherwise the URL requests a different shape than the
|
||
// `<img>` reserves and the image flickers when it loads.
|
||
const arString = $derived(
|
||
aspect ??
|
||
(height && width ? `${width}/${height}` : undefined),
|
||
);
|
||
const arValue = $derived(parseAr(arString));
|
||
const computedHeight = $derived(
|
||
height ?? (arValue ? Math.round(width / arValue) : undefined),
|
||
);
|
||
const sizesAttr = $derived(sizes ?? `${width}px`);
|
||
|
||
function baseParams(format: "avif" | "webp" | "jpeg", w: number): RustyImageTransformParams {
|
||
return {
|
||
width: w,
|
||
format,
|
||
fit,
|
||
quality,
|
||
...(arString ? { ar: arString } : {}),
|
||
...(focal ? { focalX: focal.x, focalY: focal.y } : {}),
|
||
...(arString && fit !== "contain"
|
||
? { height: Math.round(w / (arValue ?? 1)) }
|
||
: {}),
|
||
...(version ? { version } : {}),
|
||
};
|
||
}
|
||
|
||
function buildSrcset(format: "avif" | "webp" | "jpeg"): string {
|
||
return densities
|
||
.map((d) => {
|
||
const w = Math.round(width * d);
|
||
return `${getCmsImageUrl(src, baseParams(format, w))} ${d}x`;
|
||
})
|
||
.join(", ");
|
||
}
|
||
|
||
const fallbackFormat = $derived<"avif" | "webp" | "jpeg">(
|
||
formats.includes("jpeg") ? "jpeg" : formats[formats.length - 1] ?? "jpeg",
|
||
);
|
||
const fallbackUrl = $derived(
|
||
getCmsImageUrl(src, baseParams(fallbackFormat, width)),
|
||
);
|
||
const fallbackSrcset = $derived(buildSrcset(fallbackFormat));
|
||
|
||
const pictureSources = $derived(
|
||
formats.filter((f) => f !== fallbackFormat).map((f) => ({
|
||
type: `image/${f}`,
|
||
srcset: buildSrcset(f),
|
||
})),
|
||
);
|
||
</script>
|
||
|
||
<picture>
|
||
{#each pictureSources as s (s.type)}
|
||
<source type={s.type} srcset={s.srcset} sizes={sizesAttr} />
|
||
{/each}
|
||
<img
|
||
src={fallbackUrl}
|
||
srcset={fallbackSrcset}
|
||
sizes={sizesAttr}
|
||
{alt}
|
||
width={width}
|
||
height={computedHeight}
|
||
class={className}
|
||
{style}
|
||
{loading}
|
||
{decoding}
|
||
{fetchpriority}
|
||
/>
|
||
</picture>
|