Compare commits

...

54 Commits

Author SHA1 Message Date
bodymovin
52dd934e43 chore: release 4.22.1 2025-07-18 14:29:38 +00:00
Hernan Torrisi
c151ee37b5 bump rive to 2.30.4 2025-07-18 07:18:56 -07:00
damzobridge
c660a675c2 chore: release 4.22.0 2025-07-15 20:57:21 +00:00
Adam
74e1d5a5f2 feat: add tests for artboard binding 2025-07-15 12:52:34 -07:00
Adam
963ecc43b8 feat: add useViewModelInstanceArtboard hook 2025-07-15 12:52:34 -07:00
bodymovin
85807f2166 chore: release 4.21.6 2025-07-15 04:51:16 +00:00
Hernan Torrisi
9a33504d3a rive_canvas_2.30.3 2025-07-14 21:48:19 -07:00
HayesGordon
1a4d7e7168 chore: release 4.21.5 2025-07-14 16:12:59 +00:00
CI
b3d0fd4339 chore: bump rive wasm 2.30.2 2025-07-14 17:09:31 +01:00
Adam
c4239ab6b2 fix: lint error with revision 2025-07-07 14:23:22 -07:00
Adam
22f8d5a945 feat: add tests for list property 2025-07-07 14:23:22 -07:00
Adam
721ed786dc feat: add useViewModelInstanceList hook 2025-07-07 14:23:22 -07:00
Adam
eef56fb641 feat: add useViewModelInstanceImage hook 2025-07-07 14:23:22 -07:00
bodymovin
4bc0f496f8 chore: release 4.21.4 2025-06-25 13:39:06 +00:00
Maxwell Talbot
10bb4c69ea fix: update how we use release it with github tokens 2025-06-25 06:26:13 -07:00
Hernan Torrisi
c5b6826996 bump rive to 2.30.1 2025-06-25 06:23:15 -07:00
Adam
ec4875933c refactor: change onLoad to onRiveReady 2025-06-23 11:57:58 -07:00
Adam
d808a8bdea feat: add onLoad callback to useRive 2025-06-23 11:57:58 -07:00
Hernan Torrisi
7b174f7f51 cleanup rive on unmount 2025-06-18 16:25:59 -07:00
bodymovin
eecd0d3c5b chore: release 4.21.3 2025-06-08 17:57:17 +00:00
Hernan Torrisi
6c00364e60 rive react 2.29.3 2025-06-08 10:22:55 -07:00
HayesGordon
d310f1c96d chore: release 4.21.2 2025-06-05 20:43:20 +00:00
CI
68e8fbe46d chore: bump Rive wasm 2.29.2 2025-06-05 22:40:54 +02:00
bodymovin
8ff9a844fe chore: release 4.21.1 2025-05-28 16:15:41 +00:00
Hernan Torrisi
a565795452 bump rive to 2.29.0 2025-05-28 08:10:54 -07:00
HayesGordon
b26280ae12 chore: release 4.21.0 2025-05-23 16:44:06 +00:00
CI
a62e89de94 chore: bump rive wasm 2.28.0 2025-05-23 18:20:22 +02:00
HayesGordon
31255f9746 chore: release 4.20.2 2025-05-23 15:24:28 +00:00
CI
3e768533df chore: bump rive wasm 2.27.5 2025-05-22 14:42:31 +02:00
bodymovin
c790e66723 chore: release 4.20.1 2025-05-14 06:46:19 +00:00
Hernan Torrisi
ab89793032 rive canvas 2.27.3 2025-05-13 23:43:41 -07:00
damzobridge
2088c0bf9b chore: release 4.20.0 2025-05-12 22:51:11 +00:00
Adam
81fdf57736 chore: simplify useViewModelInstance 2025-05-12 14:16:00 -07:00
Adam
3a59585207 fix: implement useViewModel feedback 2025-05-12 14:16:00 -07:00
Adam
46e19874a2 feat: add db test 2025-05-12 14:16:00 -07:00
Lance
d4de776a01 Add onTrigger example 2025-05-12 14:16:00 -07:00
Lance
fb4f543077 Add stocks data binding example 2025-05-12 14:16:00 -07:00
Lance
345905f810 Merge storybook 2025-05-12 14:16:00 -07:00
Adam
0d9dabb135 fix: use default view model if none provide in useViewModelInstance and useViewModel 2025-05-12 14:16:00 -07:00
Adam
ad12fe20d1 fix: unify effects in useViewModelInstance 2025-05-12 14:16:00 -07:00
Adam
4e45f74a47 fix: change useViewModel property hook parameters to match conventions 2025-05-12 14:16:00 -07:00
Adam
efd6c4ce82 chore: simplify useViewModel 2025-05-12 14:16:00 -07:00
Adam
37e379091c fix: avoid rebuilding operations unless necessary 2025-05-12 14:16:00 -07:00
Adam
6d76e9f85d fix: hot reload crash 2025-05-12 14:16:00 -07:00
Adam
e7b64201ca chore: update UseViewModelParameters documentation 2025-05-12 14:16:00 -07:00
Adam
5dc8362107 fix: remove viewmodel hooks from initial release 2025-05-12 14:16:00 -07:00
Adam
56bc96fa76 chore: update color setter methods to be more explicit 2025-05-12 14:16:00 -07:00
Adam
01ab78db97 fix: lint issue 2025-05-12 14:16:00 -07:00
Adam
7800cc041f feat: add instance property hooks 2025-05-12 14:16:00 -07:00
Hernan Torrisi
1f9fc84629 hook with generic 2025-05-12 14:16:00 -07:00
Hernan Torrisi
452eb89e72 inital work for data binding hooks 2025-05-12 14:16:00 -07:00
Lance
3109e45724 Remove GH Action that published Stories to GH Pages 2025-05-12 11:38:30 -06:00
avivian
d303e8c96f chore: release 4.19.1 2025-05-08 11:24:05 +00:00
Arthur Vivian
479d5340e8 chore: rive-wasm -> 2.27.2 2025-05-08 12:20:01 +01:00
35 changed files with 5879 additions and 1794 deletions

View File

