143 lines
3.7 KiB
Svelte
143 lines
3.7 KiB
Svelte
<script lang="ts">
|
||
import ScrollerBase from '../ScrollerBase/ScrollerBase.svelte';
|
||
import type { ContainerWidth } from '../@types/global';
|
||
|
||
type Graphic = {
|
||
src: string;
|
||
srcset: string;
|
||
alt: string;
|
||
title?: string;
|
||
description?: string;
|
||
notes?: string;
|
||
width?: ContainerWidth;
|
||
};
|
||
|
||
interface Props {
|
||
graphics: Graphic[];
|
||
/** Duration of the squash animation in milliseconds. */
|
||
animationDuration?: number;
|
||
/** When to swap the image during the animation (0–1). E.g. 0.8 = swap at 80% through. */
|
||
animationSwap?: number;
|
||
/**
|
||
* Raw CSS string injected into the document head for this component instance.
|
||
* Use the `.graphic` selector to target the image element.
|
||
* Define any required @keyframes here too.
|
||
*
|
||
* @example
|
||
* graphicCss="@keyframes bounce { to { translate: 0 -20px; } } .graphic { animation: bounce 0.5s ease alternate infinite; }"
|
||
*/
|
||
graphicCss?: string;
|
||
}
|
||
|
||
let {
|
||
graphics,
|
||
animationDuration = 350,
|
||
animationSwap = 0.8,
|
||
graphicCss,
|
||
}: Props = $props();
|
||
|
||
let count = $state(1);
|
||
let index = $state(0);
|
||
let offset = $state(0);
|
||
let progress = $state(0);
|
||
|
||
let displayedIndex = $state(0);
|
||
let imgEl = $state<HTMLImageElement | null>(null);
|
||
|
||
// Inject graphicCss into the document head as a real style element.
|
||
$effect(() => {
|
||
if (!graphicCss) return;
|
||
const style = document.createElement('style');
|
||
style.textContent = graphicCss;
|
||
document.head.appendChild(style);
|
||
return () => style.remove();
|
||
});
|
||
|
||
// On index change: reset the CSS animation, then swap the graphic at the
|
||
// configured point through the animation (animationSwap).
|
||
$effect(() => {
|
||
const newIndex = index; // track reactive dependency
|
||
if (!imgEl) return;
|
||
|
||
imgEl.style.animation = 'none';
|
||
imgEl.offsetHeight; // force reflow to restart the animation
|
||
imgEl.style.animation = '';
|
||
|
||
const timeout = setTimeout(() => {
|
||
displayedIndex = newIndex;
|
||
}, animationDuration * animationSwap);
|
||
|
||
return () => clearTimeout(timeout);
|
||
});
|
||
</script>
|
||
|
||
<div class="photo-scroller">
|
||
<ScrollerBase
|
||
bind:count
|
||
bind:index
|
||
bind:offset
|
||
bind:progress
|
||
query="div.step-foreground-container"
|
||
>
|
||
{#snippet backgroundSnippet()}
|
||
<img
|
||
bind:this={imgEl}
|
||
class="graphic"
|
||
style="--animation-time: {animationDuration}ms"
|
||
src={graphics[displayedIndex].src}
|
||
srcset={graphics[displayedIndex].srcset}
|
||
sizes="(min-width: 690px) 660px, calc(100vw - 30px)"
|
||
alt={graphics[displayedIndex].alt}
|
||
/>
|
||
{/snippet}
|
||
|
||
{#snippet foregroundSnippet()}
|
||
{#each graphics as graphic}
|
||
<div class="step-foreground-container">
|
||
{#if graphic.title}<h2>{graphic.title}</h2>{/if}
|
||
{#if graphic.description}<p>{graphic.description}</p>{/if}
|
||
{#if graphic.notes}<aside>{graphic.notes}</aside>{/if}
|
||
</div>
|
||
{/each}
|
||
{/snippet}
|
||
</ScrollerBase>
|
||
</div>
|
||
|
||
<style lang="scss">
|
||
.graphic {
|
||
display: block;
|
||
object-fit: contain;
|
||
transform-origin: bottom center;
|
||
animation: squash var(--animation-time) linear(0, 0.417 25.5%, 0.867 49.4%, 1 57.7%, 0.925 65.1%, 0.908 68.6%, 0.902 72.2%, 0.916 78.2%, 0.988 92.1%, 1) forwards;
|
||
}
|
||
|
||
@keyframes squash {
|
||
0% {
|
||
scale: 1;
|
||
translate: 0;
|
||
}
|
||
|
||
50% {
|
||
scale: 0.8 1.2;
|
||
translate: 0 -8px;
|
||
}
|
||
|
||
80% {
|
||
scale: 1.4 0.6;
|
||
translate: 0 -16px;
|
||
}
|
||
|
||
100% {
|
||
scale: 1;
|
||
translate: 0;
|
||
}
|
||
}
|
||
|
||
.step-foreground-container {
|
||
height: 50vh;
|
||
padding: 1em;
|
||
margin: 0 0 2em 0;
|
||
max-width: var(--normal-column-width, 660px);
|
||
position: relative;
|
||
}
|
||
</style>
|