diff --git a/package.json b/package.json index 973ca42f..b6cbdd37 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b6786bc..f872bd4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/@types/global.ts b/src/components/@types/global.ts index c5bb00e2..eb3ba45f 100644 --- a/src/components/@types/global.ts +++ b/src/components/@types/global.ts @@ -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 diff --git a/src/components/ScrollerLottie/Debug.svelte b/src/components/ScrollerLottie/Debug.svelte new file mode 100644 index 00000000..80939dd2 --- /dev/null +++ b/src/components/ScrollerLottie/Debug.svelte @@ -0,0 +1,271 @@ + + + + +
+
+ + CONSOLE + +
+ +

Progress:

+
+

{componentState.progress}

+
+
+
+
+ +

Duration:

+

+ {componentState.duration}s +

+ + {#if componentState.segment} +

Segment:

+

+ {componentState.segment[0]} -- {componentState.segment[1]} +

+ {/if} + +

Current frame:

+

+ {componentState.currentFrame}/{componentState.totalFrames} +

+ +

Speed:

+

+ {componentState.speed} +

+ +

Autoplay:

+

+ {componentState.autoplay} +

+ +

Loop:

+

+ {componentState.loop} + {componentState.loop ? `(Loop count: ${componentState.loopCount})` : ''} +

+ +

Mode:

+

+ {componentState.mode} +

+ +

Layout:

+

+ {JSON.stringify(componentState.layout)} +

+ + {#if Object.keys(componentState.allMarkers).length} +

All markers:

+

+ {componentState.allMarkers} +

+ {/if} + + {#if componentState.marker} +

Active marker:

+

+ {componentState.marker} +

+ {/if} + + {#if componentState.allThemes.length} +

All themes:

+

+ {componentState.allThemes.join(', ')} +

+ {/if} + {#if componentState.activeThemeId} +

Active theme ID:

+

+ {componentState.activeThemeId} +

+ {/if} + +

isPaused:

+

+ {componentState.isPaused} +

+ +

isPlaying:

+

+ {componentState.isPlaying} +

+ +

isStopped:

+

+ {componentState.isStopped} +

+ +

isLoaded:

+

+ {componentState.isLoaded} +

+ +

isFrozen:

+

+ {componentState.isFrozen} +

+
+
+
+ + diff --git a/src/components/ScrollerLottie/ScrollerLottie.mdx b/src/components/ScrollerLottie/ScrollerLottie.mdx new file mode 100644 index 00000000..8a7e8ef2 --- /dev/null +++ b/src/components/ScrollerLottie/ScrollerLottie.mdx @@ -0,0 +1,345 @@ +import { Meta } from '@storybook/blocks'; + +import * as ScrollerLottieStories from './ScrollerLottie.stories.svelte'; +import CompositionMarkerImage from './assets/marker.png?url'; + + + +# 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 + + +{#each content.blocks as block} + + {#if block.type == 'lottie'} + + {/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. + +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 + + +{#each content.blocks as block} + + {#if block.type == 'lottie'} + + {/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 + + +{#each content.blocks as block} + + {#if block.type == 'lottie'} + + {/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 + + +{#each content.blocks as block} + + {#if block.type == 'lottie'} + + {/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 + + + +``` + +## 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 + + + + {#snippet backgroundSnippet()} + +
+ +
+ {/snippet} + {#snippet foregroundSnippet()} + +
+

Step 1

+
+
+

Step 2

+
+
+

Step 3

+
+ {/snippet} +
+ + +``` + +## 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 + + +{#each content.blocks as block} + + {#if block.type == 'lottie'} + + {#each block.foregrounds as foreground} + {#if foreground.type == 'text'} + + {:else if foreground.type == 'component'} + {@const Component = + Components[foreground.foregroundProps.componentType]} + + + + {/if} + {/each} + + {/if} +{/each} +``` diff --git a/src/components/ScrollerLottie/ScrollerLottie.stories.svelte b/src/components/ScrollerLottie/ScrollerLottie.stories.svelte new file mode 100644 index 00000000..5ce7031a --- /dev/null +++ b/src/components/ScrollerLottie/ScrollerLottie.stories.svelte @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/ScrollerLottie/ScrollerLottie.svelte b/src/components/ScrollerLottie/ScrollerLottie.svelte new file mode 100644 index 00000000..33987ce7 --- /dev/null +++ b/src/components/ScrollerLottie/ScrollerLottie.svelte @@ -0,0 +1,521 @@ + + + + {#if showDebugInfo && lottiePlayer} + + {/if} + +
+ +
+ + {#if children} + {@render children?.()} + {/if} +
+ + diff --git a/src/components/ScrollerLottie/ScrollerLottieForeground.svelte b/src/components/ScrollerLottie/ScrollerLottieForeground.svelte new file mode 100644 index 00000000..ba4c9d09 --- /dev/null +++ b/src/components/ScrollerLottie/ScrollerLottieForeground.svelte @@ -0,0 +1,170 @@ + + + + {#if componentState?.currentFrame && componentState.currentFrame >= startFrame && componentState.currentFrame <= endFrame} +
+ + {#if text} + +
+ +
+
+ + {:else if children} +
+ {@render children()} +
+ + {:else if Foreground} +
+ + + +
+ {/if} +
+ {/if} +
+ + diff --git a/src/components/ScrollerLottie/assets/marker.png b/src/components/ScrollerLottie/assets/marker.png new file mode 100644 index 00000000..9b1f2ee2 Binary files /dev/null and b/src/components/ScrollerLottie/assets/marker.png differ diff --git a/src/components/ScrollerLottie/data/defaultLottie.lottie b/src/components/ScrollerLottie/data/defaultLottie.lottie new file mode 100644 index 00000000..2739af54 Binary files /dev/null and b/src/components/ScrollerLottie/data/defaultLottie.lottie differ diff --git a/src/components/ScrollerLottie/data/dotlottie-player.wasm b/src/components/ScrollerLottie/data/dotlottie-player.wasm new file mode 100644 index 00000000..7195553b Binary files /dev/null and b/src/components/ScrollerLottie/data/dotlottie-player.wasm differ diff --git a/src/components/ScrollerLottie/data/foregroundSample.lottie b/src/components/ScrollerLottie/data/foregroundSample.lottie new file mode 100644 index 00000000..2b9e0b55 Binary files /dev/null and b/src/components/ScrollerLottie/data/foregroundSample.lottie differ diff --git a/src/components/ScrollerLottie/data/markerSample.lottie b/src/components/ScrollerLottie/data/markerSample.lottie new file mode 100644 index 00000000..8064b2e7 Binary files /dev/null and b/src/components/ScrollerLottie/data/markerSample.lottie differ diff --git a/src/components/ScrollerLottie/demo/withScrollerBase.svelte b/src/components/ScrollerLottie/demo/withScrollerBase.svelte new file mode 100644 index 00000000..0bb35c8a --- /dev/null +++ b/src/components/ScrollerLottie/demo/withScrollerBase.svelte @@ -0,0 +1,74 @@ + + + + {#snippet backgroundSnippet()} + +
+ +
+ {/snippet} + {#snippet foregroundSnippet()} + +

Step 1

+

Step 2

+

Step 3

+

Step 4

+

Step 5

+ {/snippet} +
+ + diff --git a/src/components/ScrollerLottie/ts/lottieState.svelte.ts b/src/components/ScrollerLottie/ts/lottieState.svelte.ts new file mode 100644 index 00000000..e0a026ed --- /dev/null +++ b/src/components/ScrollerLottie/ts/lottieState.svelte.ts @@ -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; + marker: null | string; + allThemes: Array; + activeThemeId: null | string; +} + +export function createLottieState(): LottieState { + const lottieState = $state({ + 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; +} diff --git a/src/components/ScrollerLottie/ts/utils.ts b/src/components/ScrollerLottie/ts/utils.ts new file mode 100644 index 00000000..803d201d --- /dev/null +++ b/src/components/ScrollerLottie/ts/utils.ts @@ -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); + } +} diff --git a/src/index.ts b/src/index.ts index 790e9306..559a818d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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';