@@ -14,9 +14,10 @@ jobs:
publish_job:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT_GITHUB }}
- name: Setup Git config
run: |
git config --local user.email 'hello@rive.app'
@@ -39,20 +40,14 @@ jobs:
name: Major Release - Bump version number, update changelog, push and tag
run: npm run release:major
env:
GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }}
PAT_GITHUB: ${{ secrets.PAT_GITHUB }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- if: ${{inputs.major == false && inputs.minor == true}}
name: Minor release - Bump version number, update changelog, push and tag
run: npm run release:minor
env:
GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }}
PAT_GITHUB: ${{ secrets.PAT_GITHUB }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- if: ${{inputs.major == false && inputs.minor == false}}
name: Patch release - Bump version number, update changelog, push and tag
run: npm run release:patch
env:
GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }}
PAT_GITHUB: ${{ secrets.PAT_GITHUB }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -1,30 +0,0 @@
name: Deploy Storybook
on:
# Testing to see if this job is causing the race condition
workflow_dispatch:
# pull_request:
# types: [closed]
# branches:
# - main
# paths: ['src', 'examples/stories/**'] # Trigger the action only when files change in the folders defined here
jobs:
build-and-deploy:
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
with:
persist-credentials: false
- name: Install and Build 🔧
run: | # Install npm packages and build the Storybook files
npm install
npm run build-storybook
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@3.6.2
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: main # The branch the action should deploy to.
FOLDER: docs-build # The folder that the build-storybook script generates files.
CLEAN: true # Automatically remove deleted files from the deploy branch
TARGET_FOLDER: docs # The folder that we serve our Storybook files from

View File

@@ -4,8 +4,102 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v4.22.1](https://github.com/rive-app/rive-react/compare/v4.22.0...v4.22.1)
- bump rive to 2.30.4 [`c151ee3`](https://github.com/rive-app/rive-react/commit/c151ee37b5482cb7eee258c84f6c52182dbe9db5)
#### [v4.22.0](https://github.com/rive-app/rive-react/compare/v4.21.6...v4.22.0)
> 15 July 2025
- feat: add tests for artboard binding [`74e1d5a`](https://github.com/rive-app/rive-react/commit/74e1d5a5f29f14f46be3af3d052bb51c3d833799)
- feat: add useViewModelInstanceArtboard hook [`963ecc4`](https://github.com/rive-app/rive-react/commit/963ecc43b80e6465d159621d014b70b8cbfee9d4)
- chore: release 4.22.0 [`c660a67`](https://github.com/rive-app/rive-react/commit/c660a675c246af9fca50795ff88b7935c2d2a101)
#### [v4.21.6](https://github.com/rive-app/rive-react/compare/v4.21.5...v4.21.6)
> 15 July 2025
- chore: release 4.21.6 [`85807f2`](https://github.com/rive-app/rive-react/commit/85807f2166fcfba01e4556ac346b769d6fa08341)
- rive_canvas_2.30.3 [`9a33504`](https://github.com/rive-app/rive-react/commit/9a33504d3a315ce2f3dff753192b0ae491a56a04)
#### [v4.21.5](https://github.com/rive-app/rive-react/compare/v4.21.4...v4.21.5)
> 14 July 2025
- feat: add tests for list property [`22f8d5a`](https://github.com/rive-app/rive-react/commit/22f8d5a945c74974b7dabcfe16aaa019f6141326)
- feat: add useViewModelInstanceImage hook [`eef56fb`](https://github.com/rive-app/rive-react/commit/eef56fb641839b55806296873186aa53b3e1d068)
- feat: add useViewModelInstanceList hook [`721ed78`](https://github.com/rive-app/rive-react/commit/721ed786dc43a526eafb54108bfb54f353d7430d)
#### [v4.21.4](https://github.com/rive-app/rive-react/compare/v4.21.3...v4.21.4)
> 25 June 2025
- chore: release 4.21.4 [`4bc0f49`](https://github.com/rive-app/rive-react/commit/4bc0f496f87a54ffda673acb7b9be4b7a8b311c0)
- cleanup rive on unmount [`7b174f7`](https://github.com/rive-app/rive-react/commit/7b174f7f5106b1b863969bd7318a8a6cb1a12b67)
- refactor: change onLoad to onRiveReady [`ec48759`](https://github.com/rive-app/rive-react/commit/ec4875933cad45a3d338290951d55ac9c72df9d0)
#### [v4.21.3](https://github.com/rive-app/rive-react/compare/v4.21.2...v4.21.3)
> 8 June 2025
- chore: release 4.21.3 [`eecd0d3`](https://github.com/rive-app/rive-react/commit/eecd0d3c5be011fe9865e45b05435fbd45e7395d)
- rive react 2.29.3 [`6c00364`](https://github.com/rive-app/rive-react/commit/6c00364e60e91a7a6556e763ebf9ebee4793b336)
#### [v4.21.2](https://github.com/rive-app/rive-react/compare/v4.21.1...v4.21.2)
> 5 June 2025
- chore: release 4.21.2 [`d310f1c`](https://github.com/rive-app/rive-react/commit/d310f1c96dbff6cbb7397d4bea2687c8d3f271f4)
- chore: bump Rive wasm 2.29.2 [`68e8fbe`](https://github.com/rive-app/rive-react/commit/68e8fbe46d4f1824a6228ce2ea0a02735dced5ba)
#### [v4.21.1](https://github.com/rive-app/rive-react/compare/v4.21.0...v4.21.1)
> 28 May 2025
- chore: release 4.21.1 [`8ff9a84`](https://github.com/rive-app/rive-react/commit/8ff9a844fe5b02a2eb1964cf01814479f6c72248)
- bump rive to 2.29.0 [`a565795`](https://github.com/rive-app/rive-react/commit/a565795452444205e88083cba272bc8ca6c9968f)
#### [v4.21.0](https://github.com/rive-app/rive-react/compare/v4.20.2...v4.21.0)
> 23 May 2025
- chore: release 4.21.0 [`b26280a`](https://github.com/rive-app/rive-react/commit/b26280ae125173b52dd9a6147833f45631c2252f)
- chore: bump rive wasm 2.28.0 [`a62e89d`](https://github.com/rive-app/rive-react/commit/a62e89de9436360e439896a1aa11623e3574897e)
#### [v4.20.2](https://github.com/rive-app/rive-react/compare/v4.20.1...v4.20.2)
> 23 May 2025
- chore: release 4.20.2 [`31255f9`](https://github.com/rive-app/rive-react/commit/31255f974635278aea211dcf827e3a0cd0cc138e)
- chore: bump rive wasm 2.27.5 [`3e76853`](https://github.com/rive-app/rive-react/commit/3e768533df747da69acd392332495303077fa8c6)
#### [v4.20.1](https://github.com/rive-app/rive-react/compare/v4.20.0...v4.20.1)
> 14 May 2025
- chore: release 4.20.1 [`c790e66`](https://github.com/rive-app/rive-react/commit/c790e6672389ea68ee222140a49bcb7e4a7d3ca3)
- rive canvas 2.27.3 [`ab89793`](https://github.com/rive-app/rive-react/commit/ab89793032bcadf58f680610cea2e15fcd76d0b2)
#### [v4.20.0](https://github.com/rive-app/rive-react/compare/v4.19.1...v4.20.0)
> 12 May 2025
- feat: add db test [`46e1987`](https://github.com/rive-app/rive-react/commit/46e19874a2ec5893b5d3365f61db871400327087)
- fix: hot reload crash [`6d76e9f`](https://github.com/rive-app/rive-react/commit/6d76e9f85d949ec1e0e4d29458676efbe1c24d1d)
- inital work for data binding hooks [`452eb89`](https://github.com/rive-app/rive-react/commit/452eb89e72ffb73f837917fd969a51ed238a6d05)
#### [v4.19.1](https://github.com/rive-app/rive-react/compare/v4.19.0...v4.19.1)
> 8 May 2025
- chore: release 4.19.1 [`d303e8c`](https://github.com/rive-app/rive-react/commit/d303e8c96f70fa8886d96aba35afd911f0fcef50)
- chore: rive-wasm -> 2.27.2 [`479d534`](https://github.com/rive-app/rive-react/commit/479d5340e87f5335a2525b547e690be60ebafc00)
#### [v4.19.0](https://github.com/rive-app/rive-react/compare/v4.18.9...v4.19.0)
> 29 April 2025
- Add examples [`5354d1f`](https://github.com/rive-app/rive-react/commit/5354d1f69bfe91dc67c39cee80a6ea00c4c70cb1)
- Storybookk page reloads when the package in the parent changes [`7277ed2`](https://github.com/rive-app/rive-react/commit/7277ed2f0d877150637a69f2ff9122db1b151686)
- Upgrade React and React DOM [`a9a98fe`](https://github.com/rive-app/rive-react/commit/a9a98fece2caf727e941ce645be2f031efaf8a89)

View File

@@ -1,5 +1,4 @@
![Build Status](https://github.com/rive-app/rive-react/actions/workflows/tests.yml/badge.svg)
[![Storybook](https://cdn.jsdelivr.net/gh/storybookjs/brand@main/badge/badge-storybook.svg)](https://rive-app.github.io/rive-react)
![Discord badge](https://img.shields.io/discord/532365473602600965)
![Twitter handle](https://img.shields.io/twitter/follow/rive_app.svg?style=social&label=Follow)
@@ -55,8 +54,6 @@ This library supports React versions `^16.8.0` through `^18.0.0`.
Check out our Storybook instance that shows how to use the library in small examples, along with code snippets! This includes examples using the basic component, as well as the convenient hooks exported to take advantage of state machines.
- [Example page](https://rive-app.github.io/rive-react)
- [Login screen w/ input tracking](https://rive-app.github.io/rive-use-cases/?path=/story/example-loginformcomponent--primary)
- [Mouse tracking](https://codesandbox.io/s/rive-mouse-track-test-t0y965?file=/src/App.js)
- [Accessibility concerns](https://rive.app/blog/accesible-web-animations-aria-live-regions)

View File

@@ -17,7 +17,8 @@
},
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"test-storybook": "test-storybook"
},
"eslintConfig": {
"extends": [
@@ -47,8 +48,9 @@
"@storybook/react": "^8.6.12",
"@storybook/react-webpack5": "^8.6.12",
"@storybook/test": "^8.6.12",
"@storybook/test-runner": "^0.22.0",
"eslint-plugin-storybook": "^0.12.0",
"storybook": "^8.6.12",
"webpack": "^5.99.6"
}
}
}

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

BIN
examples/public/stocks.riv Normal file
View File

Binary file not shown.

View File

@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/react';
import DataBinding from './DataBinding';
const meta = {
title: 'DataBinding',
component: DataBinding,
parameters: {
layout: 'fullscreen',
},
args: {},
} satisfies Meta<typeof DataBinding>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@@ -0,0 +1,147 @@
import React, { useEffect } from 'react';
import {
useRive,
useViewModel,
useViewModelInstance,
useViewModelInstanceColor,
useViewModelInstanceNumber,
useViewModelInstanceString,
useViewModelInstanceEnum,
useViewModelInstanceTrigger,
} from '@rive-app/react-webgl2';
const randomValue = () => Math.random() * 200 - 100;
const DataBinding = () => {
const { rive, RiveComponent } = useRive({
src: 'stocks.riv',
artboard: 'Main',
stateMachines: 'State Machine 1',
autoplay: true,
autoBind: false,
});
// Get the default instance of the view model
const viewModel = useViewModel(rive, { name: 'Dashboard' });
const viewModelInstance = useViewModelInstance(viewModel, { rive });
// Get the view model instance properties
const { setValue: setTitle } = useViewModelInstanceString(
'title',
viewModelInstance
);
const { setValue: setLogoShape } = useViewModelInstanceEnum(
'logoShape',
viewModelInstance
);
const { setValue: setRootColor } = useViewModelInstanceColor(
'rootColor',
viewModelInstance
);
const { trigger: triggerSpinLogo } = useViewModelInstanceTrigger(
'triggerSpinLogo',
viewModelInstance
);
useViewModelInstanceTrigger('triggerButton', viewModelInstance, {
onTrigger: () => console.log('Button Triggered!'),
});
// Apple Values
const { setValue: setAppleName } = useViewModelInstanceString(
'apple/name',
viewModelInstance
);
const { setValue: setAppleStockChange } = useViewModelInstanceNumber(
'apple/stockChange',
viewModelInstance
);
const { value: appleColor } = useViewModelInstanceColor(
'apple/currentColor',
viewModelInstance
);
// Apple Values
const { setValue: setMicrosoftName } = useViewModelInstanceString(
'microsoft/name',
viewModelInstance
);
const { setValue: setMicrosoftStockChange } = useViewModelInstanceNumber(
'microsoft/stockChange',
viewModelInstance
);
// Tesla Values
const { setValue: setTeslaName } = useViewModelInstanceString(
'tesla/name',
viewModelInstance
);
const { setValue: setTeslaStockChange } = useViewModelInstanceNumber(
'tesla/stockChange',
viewModelInstance
);
useEffect(() => {
// Set initial values for the view model
if (
setTitle &&
setLogoShape &&
setRootColor &&
setAppleName &&
setMicrosoftName &&
setTeslaName
) {
setTitle('Rive Stocks Dashboard');
setLogoShape('triangle');
setRootColor(parseInt('ffc0ffee', 16));
setAppleName('AAPL');
setMicrosoftName('MSFT');
setTeslaName('TSLA');
}
// randomly generate stock values every 2 seconds
const interval = setInterval(() => {
const appleValue = randomValue();
const microsoftValue = randomValue();
const teslaValue = randomValue();
setAppleStockChange(appleValue);
setMicrosoftStockChange(microsoftValue);
setTeslaStockChange(teslaValue);
// If all the stock values are either all positive or all negative, spin the logo
if (
(appleValue > 0 && microsoftValue > 0 && teslaValue > 0) ||
(appleValue < 0 && microsoftValue < 0 && teslaValue < 0)
) {
triggerSpinLogo();
}
}, 2000);
return () => clearInterval(interval);
}, [
setTitle,
setLogoShape,
setRootColor,
setAppleName,
setMicrosoftName,
setTeslaName,
setAppleStockChange,
setMicrosoftStockChange,
setTeslaStockChange,
triggerSpinLogo,
]);
// listen for changes to the AAPL color and log them
useEffect(() => {
if (appleColor) {
console.log('Apple color changed:', appleColor);
}
}, [appleColor]);
return <RiveComponent />;
};
export default DataBinding;

View File

@@ -0,0 +1,527 @@
import React, { useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { within, expect, waitFor, userEvent } from '@storybook/test';
import { StringPropertyTest, NumberPropertyTest, BooleanPropertyTest, ColorPropertyTest, EnumPropertyTest, NestedViewModelTest, TriggerPropertyTest, PersonForm, PersonInstances, ImagePropertyTest, TodoListTest, ArtboardPropertyTest } from './DataBindingTests';
const meta: Meta = {
title: 'Tests/DataBinding',
parameters: {
layout: 'centered',
},
};
export default meta;
export const StringPropertyStory: StoryObj = {
name: 'String Property',
render: () => <StringPropertyTest src="person_databinding_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('name-value')).toBeTruthy();
}, { timeout: 3000 });
const nameInput = canvas.getByTestId<HTMLInputElement>('name-input');
await userEvent.clear(nameInput);
// Wait for the input to be cleared
await waitFor(() => {
expect(nameInput.value).toBe('');
}, { timeout: 1000 });
await userEvent.click(nameInput);
await userEvent.paste('Test User');
await waitFor(() => {
expect(nameInput.value).toBe('Test User');
}, { timeout: 2000 });
await waitFor(() => {
expect(canvas.getByTestId('name-value').textContent).toBe('Test User');
}, { timeout: 2000 });
}
};
export const NumberPropertyStory: StoryObj = {
name: 'Number Property',
render: () => <NumberPropertyTest src="person_databinding_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('age-value')).toBeTruthy();
}, { timeout: 2000 });
const ageInput = canvas.getByTestId<HTMLInputElement>('age-input');
const currentValue = ageInput.value;
expect(currentValue).toBe('23');
await userEvent.click(ageInput);
await userEvent.clear(ageInput);
await waitFor(() => {
expect(ageInput.value).toBe('0'); // This is a hack to wait for the input to be cleared
}, { timeout: 1000 });
await userEvent.paste('42');
await waitFor(() => {
expect(canvas.getByTestId('age-value').textContent).toBe('42');
}, { timeout: 2000 });
}
};
export const BooleanPropertyStory: StoryObj = {
name: 'Boolean Property',
render: () => <BooleanPropertyTest src="person_databinding_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('terms-value')).toBeTruthy();
}, { timeout: 2000 });
const termsCheckbox = canvas.getByTestId<HTMLInputElement>('terms-checkbox');
expect(termsCheckbox.checked).toBe(false);
expect(canvas.getByTestId('terms-value').textContent).toBe('false');
await userEvent.click(termsCheckbox);
// Verify terms update
await waitFor(() => {
expect(canvas.getByTestId('terms-value').textContent).toBe('true');
});
}
};
export const ColorPropertyStory: StoryObj = {
name: 'Color Property',
render: () => <ColorPropertyTest src="person_databinding_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load and the component to render
await waitFor(() => {
expect(canvas.getByTestId('color-value')).toBeTruthy();
expect(canvas.getByTestId('set-color-red')).toBeTruthy();
expect(canvas.getByTestId('set-color-blue')).toBeTruthy();
}, { timeout: 5000 });
const numberValueDiv = canvas.getByTestId('number-value');
const hexValueDiv = canvas.getByTestId('hex-value');
// Verify initial state is red
await waitFor(() => {
expect(hexValueDiv.textContent).toContain('Hex value: #ce2323');
expect(numberValueDiv.textContent).toContain('Number value: -3267805');
});
// Change color to Blue ---
const blueButton = canvas.getByTestId('set-color-blue');
await userEvent.click(blueButton);
// Verify Blue State
await waitFor(() => {
expect(numberValueDiv.textContent).toContain('Number value: -16776961');
expect(hexValueDiv.textContent).toContain('Hex value: #0000ff');
});
// Change color back to Red ---
const redButton = canvas.getByTestId('set-color-red');
await userEvent.click(redButton);
// Verify Red State
await waitFor(() => {
expect(numberValueDiv.textContent).toContain('Number value: -65536');
expect(hexValueDiv.textContent).toContain('Hex value: #ff0000');
});
}
};
export const EnumPropertyStory: StoryObj = {
name: 'Enum Property',
render: () => <EnumPropertyTest src="person_databinding_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('country-value')).toBeTruthy();
});
// Wait for options to be loaded
await waitFor(() => {
const countrySelect = canvas.getByTestId<HTMLSelectElement>('country-select');
return countrySelect.options.length > 0;
});
const countrySelect = canvas.getByTestId<HTMLSelectElement>('country-select');
// Verify that the dropdown contains usa, japan, and canada
const optionValues = Array.from(countrySelect.options).map(option => option.value);
expect(optionValues).toContain('usa');
expect(optionValues).toContain('japan');
expect(optionValues).toContain('canada');
const currentValue = countrySelect.value;
expect(currentValue).toBe('usa');
let optionToSelect = 'japan';
await userEvent.selectOptions(countrySelect, optionToSelect);
await waitFor(() => {
expect(canvas.getByTestId('country-value').textContent).toBe(optionToSelect);
});
}
};
export const NestedViewModelStory: StoryObj = {
name: 'Nested ViewModel Property',
render: () => <NestedViewModelTest src="person_databinding_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('drink-type-value')).toBeTruthy();
});
// Wait for options to be loaded
await waitFor(() => {
const drinkTypeSelect = canvas.getByTestId<HTMLSelectElement>('drink-type-select');
return drinkTypeSelect.options.length > 0;
}, { timeout: 2000 });
const drinkTypeSelect = canvas.getByTestId<HTMLSelectElement>('drink-type-select');
const optionValues = Array.from(drinkTypeSelect.options).map(option => option.value);
expect(optionValues).toContain('Coffee');
expect(optionValues).toContain('Tea');
expect(drinkTypeSelect.value).toBe('Tea');
let optionToSelect = 'Coffee';
await userEvent.selectOptions(drinkTypeSelect, optionToSelect);
await waitFor(() => {
expect(canvas.getByTestId('drink-type-value').textContent).toBe(optionToSelect);
});
}
};
export const TriggerPropertyStory: StoryObj = {
name: 'Trigger Property',
render: () => <TriggerPropertyTest src="person_databinding_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('submit-button')).toBeTruthy();
}, { timeout: 2000 });
expect(canvas.getByTestId('callback-triggered').textContent).toContain('none');
// Trigger submit action
await userEvent.click(canvas.getByTestId('submit-button'));
await waitFor(() => {
expect(canvas.getByTestId('callback-triggered').textContent).toContain('submit-callback');
});
await userEvent.click(canvas.getByTestId('reset-button'));
// Verify onTrigger callback works for reset
await waitFor(() => {
expect(canvas.getByTestId('callback-triggered').textContent).toContain('reset-callback');
});
}
};
export const PersonInstancesStory: StoryObj = {
name: 'Person Instances',
render: () => <PersonInstances src="person_databinding_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('instance-name')).toBeTruthy();
expect(canvas.getByTestId('select-jane')).toBeTruthy();
expect(canvas.getByTestId('select-default')).toBeTruthy();
}, { timeout: 2000 });
// Initially should show Steve
expect(canvas.getByTestId('instance-name').textContent).toContain('Steve');
// Switch to Jane
const janeButton = canvas.getByTestId('select-jane');
await userEvent.click(janeButton);
// Verify instance changed to Jane
await waitFor(() => {
expect(canvas.getByTestId('instance-name').textContent).toContain('Jane');
});
// Switch to Default instance
const defaultButton = canvas.getByTestId('select-default');
await userEvent.click(defaultButton);
// Verify instance changed to Default
await waitFor(() => {
expect(canvas.getByTestId('instance-name').textContent).toContain('Default');
});
// Switch back to Steve
const steveButton = canvas.getByTestId('select-steve');
await userEvent.click(steveButton);
// Verify instance changed back to Steve
await waitFor(() => {
expect(canvas.getByTestId('instance-name').textContent).toContain('Steve');
});
}
};
// A configurable form story, so we can test all the properties at once
export const PersonFormStory: StoryObj = {
name: 'Complete Person Form',
render: () => <PersonForm src="person_databinding_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('name-value')).toBeTruthy();
}, { timeout: 2000 });
// Update name
const nameInput = canvas.getByTestId('name-input');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Test User');
// Update age
const ageInput = canvas.getByTestId('age-input');
await userEvent.clear(ageInput);
await userEvent.type(ageInput, '42');
// Toggle terms agreement
const termsCheckbox = canvas.getByTestId('terms-checkbox');
await userEvent.click(termsCheckbox);
// Change color
const colorButton = canvas.getByTestId('set-color-red');
await userEvent.click(colorButton);
// Change country
const countrySelect = canvas.getByTestId<HTMLSelectElement>('country-select');
await userEvent.selectOptions(countrySelect, 'japan');
// Change drink type
const drinkTypeSelect = canvas.getByTestId<HTMLSelectElement>('drink-type-select');
await userEvent.selectOptions(drinkTypeSelect, 'Coffee');
// Submit the form
const submitButton = canvas.getByTestId('submit-button');
await userEvent.click(submitButton);
}
};
export const ImagePropertyStory: StoryObj = {
name: 'Image Property',
render: () => <ImagePropertyTest src="image_db_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('load-random-image')).toBeTruthy();
expect(canvas.getByTestId('clear-image')).toBeTruthy();
}, { timeout: 3000 });
const loadImageButton = canvas.getByTestId('load-random-image');
const clearImageButton = canvas.getByTestId('clear-image');
expect(canvas.queryByTestId('current-image-url')).toBeNull();
// Load a random image
await userEvent.click(loadImageButton);
// Wait for the image to load and URL to appear
await waitFor(() => {
expect(canvas.getByTestId('current-image-url')).toBeTruthy();
}, { timeout: 5000 });
// Verify the image URL is displayed
const imageUrlElement = canvas.getByTestId('current-image-url');
expect(imageUrlElement.textContent).toContain('Current image: https://picsum.photos');
// Clear the image
await userEvent.click(clearImageButton);
// Load another image to test it works multiple times
await userEvent.click(loadImageButton);
// Wait for the new image to load
await waitFor(() => {
expect(canvas.getByTestId('current-image-url')).toBeTruthy();
}, { timeout: 5000 });
}
};
export const TodoListStory: StoryObj = {
name: 'Todo List Property',
render: () => <TodoListTest src="db_list_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('list-length')).toBeTruthy();
}, { timeout: 3000 });
const initialLengthText = canvas.getByTestId('list-length').textContent;
const initialCount = parseInt(initialLengthText?.match(/Items: (\d+)/)?.[1] || '0');
// Test 1: addInstance - Add item to end
const addButton = canvas.getByTestId('add-item-button');
await userEvent.click(addButton);
await waitFor(() => {
expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${initialCount + 1}`);
});
// Test 2: addInstanceAt - Add item at specific index (if we have items)
if (initialCount > 0) {
const addAtButton = canvas.getByTestId('add-item-at-button');
await userEvent.click(addAtButton);
await waitFor(() => {
expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${initialCount + 2}`);
});
}
// Test 3: getInstanceAt - Interact with specific items
const currentCount = initialCount + (initialCount > 0 ? 2 : 1);
if (currentCount > 0) {
await waitFor(() => {
expect(canvas.getByTestId('todo-item-0')).toBeTruthy();
});
// Edit the first item
const todoText = canvas.getByTestId('todo-text-0');
await userEvent.clear(todoText);
// Wait for the input to be cleared to avoid issues with autocomplete
await waitFor(() => {
expect((todoText as HTMLInputElement).value).toBe('');
}, { timeout: 2000 });
await userEvent.click(todoText);
await userEvent.paste('Test Item');
await waitFor(() => {
expect(canvas.getByTestId('todo-text-value-0').textContent).toContain('Test Item');
}, { timeout: 3000 });
}
// Test 4: swap - Swap first two items
if (currentCount >= 2) {
const firstText = canvas.getByTestId<HTMLInputElement>('todo-text-0').value;
const secondText = canvas.getByTestId<HTMLInputElement>('todo-text-1').value;
const swapButton = canvas.getByTestId('swap-button');
await userEvent.click(swapButton);
await waitFor(() => {
expect(canvas.getByTestId('todo-text-0')).toHaveValue(secondText);
expect(canvas.getByTestId('todo-text-1')).toHaveValue(firstText);
}, { timeout: 3000 });
}
// Test 5: removeInstance - Remove by instance reference
if (currentCount > 0) {
const removeInstanceButton = canvas.getByTestId('remove-instance-button');
await userEvent.click(removeInstanceButton);
await waitFor(() => {
expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${currentCount - 1}`);
}, { timeout: 3000 });
}
// Test 6: removeInstanceAt - Remove by index
const afterRemoveInstance = currentCount > 0 ? currentCount - 1 : 0;
if (afterRemoveInstance > 0) {
const removeIndexButton = canvas.getByTestId('remove-index-button');
await userEvent.click(removeIndexButton);
await waitFor(() => {
expect(canvas.getByTestId('list-length').textContent).toContain(`Items: ${afterRemoveInstance - 1}`);
}, { timeout: 3000 });
}
}
};
export const ArtboardPropertyStory: StoryObj = {
name: 'Artboard Property',
render: () => <ArtboardPropertyTest src="artboard_db_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Wait for the Rive file to load
await waitFor(() => {
expect(canvas.getByTestId('set-artboard1-blue')).toBeTruthy();
expect(canvas.getByTestId('set-artboard1-red')).toBeTruthy();
expect(canvas.getByTestId('set-artboard1-green')).toBeTruthy();
}, { timeout: 3000 });
// Initially should show None
expect(canvas.getByTestId('artboard1-current').textContent).toBe('Current: None');
expect(canvas.getByTestId('artboard2-current').textContent).toBe('Current: None');
// Set artboard 1 to blue
await userEvent.click(canvas.getByTestId('set-artboard1-blue'));
await waitFor(() => {
expect(canvas.getByTestId('artboard1-current').textContent).toBe('Current: ArtboardBlue');
});
// Set artboard 2 to red
await userEvent.click(canvas.getByTestId('set-artboard2-red'));
await waitFor(() => {
expect(canvas.getByTestId('artboard2-current').textContent).toBe('Current: ArtboardRed');
});
// Switch artboard 1 to green
await userEvent.click(canvas.getByTestId('set-artboard1-green'));
await waitFor(() => {
expect(canvas.getByTestId('artboard1-current').textContent).toBe('Current: ArtboardGreen');
});
// Switch artboard 2 to blue
await userEvent.click(canvas.getByTestId('set-artboard2-blue'));
await waitFor(() => {
expect(canvas.getByTestId('artboard2-current').textContent).toBe('Current: ArtboardBlue');
});
}
};

View File

@@ -0,0 +1,881 @@
import React, { useEffect, useState } from 'react';
import Rive, {
useRive,
useViewModel,
useViewModelInstance,
useViewModelInstanceBoolean,
useViewModelInstanceString,
useViewModelInstanceNumber,
useViewModelInstanceEnum,
useViewModelInstanceColor,
useViewModelInstanceTrigger,
useViewModelInstanceImage,
decodeImage,
ViewModelInstance,
useViewModelInstanceList,
useViewModelInstanceArtboard
} from '@rive-app/react-webgl2';
export const StringPropertyTest = ({ src }: { src: string }) => {
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
artboard: "Artboard",
autoBind: true,
stateMachines: "State Machine 1",
});
const { value: name, setValue: setName } = useViewModelInstanceString('name', rive?.viewModelInstance);
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{(rive === null) ? <div data-testid="loading-text">Loading</div> : (
<div>
<label>
Name:
<input
data-testid="name-input"
type="text"
value={name || ''}
onChange={(e) => setName(e.target.value)}
autoFocus={false}
/>
</label>
<div data-testid="name-value">{name}</div>
</div>
)}
</div>
);
};
export const NumberPropertyTest = ({ src }: { src: string }) => {
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
artboard: "Artboard",
autoBind: true,
stateMachines: "State Machine 1",
});
const { value: age, setValue: setAge } = useViewModelInstanceNumber('age', rive?.viewModelInstance);
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{(rive === null) ? <div data-testid="loading-text">Loading</div> : (
<div>
<label>
Age:
<input
data-testid="age-input"
type="number"
value={age ?? 0}
onChange={(e) => setAge(Number(e.target.value))}
autoFocus={false}
/>
</label>
<div data-testid="age-value">{age}</div>
</div>
)}
</div>
);
};
export const BooleanPropertyTest = ({ src }: { src: string }) => {
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
artboard: "Artboard",
autoBind: true,
stateMachines: "State Machine 1",
});
const { value: agreedToTerms, setValue: setAgreedToTerms } = useViewModelInstanceBoolean('agreedToTerms', rive?.viewModelInstance);
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{(rive === null) ? <div data-testid="loading-text">Loading</div> : (
<div>
<label>
<input
data-testid="terms-checkbox"
type="checkbox"
checked={agreedToTerms ?? false}
onChange={(e) => setAgreedToTerms(e.target.checked)}
/>
Agree to Terms
</label>
<div data-testid="terms-value">{agreedToTerms ? 'true' : 'false'}</div>
</div>
)}
</div>
);
};
const colorNumberToHexString = (colorNum: number | null) => {
if (colorNum === null) {
return 'N/A';
}
const unsignedInt = colorNum >>> 0;
const r = (unsignedInt >> 16) & 0xff;
const g = (unsignedInt >> 8) & 0xff;
const b = unsignedInt & 0xff;
const toHex = (c: number) => c.toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
export const ColorPropertyTest = ({ src }: { src: string }) => {
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
artboard: "Artboard",
autoBind: true,
stateMachines: "State Machine 1",
});
const { value: colorNum, setValue: setColor, setRgb } = useViewModelInstanceColor('favColor', rive?.viewModelInstance);
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{(rive === null) ? <div data-testid="loading-text">Loading</div> : (
<div>
<label>
Favorite Color:
<div data-testid="color-value" style={{
backgroundColor: typeof colorNum === 'string' ? colorNum : colorNumberToHexString(colorNum),
width: '20px',
height: '20px',
display: 'inline-block',
marginLeft: '10px'
}}></div>
<div data-testid="number-value">
Number value: {typeof colorNum === 'number' ? colorNum : 'N/A'}
</div>
<div data-testid="hex-value">
Hex value: {colorNumberToHexString(colorNum)}
</div>
</label>
<button
data-testid="set-color-red"
type="button"
onClick={() => setRgb(255, 0, 0)}
>
Red
</button>
<button
data-testid="set-color-blue"
type="button"
onClick={() => setRgb(0, 0, 255)}
>
Blue
</button>
</div>
)}
</div>
);
};
export const EnumPropertyTest = ({ src }: { src: string }) => {
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
artboard: "Artboard",
autoBind: true,
stateMachines: "State Machine 1",
});
const { value: country, setValue: setCountry, values: countries } = useViewModelInstanceEnum('country', rive?.viewModelInstance);
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{(rive === null) ? <div data-testid="loading-text">Loading</div> : (
<div>
<label>
Country:
<select
data-testid="country-select"
value={country || ''}
onChange={(e) => setCountry(e.target.value)}
>
{countries.map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
</label>
<div data-testid="country-value">{country}</div>
</div>
)}
</div>
);
};
export const NestedViewModelTest = ({ src }: { src: string }) => {
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
artboard: "Artboard",
autoBind: true,
stateMachines: "State Machine 1",
});
const { value: drinkType, setValue: setDrinkType, values: drinkTypes } = useViewModelInstanceEnum('favDrink/type', rive?.viewModelInstance);
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{(rive === null) ? <div data-testid="loading-text">Loading</div> : (
<div>
<label>
Favorite Drink Type:
<select
data-testid="drink-type-select"
value={drinkType || ''}
onChange={(e) => setDrinkType(e.target.value)}
>
{drinkTypes.map(dt => (
<option key={dt} value={dt}>{dt}</option>
))}
</select>
</label>
<div data-testid="drink-type-value">{drinkType}</div>
</div>
)}
</div>
);
};
export const TriggerPropertyTest = ({ src }: { src: string }) => {
const [callbackTriggered, setCallbackTriggered] = useState('');
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
autoBind: true,
artboard: "Artboard",
stateMachines: "State Machine 1",
});
const { trigger: onFormSubmit } = useViewModelInstanceTrigger('onFormSubmit', rive?.viewModelInstance,
{
onTrigger: () => {
setCallbackTriggered('submit-callback');
}
}
);
const { trigger: onFormReset } = useViewModelInstanceTrigger('onFormReset', rive?.viewModelInstance,
{
onTrigger: () => {
setCallbackTriggered('reset-callback');
}
}
);
const handleSubmit = () => {
onFormSubmit();
};
const handleReset = () => {
onFormReset();
};
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{(rive === null) ? <div data-testid="loading-text">Loading</div> : (
<div>
<button data-testid="submit-button" type="button" onClick={handleSubmit}>Submit</button>
<button data-testid="reset-button" type="button" onClick={handleReset}>Reset</button>
<div data-testid="callback-triggered">
Last callback triggered: {callbackTriggered || 'none'}
</div>
</div>
)}
</div>
);
};
export const PersonForm = ({ src }: { src: string }) => {
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
autoBind: true,
artboard: "Artboard",
stateMachines: "State Machine 1",
});
const { value: name, setValue: setName } = useViewModelInstanceString('name', rive?.viewModelInstance);
const { value: age, setValue: setAge } = useViewModelInstanceNumber('age', rive?.viewModelInstance);
const { value: agreedToTerms, setValue: setAgreedToTerms } = useViewModelInstanceBoolean('agreedToTerms', rive?.viewModelInstance);
const { value: colorNum, setRgb } = useViewModelInstanceColor('favColor', rive?.viewModelInstance);
const { value: country, setValue: setCountry, values: countries } = useViewModelInstanceEnum('country', rive?.viewModelInstance);
const { trigger: onFormSubmit } = useViewModelInstanceTrigger('onFormSubmit', rive?.viewModelInstance);
const { trigger: onFormReset } = useViewModelInstanceTrigger('onFormReset', rive?.viewModelInstance);
// Drink properties (nested viewmodel)
const { value: drinkType, setValue: setDrinkType, values: drinkTypes } = useViewModelInstanceEnum('favDrink/type', rive?.viewModelInstance);
const handleReset = () => {
setName('');
setAge(0);
setAgreedToTerms(false);
setRgb(0, 0, 0);
setCountry(countries[0]);
setDrinkType(drinkTypes[0]);
onFormReset();
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
onFormSubmit();
};
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{(rive === null) ? <div data-testid="loading-text">Loading</div> : (
<form onSubmit={handleSubmit}>
<div>
<label>
Name:
<input
data-testid="name-input"
type="text"
value={name || ''}
onChange={(e) => setName(e.target.value)}
/>
</label>
<div data-testid="name-value">{name}</div>
</div>
<div>
<label>
Age:
<input
data-testid="age-input"
type="number"
value={age || 0}
onChange={(e) => setAge(Number(e.target.value))}
/>
</label>
<div data-testid="age-value">{age}</div>
</div>
<div>
<label>
<input
data-testid="terms-checkbox"
type="checkbox"
checked={agreedToTerms || false}
onChange={(e) => setAgreedToTerms(e.target.checked)}
/>
Agree to Terms
</label>
<div data-testid="terms-value">{agreedToTerms ? 'true' : 'false'}</div>
</div>
<div>
<label>
Favorite Color:
<div data-testid="color-value" style={{
backgroundColor: colorNumberToHexString(colorNum),
width: '20px',
height: '20px',
display: 'inline-block',
marginLeft: '10px'
}}></div>
</label>
<button
data-testid="set-color-red"
type="button"
onClick={() => setRgb(255, 0, 0)}
>
Red
</button>
<button
data-testid="set-color-blue"
type="button"
onClick={() => setRgb(0, 0, 255)}
>
Blue
</button>
</div>
<div>
<label>
Country:
<select
data-testid="country-select"
value={country || ''}
onChange={(e) => setCountry(e.target.value)}
>
{countries.map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
</label>
<div data-testid="country-value">{country}</div>
</div>
<div>
<label>
Favorite Drink Type:
<select
data-testid="drink-type-select"
value={drinkType || ''}
onChange={(e) => setDrinkType(e.target.value)}
>
{drinkTypes.map(dt => (
<option key={dt} value={dt}>{dt}</option>
))}
</select>
</label>
<div data-testid="drink-type-value">{drinkType}</div>
</div>
<div>
<button data-testid="submit-button" type="submit">Submit</button>
<button data-testid="reset-button" type="button" onClick={handleReset}>Reset</button>
</div>
</form>
)}
</div>
);
};
// Component to demonstrate different viewmodel instances
export const PersonInstances = ({ src }: { src: string }) => {
const [activeInstance, setActiveInstance] = useState('Steve');
const [useDefaultInstance, setUseDefaultInstance] = useState(false);
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
artboard: "Artboard",
stateMachines: "State Machine 1",
});
const viewModel = useViewModel(rive, { name: 'PersonViewModel' });
const params = useDefaultInstance ? { useDefault: true, rive } : { name: activeInstance, rive }
const viewModelInstance = useViewModelInstance(viewModel, params);
const { value: name } = useViewModelInstanceString('name', viewModelInstance);
const { value: age } = useViewModelInstanceNumber('age', viewModelInstance);
const { value: country } = useViewModelInstanceEnum('country', viewModelInstance);
const switchToNamedInstance = (instanceName: string) => {
setActiveInstance(instanceName);
setUseDefaultInstance(false);
};
const switchToDefaultInstance = () => {
setUseDefaultInstance(true);
};
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{(rive === null) ? <div data-testid="loading-text">Loading</div> : (
<div>
<button
data-testid="select-steve"
onClick={() => switchToNamedInstance('Steve')}
disabled={!useDefaultInstance && activeInstance === 'Steve'}
>
Steve
</button>
<button
data-testid="select-jane"
onClick={() => switchToNamedInstance('Jane')}
disabled={!useDefaultInstance && activeInstance === 'Jane'}
>
Jane
</button>
<button
data-testid="select-default"
onClick={switchToDefaultInstance}
disabled={useDefaultInstance}
>
Default
</button>
</div>
)}
<div>
<h3 data-testid="instance-name">Instance: {useDefaultInstance ? 'Default' : activeInstance}</h3>
<p data-testid="person-name">Name: {name}</p>
<p data-testid="person-age">Age: {age}</p>
<p data-testid="person-country">Country: {country}</p>
</div>
</div>
);
};
export const ImagePropertyTest = ({ src }: { src: string }) => {
const [currentImageUrl, setCurrentImageUrl] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const { rive, RiveComponent } = useRive({
src,
artboard: "Artboard",
stateMachines: "State Machine 1",
autoplay: true,
autoBind: false,
});
const viewModel = useViewModel(rive, { name: 'Post' });
const viewModelInstance = useViewModelInstance(viewModel, { rive });
const { setValue: setImage } = useViewModelInstanceImage(
'image',
viewModelInstance
);
const loadRandomImage = async () => {
if (!setImage) return;
setIsLoading(true);
try {
const imageUrl = `https://picsum.photos/400/300?random=${Date.now()}`;
setCurrentImageUrl(imageUrl);
const response = await fetch(imageUrl);
const imageBuffer = await response.arrayBuffer();
const decodedImage = await decodeImage(new Uint8Array(imageBuffer));
setImage(decodedImage);
decodedImage.unref();
} catch (error) {
console.error('Failed to load image:', error);
} finally {
setIsLoading(false);
}
};
const clearImage = () => {
if (setImage) {
setImage(null);
setCurrentImageUrl('');
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '20px' }}>
<div style={{ width: '400px', height: '300px', border: '1px solid #ccc' }}>
<RiveComponent />
</div>
{rive === null ? (
<div data-testid="loading-text">Loading</div>
) : (
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<button
onClick={loadRandomImage}
disabled={isLoading}
data-testid="load-random-image"
>
{isLoading ? 'Loading...' : 'Load Random Image'}
</button>
<button
onClick={clearImage}
disabled={isLoading}
data-testid="clear-image"
>
Clear Image
</button>
</div>
)}
{currentImageUrl && (
<div style={{ fontSize: '12px', color: '#666' }}>
<span data-testid="current-image-url">Current image: {currentImageUrl}</span>
</div>
)}
</div>
);
};
// List Property Test
const TodoItemComponent = ({
index,
todoItem
}: {
index: number;
todoItem: ViewModelInstance | null;
}) => {
const { value: text, setValue: setText } = useViewModelInstanceString('text', todoItem);
const { value: isDone, setValue: setIsDone } = useViewModelInstanceBoolean('isDone', todoItem);
if (!todoItem) {
return <div data-testid={`todo-item-${index}`}>Item not found</div>;
}
return (
<div data-testid={`todo-item-${index}`} style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px',
border: '1px solid #ccc',
marginBottom: '4px'
}}>
<input
data-testid={`todo-checkbox-${index}`}
type="checkbox"
checked={isDone ?? false}
onChange={(e) => setIsDone(e.target.checked)}
/>
<input
data-testid={`todo-text-${index}`}
type="text"
value={text || ''}
onChange={(e) => setText(e.target.value)}
style={{ flex: 1 }}
/>
<div data-testid={`todo-text-value-${index}`} style={{ fontSize: '12px', color: '#666' }}>
Text: {text}
</div>
<div data-testid={`todo-done-value-${index}`} style={{ fontSize: '12px', color: '#666' }}>
Done: {isDone ? 'true' : 'false'}
</div>
</div>
);
};
export const TodoListTest = ({ src }: { src: string }) => {
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
artboard: "Artboard",
autoBind: false,
stateMachines: "State Machine 1",
});
const viewModel = useViewModel(rive, { name: 'TodoList' });
const viewModelInstance = useViewModelInstance(viewModel, { rive });
const {
length,
addInstance,
addInstanceAt,
removeInstance,
removeInstanceAt,
getInstanceAt,
swap
} = useViewModelInstanceList('items', viewModelInstance);
const handleAddItem = () => {
const todoItemViewModel = rive?.viewModelByName?.('TodoItem');
if (todoItemViewModel) {
const newTodoItem = todoItemViewModel.instance?.();
if (newTodoItem) {
addInstance(newTodoItem);
}
}
};
const handleAddItemAt = () => {
const todoItemViewModel = rive?.viewModelByName?.('TodoItem');
if (todoItemViewModel && length > 0) {
const newTodoItem = todoItemViewModel.instance?.();
if (newTodoItem) {
addInstanceAt(newTodoItem, 1);
}
}
};
const handleRemoveFirstInstance = () => {
const firstInstance = getInstanceAt(0);
if (firstInstance) {
removeInstance(firstInstance);
}
};
const handleRemoveFirstByIndex = () => {
if (length > 0) {
removeInstanceAt(0);
}
};
const handleSwapItems = () => {
if (length >= 2) {
swap(0, 1);
}
};
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{rive === null ? (
<div data-testid="loading-text">Loading</div>
) : (
<div>
<div data-testid="list-length">Items: {length}</div>
<div style={{ marginBottom: '10px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
data-testid="add-item-button"
onClick={handleAddItem}
>
Add Item (End)
</button>
<button
data-testid="add-item-at-button"
onClick={handleAddItemAt}
disabled={length === 0}
>
Add Item at Index 1
</button>
<button
data-testid="remove-instance-button"
onClick={handleRemoveFirstInstance}
disabled={length === 0}
>
Remove First (by Instance)
</button>
<button
data-testid="remove-index-button"
onClick={handleRemoveFirstByIndex}
disabled={length === 0}
>
Remove First (by Index)
</button>
<button
data-testid="swap-button"
onClick={handleSwapItems}
disabled={length < 2}
>
Swap First Two
</button>
</div>
<div data-testid="todo-items">
{Array.from({ length }, (_, index) => (
<TodoItemComponent
key={index}
index={index}
todoItem={getInstanceAt(index)}
/>
))}
</div>
</div>
)}
</div>
);
};
export const ArtboardPropertyTest = ({ src }: { src: string }) => {
const [currentArtboard1, setCurrentArtboard1] = useState<string>('None');
const [currentArtboard2, setCurrentArtboard2] = useState<string>('None');
const { rive, RiveComponent } = useRive({
src,
autoplay: true,
artboard: "Main",
autoBind: true,
stateMachines: "State Machine 1",
});
const { setValue: setArtboard1 } = useViewModelInstanceArtboard('artboard_1', rive?.viewModelInstance);
const { setValue: setArtboard2 } = useViewModelInstanceArtboard('artboard_2', rive?.viewModelInstance);
const handleSetArtboard1 = (artboardName: string) => {
if (rive) {
const artboard = rive.getArtboard(artboardName);
setArtboard1(artboard);
setCurrentArtboard1(artboardName);
}
};
const handleSetArtboard2 = (artboardName: string) => {
if (rive) {
const artboard = rive.getArtboard(artboardName);
setArtboard2(artboard);
setCurrentArtboard2(artboardName);
}
};
return (
<div>
<RiveComponent style={{ width: '400px', height: '400px' }} />
{(rive === null) ? <div data-testid="loading-text">Loading</div> : (
<div>
<div style={{ marginBottom: '20px' }}>
<h4>Artboard 1:</h4>
<div style={{ display: 'flex', gap: '10px', marginBottom: '10px' }}>
<button
data-testid="set-artboard1-blue"
onClick={() => handleSetArtboard1('ArtboardBlue')}
>
Set Blue Artboard
</button>
<button
data-testid="set-artboard1-red"
onClick={() => handleSetArtboard1('ArtboardRed')}
>
Set Red Artboard
</button>
<button
data-testid="set-artboard1-green"
onClick={() => handleSetArtboard1('ArtboardGreen')}
>
Set Green Artboard
</button>
</div>
<div data-testid="artboard1-current">Current: {currentArtboard1}</div>
</div>
<div>
<h4>Artboard 2:</h4>
<div style={{ display: 'flex', gap: '10px', marginBottom: '10px' }}>
<button
data-testid="set-artboard2-blue"
onClick={() => handleSetArtboard2('ArtboardBlue')}
>
Set Blue Artboard
</button>
<button
data-testid="set-artboard2-red"
onClick={() => handleSetArtboard2('ArtboardRed')}
>
Set Red Artboard
</button>
<button
data-testid="set-artboard2-green"
onClick={() => handleSetArtboard2('ArtboardGreen')}
>
Set Green Artboard
</button>
</div>
<div data-testid="artboard2-current">Current: {currentArtboard2}</div>
</div>
</div>
)}
</div>
);
};

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@rive-app/react-canvas-lite",
"version": "4.19.0",
"version": "4.22.1",
"description": "React wrapper around the @rive-app/canvas-lite library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
@@ -18,7 +18,7 @@
},
"homepage": "https://github.com/rive-app/rive-react#readme",
"dependencies": {
"@rive-app/canvas-lite": "2.27.1"
"@rive-app/canvas-lite": "2.30.4"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@rive-app/react-canvas",
"version": "4.19.0",
"version": "4.22.1",
"description": "React wrapper around the @rive-app/canvas library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
@@ -18,7 +18,7 @@
},
"homepage": "https://github.com/rive-app/rive-react#readme",
"dependencies": {
"@rive-app/canvas": "2.27.1"
"@rive-app/canvas": "2.30.4"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@rive-app/react-webgl",
"version": "4.19.0",
"version": "4.22.1",
"description": "React wrapper around the @rive-app/webgl library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
@@ -18,7 +18,7 @@
},
"homepage": "https://github.com/rive-app/rive-react#readme",
"dependencies": {
"@rive-app/webgl": "2.27.1"
"@rive-app/webgl": "2.30.4"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0"

View File

@@ -1,6 +1,6 @@
{
"name": "@rive-app/react-webgl2",
"version": "4.19.0",
"version": "4.22.1",
"description": "React wrapper around the @rive-app/webgl2 library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
@@ -18,7 +18,7 @@
},
"homepage": "https://github.com/rive-app/rive-react#readme",
"dependencies": {
"@rive-app/webgl2": "2.27.1"
"@rive-app/webgl2": "2.30.4"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0"

View File

@@ -1,6 +1,6 @@
{
"name": "rive-react",
"version": "4.19.0",
"version": "4.22.1",
"description": "React wrapper around the rive-js library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
@@ -35,10 +35,10 @@
},
"homepage": "https://github.com/rive-app/rive-react#readme",
"dependencies": {
"@rive-app/canvas": "2.27.1",
"@rive-app/canvas-lite": "2.27.1",
"@rive-app/webgl": "2.27.1",
"@rive-app/webgl2": "2.27.1"
"@rive-app/canvas": "2.30.4",
"@rive-app/canvas-lite": "2.30.4",
"@rive-app/webgl": "2.30.4",
"@rive-app/webgl2": "2.30.4"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0"

View File

@@ -69,6 +69,7 @@ export default function useRive(
): RiveState {
const [canvasElem, setCanvasElem] = useState<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLElement | null>(null);
const riveRef = useRef<Rive | null>(null);
const [rive, setRive] = useState<Rive | null>(null);
@@ -130,13 +131,23 @@ export default function useRive(
let r: Rive | null;
if (rive == null) {
const { useOffscreenRenderer } = options;
const { onRiveReady, ...restRiveParams } = riveParams;
r = new Rive({
useOffscreenRenderer,
...riveParams,
...restRiveParams,
canvas: canvasElem,
});
if (riveRef.current != null) {
riveRef.current!.cleanup();
}
riveRef.current = r;
r.on(EventType.Load, () => {
isLoaded = true;
if (onRiveReady) {
onRiveReady(r!);
}
// Check if the component/canvas is mounted before setting state to avoid setState
// on an unmounted component in some rare cases
if (canvasElem) {
@@ -237,6 +248,14 @@ export default function useRive(
};
}, [rive, canvasElem]);
useEffect(() => {
return () => {
if (riveRef.current != null) {
riveRef.current!.cleanup();
}
};
}, []);
/**
* Listen for changes in the animations params
*/

55
src/hooks/useViewModel.ts Normal file
View File

@@ -0,0 +1,55 @@
import { useState, useEffect } from 'react';
import { Rive, ViewModel, EventType } from '@rive-app/canvas';
import { UseViewModelParameters } from '../types';
/**
* Hook for fetching a ViewModel from a Rive instance.
*
* @param rive - The Rive instance to retrieve the ViewModel from
* @param params - Options for retrieving a ViewModel
* @param params.name - When provided, specifies the name of the ViewModel to retrieve
* @param params.useDefault - When true, uses the default ViewModel from the Rive instance
* @returns The ViewModel or null if not found
*/
export default function useViewModel(
rive: Rive | null,
params?: UseViewModelParameters
): ViewModel | null {
const { name, useDefault = false } = params ?? {};
const [viewModel, setViewModel] = useState<ViewModel | null>(null);
useEffect(() => {
function fetchViewModel() {
if (!rive) {
setViewModel(null);
return;
}
let model: ViewModel | null = null;
if (name != null) {
model = rive.viewModelByName?.(name) || null;
} else if (useDefault) {
model = rive.defaultViewModel() || null;
} else {
model = rive.defaultViewModel() || null;
}
setViewModel(model);
}
fetchViewModel();
if (rive) {
rive.on(EventType.Load, fetchViewModel);
}
return () => {
if (rive) {
rive.off(EventType.Load, fetchViewModel);
}
};
}, [rive, name, useDefault]);
return viewModel;
}

View File

@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import { ViewModel, ViewModelInstance } from '@rive-app/canvas';
import { UseViewModelInstanceParameters } from '../types';
/**
* Hook for fetching a ViewModelInstance from a ViewModel.
*
* @param viewModel - The ViewModel to get an instance from
* @param params - Options for retrieving a ViewModelInstance
* @param params.name - When provided, specifies the name of the instance to retrieve
* @param params.useDefault - When true, uses the default instance from the ViewModel
* @param params.useNew - When true, creates a new instance of the ViewModel
* @param params.rive - If provided, automatically binds the instance to this Rive instance
* @returns The ViewModelInstance or null if not found
*/
export default function useViewModelInstance(
viewModel: ViewModel | null,
params?: UseViewModelInstanceParameters
): ViewModelInstance | null {
const { name, useDefault = false, useNew = false, rive } = params ?? {};
const [instance, setInstance] = useState<ViewModelInstance | null>(null);
useEffect(() => {
if (!viewModel) {
setInstance(null);
return;
}
let result: ViewModelInstance | null = null;
if (name != null) {
result = viewModel.instanceByName(name) || null;
} else if (useDefault) {
result = viewModel.defaultInstance?.() || null;
} else if (useNew) {
result = viewModel.instance?.() || null;
} else {
result = viewModel.defaultInstance?.() || null;
}
setInstance(result);
if (rive && result && rive.viewModelInstance !== result) {
rive.bindViewModelInstance(result);
}
}, [viewModel, name, useDefault, useNew, rive]);
return instance;
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { ViewModelInstance, ViewModelInstanceArtboard } from '@rive-app/canvas';
import { UseViewModelInstanceArtboardResult } from '../types';
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
/**
* Hook for interacting with artboard properties of a ViewModelInstance.
*
* @param path - Path to the artboard property (e.g. "targetArtboard" or "group/artboard")
* @param viewModelInstance - The ViewModelInstance containing the artboard property
* @returns An object with a setter function
*/
export default function useViewModelInstanceArtboard(
path: string,
viewModelInstance?: ViewModelInstance | null
): UseViewModelInstanceArtboardResult {
const result = useViewModelInstanceProperty<ViewModelInstanceArtboard, undefined, UseViewModelInstanceArtboardResult>(
path,
viewModelInstance,
{
getProperty: useCallback((vm, p) => vm.artboard(p), []),
getValue: useCallback(() => undefined, []), // Artboards properties don't currently have a readable value
defaultValue: null,
buildPropertyOperations: useCallback((safePropertyAccess) => ({
setValue: (newValue) => {
safePropertyAccess(prop => { prop.value = newValue; });
}
}), [])
}
);
return {
setValue: result.setValue
};
}

View File

@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { ViewModelInstanceBoolean, ViewModelInstance } from '@rive-app/canvas';
import { UseViewModelInstanceBooleanResult } from '../types';
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
/**
* Hook for interacting with boolean ViewModel instance properties.
*
* @param path - The path to the boolean property
* @param viewModelInstance - The ViewModelInstance containing the boolean property to operate on
* @returns An object with the boolean value and a setter function
*/
export default function useViewModelInstanceBoolean(
path: string,
viewModelInstance?: ViewModelInstance | null
): UseViewModelInstanceBooleanResult {
const result = useViewModelInstanceProperty<ViewModelInstanceBoolean, boolean, Omit<UseViewModelInstanceBooleanResult, 'value'>>(
path,
viewModelInstance,
{
getProperty: useCallback((vm, p) => vm.boolean(p), []),
getValue: useCallback((prop) => prop.value, []),
defaultValue: null,
buildPropertyOperations: useCallback((safePropertyAccess) => ({
setValue: (newValue: boolean) => {
safePropertyAccess(prop => { prop.value = newValue; });
}
}), [])
}
);
return {
value: result.value,
setValue: result.setValue
};
}

View File

@@ -0,0 +1,56 @@
import { useCallback } from 'react';
import { ViewModelInstanceColor, ViewModelInstance } from '@rive-app/canvas';
import { UseViewModelInstanceColorResult } from '../types';
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
/**
* Hook for interacting with color properties of a ViewModelInstance.
*
* @param path - Path to the color property
* @param viewModelInstance - The ViewModelInstance containing the color property
* @returns An object with the color value and setter functions for different color formats
*/
export default function useViewModelInstanceColor(
path: string,
viewModelInstance?: ViewModelInstance | null
): UseViewModelInstanceColorResult {
const result = useViewModelInstanceProperty<ViewModelInstanceColor, number, Omit<UseViewModelInstanceColorResult, 'value'>>(
path,
viewModelInstance,
{
getProperty: useCallback((vm, p) => vm.color(p), []),
getValue: useCallback((prop) => prop.value, []),
defaultValue: null,
buildPropertyOperations: useCallback((safePropertyAccess) => ({
setValue: (newValue: number) => {
safePropertyAccess(prop => { prop.value = newValue; });
},
setRgb: (r: number, g: number, b: number) => {
safePropertyAccess(prop => { prop.rgb(r, g, b); });
},
setRgba: (r: number, g: number, b: number, a: number) => {
safePropertyAccess(prop => { prop.rgba(r, g, b, a); });
},
setAlpha: (a: number) => {
safePropertyAccess(prop => { prop.alpha(a); });
},
setOpacity: (o: number) => {
safePropertyAccess(prop => { prop.opacity(o); });
}
}), [])
}
);
return {
value: result.value,
setValue: result.setValue,
setRgb: result.setRgb,
setRgba: result.setRgba,
setAlpha: result.setAlpha,
setOpacity: result.setOpacity
};
}

View File

@@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { ViewModelInstance, ViewModelInstanceEnum } from '@rive-app/canvas';
import { UseViewModelInstanceEnumResult } from '../types';
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
/**
* Hook for interacting with enum properties of a ViewModelInstance.
*
* @param params - Parameters for interacting with enum properties
* @param params.path - Path to the enum property (e.g. "state" or "group/state")
* @param params.viewModelInstance - The ViewModelInstance containing the enum property
* @returns An object with the enum value, available values, and a setter function
*/
export default function useViewModelInstanceEnum(
path: string,
viewModelInstance?: ViewModelInstance | null
): UseViewModelInstanceEnumResult {
const result = useViewModelInstanceProperty<
ViewModelInstanceEnum,
string,
Omit<UseViewModelInstanceEnumResult, 'value' | 'values'>,
string[]
>(path, viewModelInstance, {
getProperty: useCallback((vm, p) => vm.enum(p), []),
getValue: useCallback((prop) => prop.value, []),
defaultValue: null,
getExtendedData: useCallback((prop: any) => prop.values, []),
buildPropertyOperations: useCallback(
(safePropertyAccess) => ({
setValue: (newValue: string) => {
safePropertyAccess((prop) => {
prop.value = newValue;
});
},
}),
[]
),
});
return {
value: result.value,
values: result.extendedData || [],
setValue: result.setValue,
};
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { ViewModelInstance, ViewModelInstanceAssetImage } from '@rive-app/canvas';
import { UseViewModelInstanceImageResult, RiveRenderImage } from '../types';
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
/**
* Hook for interacting with image properties of a ViewModelInstance.
*
* @param path - Path to the image property (e.g. "profileImage" or "group/avatar")
* @param viewModelInstance - The ViewModelInstance containing the image property
* @returns An object with a setter function
*/
export default function useViewModelInstanceImage(
path: string,
viewModelInstance?: ViewModelInstance | null
): UseViewModelInstanceImageResult {
const result = useViewModelInstanceProperty<ViewModelInstanceAssetImage, undefined, UseViewModelInstanceImageResult>(
path,
viewModelInstance,
{
getProperty: useCallback((vm, p) => vm.image(p), []),
getValue: useCallback(() => undefined, []), // Images don't have a readable value
defaultValue: null,
buildPropertyOperations: useCallback((safePropertyAccess) => ({
setValue: (newValue: RiveRenderImage | null) => {
safePropertyAccess(prop => { prop.value = newValue; });
}
}), [])
}
);
return {
setValue: result.setValue
};
}

View File

@@ -0,0 +1,75 @@
import { useCallback, useState } from 'react';
import { ViewModelInstance, ViewModelInstanceList } from '@rive-app/canvas';
import { UseViewModelInstanceListResult } from '../types';
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
/**
* Hook for interacting with list properties of a ViewModelInstance.
*
* @param path - Path to the property (e.g. "items" or "nested/items")
* @param viewModelInstance - The ViewModelInstance containing the list property
* @returns An object with the list length and manipulation functions
*/
export default function useViewModelInstanceList(
path: string,
viewModelInstance?: ViewModelInstance | null
): UseViewModelInstanceListResult {
// We track revision to trigger re-renders on list manipulation (e.g. addInstance, removeInstance, etc).
// This is mostly important for things like the swap function which wouldn't trigger a re-render otherwise because it doesn't change the length of the list.
// For example, if the user swaps two items in the list and we don't trigger a re-render, the user will see the old items if they were using the getInstanceAt function.
// It also accounts for changes that happen within the Rive file itself rather than through the hook.
const [, setRevision] = useState(0);
const result = useViewModelInstanceProperty<ViewModelInstanceList, number, Omit<UseViewModelInstanceListResult, 'length'>>(
path,
viewModelInstance,
{
getProperty: useCallback((vm, p) => vm.list(p), []),
getValue: useCallback((prop) => prop.length, []),
defaultValue: null,
onPropertyEvent: () => {
// This fires when the list changes in Rive
setRevision(prev => prev + 1);
},
buildPropertyOperations: useCallback((safePropertyAccess) => ({
addInstance: (instance: ViewModelInstance) => {
safePropertyAccess(prop => prop.addInstance(instance));
},
addInstanceAt: (instance: ViewModelInstance, index: number): boolean => {
let result = false;
safePropertyAccess(prop => {
result = prop.addInstanceAt(instance, index);
});
return result;
},
removeInstance: (instance: ViewModelInstance) => {
safePropertyAccess(prop => prop.removeInstance(instance));
},
removeInstanceAt: (index: number) => {
safePropertyAccess(prop => prop.removeInstanceAt(index));
},
getInstanceAt: (index: number): ViewModelInstance | null => {
let result: ViewModelInstance | null = null;
safePropertyAccess(prop => {
result = prop.instanceAt(index);
});
return result;
},
swap: (a: number, b: number) => {
safePropertyAccess(prop => prop.swap(a, b));
}
}), [])
}
);
return {
length: result.value ?? 0,
addInstance: result.addInstance,
addInstanceAt: result.addInstanceAt,
removeInstance: result.removeInstance,
removeInstanceAt: result.removeInstanceAt,
getInstanceAt: result.getInstanceAt,
swap: result.swap
};
}

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react';
import { ViewModelInstance, ViewModelInstanceNumber } from '@rive-app/canvas';
import { UseViewModelInstanceNumberResult } from '../types';
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
/**
* Hook for interacting with number properties of a ViewModelInstance.
*
* @param params - Parameters for interacting with number properties
* @param params.path - Path to the number property (e.g. "speed" or "group/speed")
* @param params.viewModelInstance - The ViewModelInstance containing the number property
* @returns An object with the number value and a setter function
*/
export default function useViewModelInstanceNumber(
path: string,
viewModelInstance?: ViewModelInstance | null
): UseViewModelInstanceNumberResult {
const result = useViewModelInstanceProperty<ViewModelInstanceNumber, number, Omit<UseViewModelInstanceNumberResult, 'value'>>(
path,
viewModelInstance,
{
getProperty: useCallback((vm, p) => vm.number(p), []),
getValue: useCallback((prop) => prop.value, []),
defaultValue: null,
buildPropertyOperations: useCallback((safePropertyAccess) => ({
setValue: (newValue: number) => {
safePropertyAccess(prop => { prop.value = newValue; });
}
}), [])
}
);
return {
value: result.value,
setValue: result.setValue
};
}

View File

@@ -0,0 +1,175 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { ViewModelInstance, ViewModelInstanceValue } from '@rive-app/canvas';
/**
* Base hook for all ViewModelInstance property interactions.
*
* This hook handles the common tasks needed when working with Rive properties:
* 1. Safely accessing properties (even during hot-reload)
* 2. Keeping React state in sync with property changes
* 3. Providing type safety for all operations
*
* @param path - Property path in the ViewModelInstance
* @param viewModelInstance - The source ViewModelInstance
* @param options - Configuration for working with the property
* @returns Object with the value and operations
*/
export function useViewModelInstanceProperty<P extends ViewModelInstanceValue, V, R, E = undefined>(
path: string,
viewModelInstance: ViewModelInstance | null | undefined,
options: {
/** Function to get the property from a ViewModelInstance */
getProperty: (vm: ViewModelInstance, path: string) => P | null;
/** Function to get the current value from the property */
getValue: (prop: P) => V;
/** Default value to use when property is unavailable */
defaultValue: V | null;
/**
* Function to create the property-specific operations
*
* @param safePropertyAccess - Helper function for safely working with properties. Handles stale property references.
* @returns Object with operations like setValue, trigger, etc.
*/
buildPropertyOperations: (safePropertyAccess: (callback: (prop: P) => void) => void) => R;
/** Optional callback for property events (mainly used by triggers) */
onPropertyEvent?: () => void;
/**
* Optional function to extract additional property data (like enum values)
* Returns undefined if not provided
*/
getExtendedData?: (prop: P) => E;
}
): R & { value: V | null } & (E extends undefined ? {} : { extendedData: E | null }) {
const [property, setProperty] = useState<P | null>(null);
const [value, setValue] = useState<V | null>(options.defaultValue);
const [extendedData, setExtendedData] = useState<E | null>(null);
const instanceRef = useRef<ViewModelInstance | null | undefined>(null);
const pathRef = useRef<string>(path);
const optionsRef = useRef(options);
useEffect(() => {
optionsRef.current = options;
}, [options]);
const updateProperty = useCallback(() => {
const currentInstance = instanceRef.current;
const currentPath = pathRef.current;
const currentOptions = optionsRef.current;
if (!currentInstance || !currentPath) {
setProperty(null);
setValue(currentOptions.defaultValue);
setExtendedData(null);
return () => { };
}
const prop = currentOptions.getProperty(currentInstance, currentPath);
if (prop) {
setProperty(prop);
setValue(currentOptions.getValue(prop));
if (currentOptions.getExtendedData) {
setExtendedData(currentOptions.getExtendedData(prop));
}
const handleChange = () => {
setValue(currentOptions.getValue(prop));
if (currentOptions.getExtendedData) {
setExtendedData(currentOptions.getExtendedData(prop));
}
if (currentOptions.onPropertyEvent) {
currentOptions.onPropertyEvent();
}
};
prop.on(handleChange);
return () => {
prop.off(handleChange);
};
}
return () => { };
}, []);
useEffect(() => {
instanceRef.current = viewModelInstance;
pathRef.current = path;
// subscribe & get our unsubscribe function
const cleanup = updateProperty();
return cleanup;
}, [viewModelInstance, path, updateProperty]);
/**
* Helper function that safely accesses properties, even during hot-reload.
*
* It tries to:
* 1. Use the existing property reference when possible
* 2. Fetch a fresh reference when needed
* 3. Apply the callback to whichever reference works
*/
const safePropertyAccess = useCallback(
(callback: (prop: P) => void) => {
// Try the fast path first
if (property && instanceRef.current === viewModelInstance) {
try {
callback(property);
// Update extended data after callback if available
if (optionsRef.current.getExtendedData) {
setExtendedData(optionsRef.current.getExtendedData(property));
}
return;
} catch (e) {
// Property might be stale - so we silently catch and try alternative
// This commonly happens during hot module replacement
}
}
// Get a fresh property if needed
if (instanceRef.current) {
try {
const freshProp = optionsRef.current.getProperty(instanceRef.current, pathRef.current);
if (freshProp) {
setProperty(freshProp);
callback(freshProp);
// Update extended data after callback if available
if (optionsRef.current.getExtendedData) {
setExtendedData(optionsRef.current.getExtendedData(freshProp));
}
}
} catch (e) {
// Silently fail during hot-reload - this is expected behavior
// We don't want to crash the app during development
}
}
},
[property, viewModelInstance]
);
const operations = useMemo(
() => optionsRef.current.buildPropertyOperations(safePropertyAccess),
[safePropertyAccess]
);
const result = {
value,
...operations
} as R & { value: V | null } & (E extends undefined ? {} : { extendedData: E | null });
if (options.getExtendedData) {
(result as any).extendedData = extendedData;
}
return result;
}

View File

@@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { ViewModelInstance, ViewModelInstanceString } from '@rive-app/canvas';
import { UseViewModelInstanceStringResult } from '../types';
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
/**
* Hook for interacting with string properties of a ViewModelInstance.
*
* @param params - Parameters for interacting with string properties
* @param params.path - Path to the property (e.g. "text" or "nested/text")
* @param params.viewModelInstance - The ViewModelInstance containing the string property
* @returns An object with the string value and a setter function
*/
export default function useViewModelInstanceString(
path: string,
viewModelInstance?: ViewModelInstance | null
): UseViewModelInstanceStringResult {
const result = useViewModelInstanceProperty<ViewModelInstanceString, string, Omit<UseViewModelInstanceStringResult, 'value'>>(
path,
viewModelInstance,
{
getProperty: useCallback((vm, p) => vm.string(p), []),
getValue: useCallback((prop) => prop.value, []),
defaultValue: null,
buildPropertyOperations: useCallback((safePropertyAccess) => ({
setValue: (newValue: string) => {
safePropertyAccess(prop => { prop.value = newValue; });
}
}), [])
}
);
return {
value: result.value,
setValue: result.setValue
};
}

View File

@@ -0,0 +1,42 @@
import { useCallback } from 'react';
import { ViewModelInstance, ViewModelInstanceTrigger } from '@rive-app/canvas';
import { UseViewModelInstanceTriggerParameters, UseViewModelInstanceTriggerResult } from '../types';
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
/**
* Hook for interacting with trigger properties of a ViewModelInstance.
*
* @param params - Parameters for interacting with trigger properties
* @param params.path - Path to the trigger property (e.g. "onTap" or "group/onTap")
* @param params.viewModelInstance - The ViewModelInstance containing the trigger property
* @param params.onTrigger - Callback that runs when the trigger is fired
* @returns An object with a trigger function
*/
export default function useViewModelInstanceTrigger(
path: string,
viewModelInstance?: ViewModelInstance | null,
params?: UseViewModelInstanceTriggerParameters
): UseViewModelInstanceTriggerResult {
const { onTrigger } = params ?? {};
const { trigger } = useViewModelInstanceProperty<ViewModelInstanceTrigger, undefined, UseViewModelInstanceTriggerResult>(
path,
viewModelInstance,
{
getProperty: useCallback((vm, p) => vm.trigger(p), []),
getValue: useCallback(() => undefined, []), // Triggers don't have a 'value'
defaultValue: null,
onPropertyEvent: onTrigger,
buildPropertyOperations: useCallback((safePropertyAccess) => ({
trigger: () => {
safePropertyAccess(prop => {
prop.trigger();
});
}
}), [])
}
);
return { trigger };
}

View File

@@ -1,10 +1,43 @@
import Rive, { RiveProps } from './components/Rive';
import useRive from './hooks/useRive';
import useStateMachineInput from './hooks/useStateMachineInput';
import useViewModel from './hooks/useViewModel';
import useViewModelInstance from './hooks/useViewModelInstance';
import useViewModelInstanceNumber from './hooks/useViewModelInstanceNumber';
import useViewModelInstanceString from './hooks/useViewModelInstanceString';
import useViewModelInstanceBoolean from './hooks/useViewModelInstanceBoolean';
import useViewModelInstanceColor from './hooks/useViewModelInstanceColor';
import useViewModelInstanceEnum from './hooks/useViewModelInstanceEnum';
import useViewModelInstanceTrigger from './hooks/useViewModelInstanceTrigger';
import useViewModelInstanceImage from './hooks/useViewModelInstanceImage';
import useViewModelInstanceList from './hooks/useViewModelInstanceList';
import useResizeCanvas from './hooks/useResizeCanvas';
import useRiveFile from './hooks/useRiveFile';
import useViewModelInstanceArtboard from './hooks/useViewModelInstanceArtboard';
export default Rive;
export { useRive, useStateMachineInput, useResizeCanvas, useRiveFile , RiveProps };
export { RiveState, UseRiveParameters, UseRiveFileParameters, UseRiveOptions } from './types';
export * from '@rive-app/canvas';
export {
useRive,
useStateMachineInput,
useResizeCanvas,
useRiveFile,
useViewModel,
useViewModelInstance,
useViewModelInstanceNumber,
useViewModelInstanceString,
useViewModelInstanceBoolean,
useViewModelInstanceColor,
useViewModelInstanceEnum,
useViewModelInstanceTrigger,
useViewModelInstanceImage,
useViewModelInstanceList,
useViewModelInstanceArtboard,
RiveProps,
};
export {
RiveState,
UseRiveParameters,
UseRiveFileParameters,
UseRiveOptions,
} from './types';
export * from '@rive-app/canvas';

View File

@@ -1,12 +1,17 @@
import {
type decodeImage,
Rive,
RiveFile,
RiveFileParameters,
RiveParameters,
ViewModelInstance,
ViewModelInstanceArtboard,
} from '@rive-app/canvas';
import { ComponentProps, RefCallback } from 'react';
export type UseRiveParameters = Partial<Omit<RiveParameters, 'canvas'>> | null;
export type UseRiveParameters = Partial<Omit<RiveParameters, 'canvas'>> & {
onRiveReady?: (rive: Rive) => void;
} | null;
export type UseRiveOptions = {
useDevicePixelRatio: boolean;
@@ -57,3 +62,189 @@ export type RiveFileState = {
riveFile: RiveFile | null;
status: FileStatus;
};
/**
* Parameters for useViewModel hook.
*
* @property name - When provided, specifies the name of the ViewModel to retrieve.
* @property useDefault - When true, uses the default ViewModel from the Rive instance.
*/
export type UseViewModelParameters =
| { name: string; useDefault?: never }
| { useDefault?: boolean; name?: never };
/**
* Parameters for useViewModelInstance hook.
*
* @property name - When provided, specifies the name of the instance to retrieve.
* @property useDefault - When true, uses the default instance from the ViewModel.
* @property useNew - When true, creates a new instance of the ViewModel.
* @property rive - If provided, automatically binds the instance to this Rive instance.
*/
export type UseViewModelInstanceParameters =
| { name: string; useDefault?: never; useNew?: never; rive?: Rive | null }
| { useDefault?: boolean; name?: never; useNew?: never; rive?: Rive | null }
| { useNew?: boolean; name?: never; useDefault?: never; rive?: Rive | null };
/**
* Parameters for interacting with trigger properties of a ViewModelInstance
* @property onTrigger - Callback that runs when the trigger fires
*/
export type UseViewModelInstanceTriggerParameters = {
onTrigger?: () => void;
};
export type UseViewModelInstanceNumberResult = {
/**
* The current value of the number.
*/
value: number | null;
/**
* Set the value of the number.
* @param value - The value to set the number to.
*/
setValue: (value: number) => void;
};
export type UseViewModelInstanceStringResult = {
/**
* The current value of the string.
*/
value: string | null;
/**
* Set the value of the string.
* @param value - The value to set the string to.
*/
setValue: (value: string) => void;
};
export type UseViewModelInstanceBooleanResult = {
/**
* The current value of the boolean.
*/
value: boolean | null;
/**
* Set the value of the boolean.
* @param value - The value to set the boolean to.
*/
setValue: (value: boolean) => void;
};
export type UseViewModelInstanceColorResult = {
/**
* The current value of the color.
*/
value: number | null;
/**
* Set the value of the color.
* @param value - The value to set the color to.
*/
setValue: (value: number) => void;
/**
* Set the red value of the color.
* @param r - The red value to set the color to.
*/
setRgb: (r: number, g: number, b: number) => void;
/**
* Set the red, green, blue, and alpha values of the color.
* @param r - The red value to set the color to.
* @param g - The green value to set the color to.
* @param b - The blue value to set the color to.
* @param a - The alpha value to set the color to.
*/
setRgba: (r: number, g: number, b: number, a: number) => void;
/**
* Set the alpha value of the color.
* @param a - The alpha value to set the color to.
*/
setAlpha: (a: number) => void;
/**
* Set the opacity value of the color.
* @param o - The opacity value to set the color to.
*/
setOpacity: (o: number) => void;
};
export type UseViewModelInstanceEnumResult = {
/**
* The current value of the enum.
*/
value: string | null;
/**
* Set the value of the enum.
* @param value - The value to set the enum to.
*/
setValue: (value: string) => void;
/**
* The values of the enum.
*/
values: string[];
};
export type UseViewModelInstanceTriggerResult = {
/**
* Fires the property trigger.
*/
trigger: () => void;
};
export type RiveRenderImage = Awaited<ReturnType<typeof decodeImage>>;
export type UseViewModelInstanceImageResult = {
/**
* Set the value of the image.
* @param value - The image to set.
*/
setValue: (value: RiveRenderImage | null) => void;
};
export type UseViewModelInstanceListResult = {
/**
* The current length of the list.
*/
length: number;
/**
* Add an instance to the end of the list.
* @param instance - The ViewModelInstance to add.
*/
addInstance: (instance: ViewModelInstance) => void;
/**
* Add an instance at a specific index in the list.
* @param instance - The ViewModelInstance to add.
* @param index - The index to add the instance at.
* @returns True if the instance was successfully added, false otherwise.
*/
addInstanceAt: (instance: ViewModelInstance, index: number) => boolean;
/**
* Remove an instance from the list.
* @param instance - The ViewModelInstance to remove.
*/
removeInstance: (instance: ViewModelInstance) => void;
/**
* Remove an instance at a specific index from the list.
* @param index - The index to remove the instance from.
*/
removeInstanceAt: (index: number) => void;
/**
* Get an instance at a specific index from the list.
* @param index - The index to get the instance from.
* @returns The ViewModelInstance at the index, or null if not found.
*/
getInstanceAt: (index: number) => ViewModelInstance | null;
/**
* Swap two instances in the list.
* @param a - The first index.
* @param b - The second index.
*/
swap: (a: number, b: number) => void;
};
export type UseViewModelInstanceArtboardResult = {
/**
* Set the value of the artboard.
* @param value - The artboard to set.
*/
setValue: (value: ViewModelInstanceArtboard extends { value: infer T } ? T : never) => void;
};