adds ScrollerLottie

This commit is contained in:
Sudev Kiyada 2025-09-26 14:32:55 +05:30
parent 041bbeabeb
commit 2d211ab5d3
Failed to extract signature
17 changed files with 1599 additions and 0 deletions

View file

@ -98,6 +98,7 @@
"dependencies": {
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@lottiefiles/dotlottie-web": "^0.52.2",
"@reuters-graphics/svelte-markdown": "^0.0.3",
"@sveltejs/kit": "^2.0.0",
"dayjs": "^1.11.13",

View file

@ -14,6 +14,9 @@ importers:
'@fortawesome/free-solid-svg-icons':
specifier: ^6.7.2
version: 6.7.2
'@lottiefiles/dotlottie-web':
specifier: ^0.52.2
version: 0.52.2
'@reuters-graphics/svelte-markdown':
specifier: ^0.0.3
version: 0.0.3(svelte@5.28.1)
@ -569,6 +572,9 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@lottiefiles/dotlottie-web@0.52.2':
resolution: {integrity: sha512-aeXCMUB5RdusHrvZ3Py2KaMgQ0w7SdA8NFbPK+SpwqC1UW1CDFZl5vPLueZHju7vLhB1rpPvpQ5fQ0L/KEZt7w==}
'@manypkg/find-root@1.1.0':
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
@ -4641,6 +4647,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@lottiefiles/dotlottie-web@0.52.2': {}
'@manypkg/find-root@1.1.0':
dependencies:
'@babel/runtime': 7.27.0

View file

@ -69,6 +69,17 @@ export type ScrollerVideoForegroundPosition =
| 'center left'
| 'center right';
export type ScrollerLottieForegroundPosition =
| 'top center'
| 'top left'
| 'top right'
| 'bottom center'
| 'bottom left'
| 'bottom right'
| 'center center'
| 'center left'
| 'center right';
// Complete ScrollerVideo instance interface
export interface ScrollerVideoInstance {
// Properties

View file

@ -0,0 +1,271 @@
<script lang="ts">
const { componentState } = $props();
let isMoving = $state(false);
let preventDetails = $state(false);
let position = $state({ x: 8, y: 8 });
function onMouseDown(e: MouseEvent) {
isMoving = true;
e.preventDefault();
}
function onMouseMove(e: MouseEvent) {
if (isMoving) {
position = {
x: position.x + e.movementX,
y: position.y + e.movementY,
};
preventDetails = true;
}
e.preventDefault();
}
function onMouseUp(e: MouseEvent) {
if (isMoving) {
isMoving = false;
setTimeout(() => {
preventDetails = false;
}, 5);
e.stopImmediatePropagation();
}
e.preventDefault();
}
function onClick(e: MouseEvent) {
if (preventDetails) {
e.preventDefault();
}
isMoving = false;
}
</script>
<svelte:window onmousemove={onMouseMove} />
<div
style="position: absolute; top: {position.y}px; left: {position.x}px; z-index: 5; user-select: none;"
role="region"
>
<details class="debug-info" open>
<summary
class="text-xxs font-sans font-bold title"
style="grid-column: span 2;"
onmousedown={onMouseDown}
onmouseup={onMouseUp}
onclick={onClick}
>
CONSOLE
</summary>
<div class="state-debug">
<!-- -->
<p>Progress:</p>
<div style="display: flex; flex-direction: column; gap: 4px;">
<p class="state-value">{componentState.progress}</p>
<div id="video-progress-bar">
<div
style="width: {componentState.progress * 100}%; height: 100%;"
></div>
</div>
</div>
<!-- -->
<p>Duration:</p>
<p class="state-value">
{componentState.duration}s
</p>
<!-- -->
{#if componentState.segment}
<p>Segment:</p>
<p class="state-value">
{componentState.segment[0]} -- {componentState.segment[1]}
</p>
{/if}
<!-- -->
<p>Current frame:</p>
<p class="state-value">
{componentState.currentFrame}/{componentState.totalFrames}
</p>
<!-- -->
<p>Speed:</p>
<p class="state-value">
{componentState.speed}
</p>
<!-- -->
<p>Autoplay:</p>
<p class="state-value">
<span class="tag">{componentState.autoplay}</span>
</p>
<!-- -->
<p>Loop:</p>
<p class="state-value">
<span class="tag">{componentState.loop}</span>
{componentState.loop ? `(Loop count: ${componentState.loopCount})` : ''}
</p>
<!-- -->
<p>Mode:</p>
<p class="state-value">
<span class="tag">{componentState.mode}</span>
</p>
<!-- -->
<p>Layout:</p>
<p class="state-value">
{JSON.stringify(componentState.layout)}
</p>
<!-- -->
{#if Object.keys(componentState.allMarkers).length}
<p>All markers:</p>
<p class="state-value">
{componentState.allMarkers}
</p>
{/if}
<!-- -->
{#if componentState.marker}
<p>Active marker:</p>
<p class="state-value">
{componentState.marker}
</p>
{/if}
<!-- -->
{#if componentState.allThemes.length}
<p>All themes:</p>
<p class="state-value">
{componentState.allThemes.join(', ')}
</p>
{/if}
{#if componentState.activeThemeId}
<p>Active theme ID:</p>
<p class="state-value">
{componentState.activeThemeId}
</p>
{/if}
<!-- -->
<p>isPaused:</p>
<p class="state-value">
<span class="tag">{componentState.isPaused}</span>
</p>
<!-- -->
<p>isPlaying:</p>
<p class="state-value">
<span class="tag">{componentState.isPlaying}</span>
</p>
<!-- -->
<p>isStopped:</p>
<p class="state-value">
<span class="tag">{componentState.isStopped}</span>
</p>
<!-- -->
<p>isLoaded:</p>
<p class="state-value">
<span class="tag">{componentState.isLoaded}</span>
</p>
<!-- -->
<p>isFrozen:</p>
<p class="state-value">
<span class="tag">{componentState.isFrozen}</span>
</p>
</div>
</details>
</div>
<style lang="scss">
@import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap');
.debug-info {
position: absolute;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 1);
z-index: 3;
margin: 0;
width: 50vmin;
min-width: 50vmin;
padding: 8px;
border-radius: 8px;
overflow: auto;
resize: horizontal;
opacity: 0.6;
transition: opacity 0.3s ease;
filter: drop-shadow(0 0 16px rgba(0, 0, 0, 0.5));
@media (prefers-reduced-motion: no-preference) {
interpolate-size: allow-keywords;
}
&::details-content {
opacity: 0;
block-size: 0;
overflow-y: clip;
transition:
content-visibility 0.4s allow-discrete,
opacity 0.4s,
block-size 0.4s cubic-bezier(0.87, 0, 0.13, 1);
}
&[open]::details-content {
opacity: 1;
block-size: auto;
}
.title {
width: 100%;
font-family: 'Geist Mono', monospace;
color: white;
margin: 0;
}
* {
user-select: none;
}
}
.debug-info[open] {
opacity: 1;
}
div.state-debug {
display: grid;
width: 100%;
padding: 8px 8px 16px 8px;
grid-template-columns: 20vmin 1fr;
align-items: center;
gap: 0.75rem 0.25rem;
background-color: #1e1e1e;
border-radius: 4px;
margin-top: 8px;
}
p {
font-size: var(--theme-font-size-xxs);
font-family: 'Geist Mono', monospace;
padding: 0;
margin: 0;
color: rgba(255, 255, 255, 0.7);
overflow-wrap: anywhere;
line-height: 100%;
font-variant: tabular-nums;
}
.state-value {
color: white;
}
#video-progress-bar {
width: 100%;
background-color: rgba(255, 255, 255, 0.2);
height: 2px;
border-radius: 50px;
// margin: auto;
div {
background-color: white;
border-radius: 50px;
}
}
.tag {
padding: 0.1rem 0.2rem;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.2);
text-transform: uppercase;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,345 @@
import { Meta } from '@storybook/blocks';
import * as ScrollerLottieStories from './ScrollerLottie.stories.svelte';
import CompositionMarkerImage from './assets/marker.png?url';
<Meta of={ScrollerLottieStories} />
# ScrollerLottie
The `ScrollerLottie` component plays Lottie animations. It uses the [dotLottie-web](https://developers.lottiefiles.com/docs/dotlottie-player/dotlottie-web/) library to render the animations.
## Basic demo
To use the `ScrollerLottie` component, import it and provide the animation source. The height defaults to `100lvh`, but you can adjust this to any valid CSS height value such as `1200px` or `200lvh` with the `height` prop.
The .lottie or .json file should be placed at the same level as the component file. If using it inside `App.svelte`, create a `data` folder and place all the animation files inside. Make sure to append **?url** to the import statement when importing an animation file, as shown in the example below. This ensures that the file is treated as a URL.
> 💡TIP: Use `lvh` or `svh` units instead of `vh` unit for the height, as [these units](https://www.w3.org/TR/css-values-4/#large-viewport-size) are more reliable on mobile or other devices where elements such as the address bar toggle between being shown and hidden.
> 💡TIP: Use showDebugInfo prop to display additional information about the component state.
[Demo](?path=/story/components-graphics-scrollerlottie--basic)
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
src: LottieFile
:end
[]
```
... which you'll parse out of a ArchieML block object before passing to the `ScrollerLottie` component.
```svelte
<script lang="ts">
import { ScrollerLottie } from '@reuters-graphics/graphics-components';
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<ScrollerLottie
src={`./data/${block.src}.lottie?url`}
autoplay
loop
showDebugInfo
/>
{/if}
{/each}
```
## Playing a marker
It is possible to play a specific portion of the animation using markers. Markers can be set in [AfterEffects](https://helpx.adobe.com/in/after-effects/using/layer-markers-composition-markers.html) to define separate portions of the animation. A specific marker can be played by using the `marker` prop.
The list of available markers can be found in the debug info when `showDebugInfo` prop is enabled.
> 💡NOTE: The **Comment** section of the Composition Marker dialog should only contain the name of your marker.
<img src={CompositionMarkerImage} alt="Composition Marker Dialog" />
[Demo](?path=/story/components-graphics-scrollerlottie--marker)
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
src: LottieFile
marker: myMarker
:end
[]
```
... which you'll parse out of a ArchieML block object before passing to the `ScrollerLottie` component.
```svelte
<script lang="ts">
import { ScrollerLottie } from '@reuters-graphics/graphics-components';
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<ScrollerLottie
src={`./data/${block.src}.lottie?url`}
marker={block.marker}
autoplay
loop
showDebugInfo
/>
{/if}
{/each}
```
## Playing a segment
Just like markers, it is also possible to play a specific segment of the animation using the `segment` prop. The `segment` prop expects an array of two numbers representing the start and end frames of the segment.
[Demo](?path=/story/components-graphics-scrollerlottie--segment)
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
src: LottieFile
[.segment]
start: 0
end: 20
[]
:end
[]
```
... which you'll parse out of a ArchieML block object before passing to the `ScrollerLottie` component.
```svelte
<script lang="ts">
import { ScrollerLottie } from '@reuters-graphics/graphics-components';
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<ScrollerLottie
src={`./data/${block.src}.lottie?url`}
segment={[block.segment.start, block.segment.end]}
autoplay
loop
showDebugInfo
/>
{/if}
{/each}
```
## Switching themes
[Lottie Creator](https://lottiefiles.com/theming) allows you to define multiple color themes for your animation. You can switch between these themes using the `theme` prop.
Available themes can be found in the debug info when `showDebugInfo` prop is enabled.
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
src: LottieFile
theme: myTheme
:end
[]
```
... which you'll parse out of a ArchieML block object before passing to the `ScrollerLottie` component.
```svelte
<script lang="ts">
import { ScrollerLottie } from '@reuters-graphics/graphics-components';
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<ScrollerLottie
src={`./data/${block.src}.lottie?url`}
theme={block.theme}
autoplay
loop
showDebugInfo
/>
{/if}
{/each}
```
It is also possible to switch themes dynamically based on the `progress` prop by binding a variable to it.
[Demo](?path=/story/components-graphics-scrollerlottie--themes)
```svelte
<script lang="ts">
import { ScrollerLottie } from '@reuters-graphics/graphics-components';
import LottieSrc from './data/lottie-example.lottie?url';
let progress = $state(0);
</script>
<ScrollerLottie
src={LottieSrc}
bind:progress
themeId={progress < 0.33 ? 'water'
: progress < 0.66 ? 'air'
: 'earth'}
autoplay
showDebugInfo
/>
```
## With ScrollerBase
The `ScrollerLottie` component can be used in conjunction with the `ScrollerBase` component to create a more complex scrolling experience. The `ScrollerBase` component provides a scrollable container that can hold the `ScrollerLottie` component as a background.
```svelte
<script lang="ts">
import { ScrollerLottie } from '@reuters-graphics/graphics-components';
import LottieSrc from './data/lottie-example.lottie?url';
// Pass `progress` as `videoPercentage` to ScrollerLottie
let progress = $state(0);
</script>
<ScrollerBase bind:progress query="div.step-foreground-container">
{#snippet backgroundSnippet()}
<!-- Pass bindable prop `progress` as `progress` -->
<div class="lottie-container">
<ScrollerLottie src={LottieSrc} {progress} showDebugInfo />
</div>
{/snippet}
{#snippet foregroundSnippet()}
<!-- Add custom foreground HTML or component -->
<div class="step-foreground-container">
<h3 class="text-center">Step 1</h3>
</div>
<div class="step-foreground-container">
<h3 class="text-center">Step 2</h3>
</div>
<div class="step-foreground-container">
<h3 class="text-center">Step 3</h3>
</div>
{/snippet}
</ScrollerBase>
<style lang="scss">
.lottie-container {
width: 100%;
height: 100lvh;
}
.step-foreground-container {
height: 100lvh;
width: 50%;
padding: 1em;
margin: auto;
h3 {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: white;
}
}
</style>
```
## With foregrounds
The `ScrollerLottie` component can also be used to display captions or even components, such as `Headline` or ai2svelte files, as foregrounds at specific times in the animation. To do so, add ScrollerLottieForeground components as children of the ScrollerLottie component.
[Demo](?path=/story/components-graphics-scrollerlottie--with-foregrounds)
With the graphics kit, you'll likely get your text and prop values from an ArchieML doc...
```yaml
# ArchieML doc
[blocks]
type: lottie
src: LottieFile
# Array of foregrounds
[.foregrounds]
startFrame: 0 # When in the animation to start showing the foreground
endFrame: 50 # When to stop showing the foreground
type: text
{.foregroundProps}
text: Some text for the foreground
{}
startFrame: 50 # When in the animation to start showing the foreground
endFrame: 100 # When to stop showing the foreground
type: component
{.foregroundProps}
componentType: Headline
hed: Some headline text
dek: Some deck text
[.authors]
* Jane Doe
* John Smith
[]
{}
[]
:end
[]
```
... which you'll parse out of a ArchieML block object before passing to the `ScrollerLottie` component.
```svelte
<script lang="ts">
import { ScrollerLottie } from '@reuters-graphics/graphics-components';
const Components = $state({
Headline,
Video,
});
</script>
{#each content.blocks as block}
<!-- Inside the content.blocks for loop... -->
{#if block.type == 'lottie'}
<ScrollerLottie
src={`./data/${block.src}.lottie?url`}
theme={block.theme}
autoplay
loop
showDebugInfo
>
{#each block.foregrounds as foreground}
{#if foreground.type == 'text'}
<ScrollerLottieForeground
endFrame={parseInt(foreground.endFrame)}
startFrame={parseInt(foreground.startFrame)}
text={foreground.foregroundProps.text}
/>
{:else if foreground.type == 'component'}
{@const Component =
Components[foreground.foregroundProps.componentType]}
<ScrollerLottieForeground
endFrame={parseInt(foreground.endFrame)}
startFrame={parseInt(foreground.startFrame)}
>
<Component {...foreground.foregroundProps} />
</ScrollerLottieForeground>
{/if}
{/each}
</ScrollerLottie>
{/if}
{/each}
```

View file

@ -0,0 +1,125 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import ScrollerLottie from './ScrollerLottie.svelte';
import ScrollerLottieForeground from './ScrollerLottieForeground.svelte';
import Headline from '../Headline/Headline.svelte';
import Theme from '../Theme/Theme.svelte';
import MarkerSample from './data/markerSample.lottie?url';
import ForegroundSample from './data/foregroundSample.lottie?url';
import WithScrollerBase from './demo/withScrollerBase.svelte';
const { Story } = defineMeta({
title: 'Components/Graphics/ScrollerLottie',
component: ScrollerLottie,
argTypes: {
data: {
table: {
disable: true, // Hides from Docs table
},
control: false, // Removes from Controls panel
},
lottiePlayer: {
table: {
category: 'Bindable states',
},
},
progress: {
table: {
category: 'Bindable states',
},
},
},
});
let progress = $state(0);
</script>
<Story name="Basic">
<ScrollerLottie autoplay loop showDebugInfo />
</Story>
<Story name="Marker">
<ScrollerLottie
src={MarkerSample}
showDebugInfo
autoplay
marker="ballerina"
loop
mode="bounce"
/>
</Story>
<Story name="Segment">
<ScrollerLottie autoplay loop showDebugInfo segment={[0, 20]} speed={0.5} />
</Story>
<Story name="Themes">
<ScrollerLottie
src={'https://lottie.host/9a5a6605-fc90-4935-8d10-9df4c83902ff/PFUKH53LJk.lottie'}
showDebugInfo
autoplay
bind:progress
themeId={progress < 0.33 ? 'Water'
: progress < 0.66 ? 'air'
: 'earth'}
/>
</Story>
<Story name="Using with ScrollerBase" exportName="ScrollerBase">
<WithScrollerBase />
</Story>
<Story name="With foregrounds">
<ScrollerLottie
src={ForegroundSample}
showDebugInfo
autoplay
speed={0.5}
loop
mode="bounce"
>
<ScrollerLottieForeground
startFrame={50}
endFrame={100}
text="Foreground caption between frames 50 and 100."
position="bottom center"
backgroundColour="rgba(0, 0, 0)"
width="normal"
/>
<ScrollerLottieForeground
startFrame={0}
endFrame={50}
position="center center"
backgroundColour="rgba(0, 0, 0)"
width="normal"
>
<Theme base="dark">
<Headline
hed="ScrollerLottie with foreground component"
dek="This is an example of using a Svelte component as the foreground."
width="normal"
authors={['Jane Doe', 'John Doe']}
/>
</Theme>
</ScrollerLottieForeground>
</ScrollerLottie>
</Story>
<style lang="scss">
:global {
.scroller-lottie-foreground {
header {
padding: 2rem;
background-color: black;
}
.foreground-text {
p {
color: white !important;
}
}
}
}
</style>

View file

@ -0,0 +1,521 @@
<script lang="ts">
import {
type Config,
type DotLottie as DotLottieType,
} from '@lottiefiles/dotlottie-web';
import { DotLottie } from '@lottiefiles/dotlottie-web';
import Block from '../Block/Block.svelte';
import type { ContainerWidth } from '../@types/global';
import { createLottieState, type LottieState } from './ts/lottieState.svelte';
import { onDestroy, onMount, setContext } from 'svelte';
import { isEqual } from 'es-toolkit';
import Debug from './Debug.svelte';
import { map } from './ts/utils';
import { Tween } from 'svelte/motion';
import type { Snippet } from 'svelte';
import WASM from './data/dotlottie-player.wasm?url';
import DefaultLottie from './data/defaultLottie.lottie?url';
type DotlottieProps = {
autoplay?: Config['autoplay'];
backgroundColor?: Config['backgroundColor'];
data?: Config['data'];
loop?: Config['loop'];
mode?: Config['mode'];
renderConfig?: Config['renderConfig'];
segment?: Config['segment'];
speed?: Config['speed'];
src?: Config['src'];
useFrameInterpolation?: Config['useFrameInterpolation'];
marker?: Config['marker'] | null | undefined;
layout?: Config['layout'];
animationId?: Config['animationId'];
themeId?: Config['themeId'];
playOnHover?: boolean;
themeData?: string;
dotLottieRefCallback?: (dotLottie: DotLottieType) => void;
onLoad?: () => void;
onRender?: () => void;
onComplete?: () => void;
};
type Props = DotlottieProps & {
// Additional properties can be added here if needed
lottiePlayer?: DotLottieType | undefined;
showDebugInfo?: boolean;
width?: ContainerWidth;
height?: string;
lottieState?: LottieState;
progress?: number;
tweenDuration?: number;
easing?: (t: number) => number;
/** Children render function */
children?: Snippet;
};
let canvas: HTMLCanvasElement;
let canvasWidth: number = $state(1);
let canvasHeight: number = $state(1);
let prevSrc = void 0;
let prevData = void 0;
let progressTween = new Tween(0, { duration: 100 });
let start: number = $state(0);
let end: number = $state(0);
let {
autoplay = false,
loop = false,
mode = 'forward',
src = DefaultLottie,
speed = 1,
data = undefined,
backgroundColor = '#ffffff',
segment = undefined,
renderConfig = undefined,
dotLottieRefCallback = () => {},
useFrameInterpolation = true,
themeId = '',
themeData = '',
playOnHover = false,
marker = '',
layout = { fit: 'contain', align: [0.5, 0.5] },
animationId = '',
lottiePlayer = $bindable(undefined),
width = 'widest',
height = '100lvh',
showDebugInfo = false,
lottieState = createLottieState(),
progress = $bindable(0),
tweenDuration = 100,
easing = (t: number) => t,
onLoad = () => {},
onRender = () => {},
onComplete = () => {},
children,
}: Props = $props();
// pass on component state to child components
// this controls fade in and out of foregrounds
setContext('lottieState', lottieState);
function onLoadEvent() {
if (showDebugInfo) {
// set layout
lottiePlayer.setLayout(layout);
lottieState.allMarkers = lottiePlayer.markers().map((x) => x.name);
if (lottiePlayer.manifest) {
lottieState.allThemes =
lottiePlayer?.manifest.themes ?
lottiePlayer.manifest.themes.map((t) => t.id)
: [];
}
if (marker == '' || marker == null || marker == undefined) {
start = segment ? segment[0] : 0;
end = segment ? segment[1] : lottiePlayer.totalFrames - 1;
}
}
// set to frame 1 to trigger initial render
// helpful especially when themeId is set
lottiePlayer.setFrame(1);
onLoad(); // call user-defined onLoad function
}
function onCompleteEvent() {
onComplete();
}
function onRenderEvent() {
const keys = [
'currentFrame',
'totalFrames',
'duration',
'loop',
'speed',
'loopCount',
'mode',
'isPaused',
'isPlaying',
'isStopped',
'isLoaded',
'isFrozen',
'segment',
'autoplay',
'layout',
'activeThemeId',
'marker',
];
if (lottiePlayer && lottieState) {
keys.forEach((key) => {
lottieState[key] = lottiePlayer[key];
});
}
progress = (lottiePlayer.currentFrame + 1) / lottiePlayer.totalFrames;
lottieState.progress = progress;
onRender(); // call user-defined onRender function
}
const hoverHandler = (event) => {
if (!playOnHover || !lottiePlayer.isLoaded) return;
if (event.type === 'mouseenter') {
lottiePlayer.play();
} else if (event.type === 'mouseleave') {
lottiePlayer.pause();
}
};
onMount(() => {
const shouldAutoplay = autoplay && !playOnHover;
progressTween = new Tween(0, { duration: tweenDuration, easing: easing });
const _renderConfig = {
autoResize: true,
devicePixelRatio:
window.devicePixelRatio > 1 ? window.devicePixelRatio * 0.75 : 1,
freezeOnOffscreen: true,
};
lottiePlayer = new DotLottie({
canvas,
src,
autoplay: shouldAutoplay,
loop,
speed,
data,
renderConfig: _renderConfig,
segment,
useFrameInterpolation,
backgroundColor,
mode,
animationId,
themeId,
});
DotLottie.setWasmUrl(WASM);
lottiePlayer.addEventListener('load', onLoadEvent);
lottiePlayer.addEventListener('frame', onRenderEvent);
lottiePlayer.addEventListener('complete', onCompleteEvent);
if (dotLottieRefCallback) {
dotLottieRefCallback(lottiePlayer);
}
canvas.addEventListener('mouseenter', hoverHandler);
canvas.addEventListener('mouseleave', hoverHandler);
return () => {
canvas.removeEventListener('mouseenter', hoverHandler);
canvas.removeEventListener('mouseleave', hoverHandler);
lottiePlayer.destroy();
};
});
onDestroy(() => {
if (lottiePlayer) {
lottiePlayer.removeEventListener('render', onRender);
lottiePlayer.removeEventListener('load', onLoad);
lottiePlayer.destroy();
}
});
// Handles progress change
$effect(() => {
if (lottieState.isLoaded && lottieState.progress !== progress) {
autoplay = false;
lottiePlayer.pause();
loop = false;
if (progress >= 0 && progress <= 1) {
if (lottieState.isFrozen) {
lottiePlayer.unfreeze();
lottieState.isFrozen = false;
}
const targetFrame = map(
mode == 'reverse' || mode == 'reverse-bounce' ?
1 - progress
: progress,
0,
1,
start,
end
);
progressTween.target = targetFrame;
// lottiePlayer.setFrame(targetFrame);
} else if ((progress < 0 || progress > 1) && !lottieState.isFrozen) {
// lottiePlayer.setFrame(progress < 0 ? start : end);
if (mode == 'reverse' || mode == 'reverse-bounce') {
progressTween.target = progress < 0 ? end : start;
} else {
progressTween.target = progress < 0 ? start : end;
}
lottiePlayer.freeze();
lottieState.isFrozen = true;
}
}
});
// Tweens to progress value
$effect(() => {
if (progressTween.current >= 0) {
lottiePlayer.setFrame(progressTween.current);
}
});
// Handles layout change
$effect(() => {
if (
typeof layout === 'object' &&
lottiePlayer.isLoaded &&
!isEqual(layout, lottiePlayer.layout)
) {
lottiePlayer.setLayout(layout);
}
});
// Handles marker change
$effect(() => {
if (lottieState.isLoaded && lottiePlayer.marker !== marker) {
if (typeof marker === 'string') {
lottiePlayer.setMarker(marker);
start =
lottiePlayer.markers().find((m) => m.name === marker)?.time ?? 0;
end =
start +
(lottiePlayer.markers().find((m) => m.name === marker)?.duration ??
0);
// change lottieState marker because
// onRender fires before this
if (lottieState) {
lottieState.marker = marker;
}
} else if (marker === null || marker === undefined) {
lottiePlayer.setMarker('');
} else {
console.warn('Invalid marker type:', marker);
}
}
});
// Handles speed change
$effect(() => {
if (
lottieState.isLoaded &&
typeof speed == 'number' &&
lottiePlayer.speed !== speed
) {
lottiePlayer.setSpeed(speed);
}
});
// Handles frame interpolation change
$effect(() => {
if (
lottieState.isLoaded &&
typeof useFrameInterpolation == 'boolean' &&
lottiePlayer.useFrameInterpolation !== useFrameInterpolation
) {
lottiePlayer.setUseFrameInterpolation(useFrameInterpolation);
}
});
// Handles segment change
$effect(() => {
if (lottieState.isLoaded && !isEqual(lottiePlayer.segment, segment)) {
if (
Array.isArray(segment) &&
segment.length === 2 &&
typeof segment[0] === 'number' &&
typeof segment[1] === 'number'
) {
let [start, end] = segment;
lottiePlayer.setSegment(start, end);
} else if (segment === null || segment === undefined) {
lottiePlayer.setSegment(0, lottiePlayer.totalFrames);
}
}
});
// Handles loop change
$effect(() => {
if (
lottieState.isLoaded &&
typeof loop == 'boolean' &&
lottiePlayer.loop !== loop
) {
lottiePlayer.setLoop(loop);
}
});
// Handles autoplay change
$effect(() => {
if (
lottieState.isLoaded &&
typeof autoplay == 'boolean' &&
lottieState.autoplay !== autoplay
) {
lottieState.autoplay = !autoplay;
}
});
// Handles background color change
$effect(() => {
if (
lottieState.isLoaded &&
lottiePlayer.backgroundColor !== backgroundColor
) {
lottiePlayer.setBackgroundColor(backgroundColor || '');
}
});
// Handles mode change
$effect(() => {
if (
lottieState.isLoaded &&
typeof mode == 'string' &&
lottiePlayer.mode !== mode
) {
lottiePlayer.setMode(mode);
}
});
// Handles src change
$effect(() => {
if (lottieState && src !== prevSrc) {
lottiePlayer.load({
src,
autoplay,
loop,
speed,
data,
renderConfig,
segment,
useFrameInterpolation,
backgroundColor,
mode,
marker,
layout,
animationId,
themeId,
});
prevSrc = src;
}
});
// Generate new instance if data changes
$effect(() => {
if (lottiePlayer && data !== prevData) {
lottiePlayer.load({
src,
autoplay,
loop,
speed,
data,
renderConfig,
segment,
useFrameInterpolation,
backgroundColor,
mode,
marker,
layout,
animationId,
themeId,
});
prevData = data;
}
});
// Handles animationId change
$effect(() => {
if (
lottieState.isLoaded &&
lottiePlayer.activeAnimationId !== animationId
) {
lottiePlayer.loadAnimation(animationId);
}
});
// Handles themeId change
$effect(() => {
if (lottieState.isLoaded && lottiePlayer.activeThemeId !== themeId) {
lottiePlayer.setTheme(themeId);
}
});
// Handles themeData change
$effect(() => {
if (lottieState.isLoaded && lottiePlayer.isLoaded) {
lottiePlayer.setThemeData(themeData);
}
});
</script>
<Block {width} class="lottie-block">
{#if showDebugInfo && lottiePlayer}
<Debug componentState={lottieState} />
{/if}
<div class="lottie-container" style:height>
<canvas
bind:this={canvas}
bind:clientWidth={canvasWidth}
bind:clientHeight={canvasHeight}
></canvas>
</div>
{#if children}
{@render children?.()}
{/if}
</Block>
<style lang="scss">
:global(.lottie-block) {
position: relative;
height: 100%;
.lottie-container {
width: 100%;
height: 100%;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
}
.debug-info {
position: absolute;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.6);
z-index: 3;
margin: 0;
min-width: 25vmin;
.title {
width: 100%;
padding: 4px 0 0 8px;
margin: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
p {
color: white;
margin: 0;
padding: 4px 8px 8px 8px;
}
}
</style>

View file

@ -0,0 +1,170 @@
<script lang="ts">
import Block from '../Block/Block.svelte';
import { fade } from 'svelte/transition';
import { getContext, onDestroy } from 'svelte';
import { Markdown } from '@reuters-graphics/svelte-markdown';
// Types
import type { Component, Snippet } from 'svelte';
import type { LottieState } from './lottieState.svelte';
import type {
ContainerWidth,
ScrollerLottieForegroundPosition,
} from '../@types/global';
interface ForegroundProps {
id?: string;
class?: string;
startFrame?: number;
endFrame?: number;
children?: Snippet;
backgroundColour?: string;
width?: ContainerWidth;
position?: ScrollerLottieForegroundPosition | string;
text?: string;
Foreground?: Component;
}
let {
id = '',
class: cls = '',
startFrame = 0,
endFrame = 10,
children,
backgroundColour = '#000',
width = 'normal',
position = 'center center',
text,
Foreground,
}: ForegroundProps = $props();
let componentState: LottieState | null = getContext('lottieState');
onDestroy(() => {
componentState = null;
});
</script>
<Block class={`scroller-lottie-foreground ${cls}`} {id}>
{#if componentState?.currentFrame && componentState.currentFrame >= startFrame && componentState.currentFrame <= endFrame}
<div
class="scroller-foreground"
in:fade={{ delay: 100, duration: 200 }}
out:fade={{ delay: 0, duration: 100 }}
>
<!-- Text blurb foreground -->
{#if text}
<Block
class="scroller-lottie-foreground-text {position.split(' ')[1]}"
{width}
>
<div
style="background-color: {backgroundColour};"
class="foreground-text {position.split(' ')[0]}"
>
<Markdown source={text} />
</div>
</Block>
<!-- Render children snippet -->
{:else if children}
<div class="scroller-lottie-foreground-item">
{@render children()}
</div>
<!-- Render Foreground component -->
{:else if Foreground}
<div class="scroller-lottie-foreground-item">
<Block width="fluid">
<Foreground />
</Block>
</div>
{/if}
</div>
{/if}
</Block>
<style lang="scss">
@use './../../scss/mixins' as mixins;
.scroller-foreground {
width: 100%;
height: 100%;
}
:global(.scroller-lottie-foreground) {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2;
}
.scroller-lottie-foreground-item {
width: 100%;
height: 100%;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
:global {
.scroller-lottie-foreground-text {
position: absolute;
width: 100%;
max-width: calc(mixins.$column-width-normal * 0.9);
height: 100%;
@media (max-width: 1200px) {
left: 50%;
transform: translateX(-50%);
}
&.left {
left: 0;
}
&.right {
right: 0;
}
&.center {
left: 50%;
transform: translateX(-50%);
}
}
.foreground-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 0.25rem;
background-color: white;
width: 100%;
@include mixins.fpy-5;
@include mixins.fpx-4;
@include mixins.fm-0;
:global(*) {
margin: 0;
padding: 0;
}
&.center {
top: 50%;
}
&.top {
top: 0;
transform: translate(-50%, 50%);
}
&.bottom {
top: 100%;
transform: translate(-50%, -150%);
}
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

View file

@ -0,0 +1,74 @@
<script lang="ts">
import Block from '../../Block/Block.svelte';
import ScrollerBase from '../../ScrollerBase/ScrollerBase.svelte';
import ScrollerLottie from '../ScrollerLottie.svelte';
import LottieSample from '../data/markerSample.lottie?url';
// Optional: Bind your own variables to use them in your code.
let count = $state(1);
let index = $state(0);
let offset = $state(0);
let progress = $state(0);
let top = $state(0);
let threshold = $state(0.5);
let bottom = $state(1);
</script>
<ScrollerBase
{top}
{threshold}
{bottom}
bind:count
bind:index
bind:offset
bind:progress
query="div.step-foreground-container"
>
{#snippet backgroundSnippet()}
<!-- Add custom background HTML or component -->
<div class="lottie-container">
<ScrollerLottie
src={'https://lottie.host/9a5a6605-fc90-4935-8d10-9df4c83902ff/PFUKH53LJk.lottie'}
showDebugInfo
{progress}
/>
</div>
{/snippet}
{#snippet foregroundSnippet()}
<!-- Add custom foreground HTML or component -->
<div class="step-foreground-container"><p>Step 1</p></div>
<div class="step-foreground-container"><p>Step 2</p></div>
<div class="step-foreground-container"><p>Step 3</p></div>
<div class="step-foreground-container"><p>Step 4</p></div>
<div class="step-foreground-container"><p>Step 5</p></div>
{/snippet}
</ScrollerBase>
<style lang="scss">
@use '../../../scss/mixins' as mixins;
.lottie-container {
// border: 2px solid red;
width: 100%;
height: 100lvh;
}
.step-foreground-container {
height: 100lvh;
width: 50%;
background-color: rgba(0, 0, 0, 0);
padding: 1em;
margin: 0 auto;
position: relative;
margin-bottom: 2rem;
p {
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: antiquewhite;
text-align: center;
}
}
</style>

View file

@ -0,0 +1,49 @@
export interface LottieState {
progress: number;
currentFrame: number;
totalFrames: number;
duration: number;
loop: boolean;
speed: number;
loopCount: number;
mode: string;
isPaused: boolean;
isPlaying: boolean;
isStopped: boolean;
isLoaded: boolean;
isFrozen: boolean;
segment: null | [number, number];
autoplay: boolean;
layout: null | string;
allMarkers: Array<string>;
marker: null | string;
allThemes: Array<string>;
activeThemeId: null | string;
}
export function createLottieState(): LottieState {
const lottieState = $state<LottieState>({
progress: 0,
currentFrame: 0,
totalFrames: 0,
duration: 0,
loop: false,
speed: 1,
loopCount: 0,
mode: '',
isPaused: false,
isPlaying: false,
isStopped: false,
isLoaded: false,
isFrozen: false,
segment: null,
autoplay: false,
layout: null,
allMarkers: [],
marker: null,
allThemes: [],
activeThemeId: null,
});
return lottieState;
}

View file

@ -0,0 +1,22 @@
function constrain(n: number, low: number, high: number) {
return Math.max(Math.min(n, high), low);
}
export function map(
n: number,
start1: number,
stop1: number,
start2: number,
stop2: number,
withinBounds: boolean = true
) {
const newval = ((n - start1) / (stop1 - start1)) * (stop2 - start2) + start2;
if (!withinBounds) {
return newval;
}
if (start2 < stop2) {
return constrain(newval, start2, stop2);
} else {
return constrain(newval, stop2, start2);
}
}

View file

@ -47,6 +47,8 @@ export { default as Spinner } from './components/Spinner/Spinner.svelte';
export { default as ScrollerBase } from './components/ScrollerBase/ScrollerBase.svelte';
export { default as ScrollerVideo } from './components/ScrollerVideo/ScrollerVideo.svelte';
export { default as ScrollerVideoForeground } from './components/ScrollerVideo/ScrollerVideoForeground.svelte';
export { default as ScrollerLottie } from './components/ScrollerLottie/ScrollerLottie.svelte';
export { default as ScrollerLottieForeground } from './components/ScrollerLottie/ScrollerLottieForeground.svelte';
export { default as SponsorshipAd } from './components/AdSlot/SponsorshipAd.svelte';
export { default as Table } from './components/Table/Table.svelte';
export { default as Theme, themes } from './components/Theme/Theme.svelte';