Files
windwiderstand/src/lib/components/RustyImage.svelte
T
Peter Meier 40b2d406ae fix(rusty-image): derive arValue from arString for layout match
`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>
2026-04-28 13:20:59 +02:00

146 lines
4.5 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>