Compare commits

..

1 Commits

Author SHA1 Message Date
zplata
19092189a7 chore: release 3.0.9 2022-06-09 18:01:16 +00:00
87 changed files with 1104 additions and 17218 deletions

View File

@@ -1,38 +1,29 @@
module.exports = {
env: {
browser: true,
es2021: true,
es2021: true
},
extends: [
'plugin:react/recommended',
'prettier',
'plugin:storybook/recommended',
],
extends: ['plugin:react/recommended', 'prettier', 'plugin:storybook/recommended'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
jsx: true
},
ecmaVersion: 12,
sourceType: 'module',
sourceType: 'module'
},
plugins: ['@typescript-eslint', 'prettier', 'react-hooks'],
plugins: ['@typescript-eslint', 'prettier'],
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'prefer-const': [
'warn',
{
destructuring: 'all',
},
],
'prefer-const': ['warn', {
destructuring: 'all'
}],
'no-var': 'error',
eqeqeq: ['error', 'smart'],
'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks
'react-hooks/exhaustive-deps': 'off', // Checks effect dependencies
eqeqeq: ['error', 'smart']
},
settings: {
react: {
version: 'detect',
},
},
};
version: 'detect'
}
}
};

View File

@@ -1,26 +0,0 @@
name: Adds all new issues to project board
on:
issues:
types:
- opened
jobs:
add-to-project:
name: Add issue to project
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v0.5.0
with:
project-url: https://github.com/orgs/rive-app/projects/12/views/1
github-token: ${{ secrets.ADD_TO_PROJECT_ACTION }}
- uses: actions/github-script@v6
with:
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["triage"]
})

View File

@@ -1,28 +1,43 @@
name: Publish to NPM
on:
workflow_dispatch:
inputs:
major:
description: 'Major'
type: boolean
default: false
minor:
description: 'Minor'
type: boolean
default: false
pull_request:
types: [closed]
branches:
- main
jobs:
publish_job:
determine_version:
name: Determine the next build version
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
outputs:
version: ${{ steps.echo_version.outputs.version }}
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Git config
- name: Checkout
uses: actions/checkout@v2
- name: Install dependencies
run: npm install
working-directory: ./
- name: Git config
run: |
git config --local user.email 'hello@rive.app'
git config --local user.name ${{ github.actor }}
- name: Authenticate with registry
run: npm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }}
- id: determine_version
name: Get Version
run: npm run release -- --ci --release-version | tail -n 1 > RELEASE_VERSION
working-directory: ./
env:
GITHUB_TOKEN: ${{ secrets.REPO_TOKEN }}
- id: echo_version
run: echo "::set-output name=version::$(cat ./RELEASE_VERSION)"
merge_job:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
needs: [determine_version]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16.x'
@@ -35,24 +50,30 @@ jobs:
run: npm run lint
- name: Run Tests
run: npm test
- if: ${{ inputs.major == true }}
name: Major Release - Bump version number, update changelog, push and tag
run: npm run release:major
- name: Build main dist
run: npm run build
- name: Copy separated builds
run: ./scripts/build.sh
- name: Copy package jsons to separate react outputs
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: ./scripts/setup_all_packages.sh
- name: Bump Versions of react outputs
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
RELEASE_VERSION: ${{ needs.determine_version.outputs.version }}
run: ./scripts/bump_all_versions.sh
- name: Git config
run: |
git config --local user.email 'hello@rive.app'
git config --local user.name ${{ github.actor }}
- name: Authenticate with registry
run: npm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }}
- name: Release rive-react
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
run: npm run release -- --ci
- name: Release @rive-app/react-* variants
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 }}
run: ./scripts/publish_all.sh

View File

@@ -1,12 +1,10 @@
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
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

View File

@@ -1,7 +1,5 @@
{
"git": {
"addUntrackedFiles": true,
"requireCleanWorkingDir": false,
"commitMessage": "chore: release ${version}",
"tagName": "v${version}",
"changelog": "npx auto-changelog --stdout --commit-limit false --unreleased --template https://raw.githubusercontent.com/release-it/release-it/master/templates/changelog-compact.hbs"
@@ -14,12 +12,6 @@
"releaseName": "${version}"
},
"hooks": {
"after:version:bump": [
"npm run build",
"npm run setup-builds",
"npm run setup-packages",
"npx auto-changelog -p",
"npm run publish:all"
]
"after:bump": ["npx auto-changelog -p", "git add ./CHANGELOG.md"]
}
}

13
.storybook/main.js Normal file
View File

@@ -0,0 +1,13 @@
module.exports = {
"stories": [
"../examples/stories/*.stories.mdx",
"../examples/stories/*.stories.@(js|jsx|ts|tsx)"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions"
],
"framework": "@storybook/react",
staticDirs: ['../examples/stories/assets'],
}

View File

@@ -0,0 +1,17 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
data-href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&amp;display=swap"
/>
<link
rel="stylesheet"
data-href="https://fonts.googleapis.com/css2?family=Roboto+Mono&amp;display=swap"
/>
<style>
body {
font-family: 'Roboto', sans-serif;
font-size: 16px;
}
</style>

39
.storybook/preview.js Normal file
View File

@@ -0,0 +1,39 @@
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
options: {
storySort: {
method: 'alphabetical',
order: ['Overview', 'Playback Controls', 'State Machines'],
},
},
viewMode: 'docs',
};
// The below function helps to default to the docs page, which contains all the documentation and examples
function clickDocsButtonOnFirstLoad() {
window.removeEventListener("load", clickDocsButtonOnFirstLoad);
try {
const docsButtonSelector = window.parent.document.evaluate(
"//button[contains(., 'Docs')]",
window.parent.document,
null,
XPathResult.ANY_TYPE,
null
);
const button = docsButtonSelector.iterateNext();
button.click();
} catch (error) {
// Do nothing if it wasn't able to click on Docs button.
}
}
window.addEventListener("load", clickDocsButtonOnFirstLoad);

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,78 +0,0 @@
# Contributing
We love contributions! If you want to run the project locally to test out changes, run the examples, or just see how things work under the hood, read on below.
## Local development
This runtime consumes specific tied-down versions of the [JS/WASM runtime](https://github.com/rive-app/rive-wasm) to have better control over changes that occur in that downstream runtime.
### Installation
1. Clone the project down
2. Run `npm i` in the shell/terminal at the base of the project to install the dependencies needed for the project
### Local dev server
To start the local dev server to reflect any changes made to the core `src/` files, run the following in a terminal tab:
```
npm run dev
```
### Running the example storybook locally
We use Storybook to deploy our examples out onto a public-facing page for folks to view and see code examples for. It also serves as the place we'll include any example suites. These story files are stored in `/examples`
To run Storybook, run the following command in the terminal:
```
npm run storybook
```
To see changes made to the Rive React runtime reflected in your storyook, run the following command in a separate terminal window:
```
npm run dev
```
### Testing
We also have a suite of unit tests against the high-level component and various hooks exported in the `test/` folder. When adding new components, changing the API, or underlying functionality, make sure to add a test here!
To run the test suite:
```
npm test
```
## Making changes
When you're ready to make changes, push up to a feature branch off of the `main` branch. Create a pull request to this repository in Github. When creating commit messages, please be as descriptive as possible to the changes being made.
For example, if the change is simply a bug fix or patch change:
```
git commit -m "Fix: Fixing a return type from useRive"
```
Or if it's simply a docs change:
```
git commit -m "Docs: Adding a new link for another example page"
```
For minor/major version releases, also ensure you preface commit messages with:
```
git commit -m "Major: Restructuring the useRive API with new parameters"
```
These messages help make the changelog clear as to what changes are made for future devs to see.
When pull requests are merged, the runtime will automatically deploy the next release version. By default, patch versions are published. If you want to set the next version as a minor/major version to be released, you have to manually update the `package.json` file at the root of the project to the verison you want it to.
You can find the deploy scripts in `.github/`
## Bumping the underlying JS/WASM runtime
Many times, fixes to the runtime and feature adds come from the underlying JS/WASM runtime. In these cases, just bump the `@rive-app/canvas` and `@rive-app/webgl` versions to the verison you need to incorporate the fix/feature. Run `npm i` and test out the change locally against the Storybook examples and run the test suite to make sure nothing breaks, and then submit a PR with just the `package.json` change if that's all that's needed.

217
README.md
View File

@@ -1,79 +1,208 @@
![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)
# Rive React
![Rive hero image](https://cdn.rive.app/rive_logo_dark_bg.png)
React Runtime for [Rive](https://rive.app).
A React runtime library for [Rive](https://rive.app).
A wrapper around [Rive.js](https://github.com/rive-app/rive-wasm), providing full control over the js runtime while making it super simple to use in React applications.
This library is a wrapper around the [JS/Wasm runtime](https://github.com/rive-app/rive-wasm), giving full control over the js runtime while providing components and hooks for React applications.
Detailed runtime documentation can be found in [Rive's help center](https://help.rive.app/runtimes).
## Table of contents
## Create and ship interactive animations to any platform
- :star: [Rive Overview](#rive-overview)
- 🚀 [Getting Started & API docs](#getting-started)
- :mag: [Supported Versions](#supported-versions)
- :books: [Examples](#examples)
- :runner: [Migration Guides](#migration-guides)
- 👨‍💻 [Contributing](#contributing)
- :question: [Issues](#issues)
[Rive](https://rive.app) is a real-time interactive design and animation tool. Use our collaborative editor to create motion graphics that respond to different states and user inputs. Then load your animations into apps, games, and websites with our lightweight open-source runtimes.
## Rive overview
## Installation
[Rive](https://rive.app) is a real-time interactive design and animation tool that helps teams create and run interactive animations anywhere. Designers and developers use our collaborative editor to create motion graphics that respond to different states and user inputs. Our lightweight open-source runtime libraries allow them to load their animations into apps, games, and websites.
```
npm i --save rive-react
```
:house_with_garden: [Homepage](https://rive.app/)
_Note: This library is using React hooks so the minimum version required for both react and react-dom is 16.8.0._
:blue_book: [General help docs](https://rive.app/community/doc/)
## Usage
🛠 [Rive Forums](https://rive.app/community/forums/home)
### Component
## Getting started
Rive React provides a basic component as it's default import for displaying simple animations. Note that any animations or state machines instantiated through this component will autoplay.
Follow along with the link below for a quick start in getting Rive React integrated into your React apps.
```js
import Rive from 'rive-react';
- [Getting Started with Rive in React](https://rive.app/community/doc/react/docRfaSQ0eaE)
- [API documentation](https://rive.app/community/doc/parameters-and-return-values/docJlDMNulDh)
function Example() {
return <Rive src="loader.riv" />;
}
For more information, see the Runtime sections of the Rive help documentation:
export default Example;
```
- [Animation Playback](https://rive.app/community/doc/animation-playback/docDKKxsr7ko)
- [Layout](https://rive.app/community/doc/layout/docBl81zd1GB)
- [State Machines](https://rive.app/community/doc/state-machines/docxeznG7iiK)
- [Rive Text](https://rive.app/community/doc/text/docn2E6y1lXo)
- [Rive Events](https://rive.app/community/doc/rive-events/docbOnaeffgr)
- [Loading Assets](https://rive.app/community/doc/loading-assets/doct4wVHGPgC)
#### Props
## Supported versions
- `src`: File path or URL to the .riv file to display.
- `artboard`: _(optional)_ Name to display.
- `animations`: _(optional)_ Name or list of names of animtions to play.
- `stateMachines`: _(optional)_ Name of state machine to play.
- `layout`: _(optional)_ Layout object to define how animations are displayed on the canvas. See [Rive.js](https://github.com/rive-app/rive-wasm#layout) for more details.
- _All attributes and eventHandlers that can be passed to a `canvas` element can also be passed to the `Rive` component and used in the same manner._
This library supports React versions `^16.8.0` through `^18.0.0`.
#### Styles and Classes
When rendering out a Rive component, in the DOM, it will show as a `<div>` element that contains the `<canvas>` element that powers the Rive animations. The purpose of the `<div>` element is to help control the sizing of the component. By default, the container has the following styles set on the `style` attribute:
```css
width: 100%;
height: 100%;
```
If you decide to pass in a `className` to the Rive component, you will override these attributes, and you will need to either set these style attributes in your CSS associated with that `className`, or set your own sizing preferences.
### useRive Hook
For more advanced usage, the `useRive` hook is provided. The hook will return a component and a [Rive.js](https://github.com/rive-app/rive-wasm) `Rive` object which gives you control of the current rive file.
```js
import { useRive } from 'rive-react';
function Example() {
const params = {
src: 'loader.riv',
autoplay: false,
};
const { RiveComponent, rive } = useRive(params);
return (
<RiveComponent
onMouseEnter={() => rive && rive.play()}
onMouseLeave={() => rive && rive.pause()}
/>
);
}
export default Example;
```
#### Parameters
- `riveParams`: Set of parameters that are passed to the Rive.js `Rive` class constructor. `null` and `undefined` can be passed to conditionally display the .riv file.
- `opts`: Rive React specific options.
#### Return Values
- `RiveComponent`: A Component that can be used to display your .riv file. This component accepts the same attributes and event handlers as a `canvas` element.
- `rive`: A Rive.js `Rive` object. This will return as null until the .riv file has fully loaded.
- `canvas`: HTMLCanvasElement object, on which the .riv file is rendering.
- `setCanvasRef`: A callback ref that can be passed to your own canvas element, if you wish to have control over the rendering of the Canvas element.
- `setContainerRef`: A callback ref that can be passed to a container element that wraps the canvas element, if you which to have control over the rendering of the container element.
_For the vast majority of use cases, you can just the returned `RiveComponent` and don't need to worry about `setCanvasRef` and `setContainerRef`._
#### riveParams
- `src?`: _(optional)_ File path or URL to the .riv file to use. One of `src` or `buffer` must be provided.
- `buffer?`: _(optional)_ ArrayBuffer containing the raw bytes from a .riv file. One of `src` or `buffer` must be provided.
- `artboard?`: _(optional)_ Name of the artboard to use.
- `animations?`: _(optional)_ Name or list of names of animations to play.
- `stateMachines?`: _(optional)_ Name of list of names of state machines to load.
- `layout?`: _(optional)_ Layout object to define how animations are displayed on the canvas. See [Rive.js](https://github.com/rive-app/rive-wasm#layout) for more details.
- `autoplay?`: _(optional)_ If `true`, the animation will automatically start playing when loaded. Defaults to false.
- `onLoad?`: _(optional)_ Callback that get's fired when the .rive file loads .
- `onLoadError?`: _(optional)_ Callback that get's fired when an error occurs loading the .riv file.
- `onPlay?`: _(optional)_ Callback that get's fired when the animation starts playing.
- `onPause?`: _(optional)_ Callback that get's fired when the animation pauses.
- `onStop?`: _(optional)_ Callback that get's fired when the animation stops playing.
- `onLoop?`: _(optional)_ Callback that get's fired when the animation completes a loop.
- `onStateChange?`: _(optional)_ Callback that get's fired when a state change occurs.
#### opts
- `useDevicePixelRatio`: _(optional)_ If `true`, the hook will scale the resolution of the animation based the [devicePixelRatio](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio). Defaults to `true`. NOTE: Requires the `setContainerRef` ref callback to be passed to a element wrapping a canvas element. If you use the `RiveComponent`, then this will happen automatically.
- `fitCanvasToArtboardHeight`: _(optional)_ If `true`, then the canvas will resize based on the height of the artboard. Defaults to `false`.
- `useOffscreenRenderer`: _(optional)_ If `true`, the Rive instance will share (or create if one does not exist) an offscreen `WebGL` context. This allows you to display multiple Rive animations on one screen to work around some browser limitations regarding multiple concurrent WebGL contexts. If `false`, each Rive instance will have its own dedicated `WebGL` context, and you may need to be cautious of the browser limitations just mentioned. Defaults to `true`.
### useStateMachineInput Hook
The `useStateMachineInput` hook is provided to make it easier to interact with state machine inputs on a rive file.
```js
import { useRive, useStateMachineInput } from 'rive-react';
function Example() {
const STATE_MACHINE_NAME = 'button';
const INPUT_NAME = 'onClick';
const { RiveComponent, rive } = useRive({
src: 'button.riv',
stateMachines: STATE_MACHINE_NAME,
autoplay: true,
});
const onClickInput = useStateMachineInput(
rive,
STATE_MACHINE_NAME,
INPUT_NAME
);
// This example is using a state machine with a trigger input.
return <RiveComponent onClick={() => onClickInput.fire()} />;
}
export default Example;
```
See our [examples](examples) folder for working examples of [Boolean](examples/state-machine-boolean-input) and [Number](examples/state-machine-number-input) inputs.
#### Parameters
- `rive`: A `Rive` object. This is returned by the `useRive` hook.
- `stateMachineName`: Name of the state machine.
- `inputName`: Name of the state machine input.
- `initialValue`: Initial value to set on a state machine input when it's loaded in, for number or boolean inputs. **Note** that this may trigger any transitional animations between the initial state and any next states that depend on the input this `initialValue` is being set to. If this is problematic or conflicting for your case, we recommend setting the true initial value of the input on your state machine in the Rive editor.
#### Return Value
A Rive.js `stateMachineInput` object.
## Examples
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.
This project uses [Storybook](https://storybook.js.org/) to build examples and API documentation. Check it out at:
https://rive-app.github.io/rive-react. To run locally, simply run `npm run storybook`.
- [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)
## Migration notes
### Awesome Rive
### Migrating from version 0.0.x to 1.x.x
For even more examples and resources on using Rive at runtime or in other tools, checkout the [awesome-rive](https://github.com/rive-app/awesome-rive) repo.
Starting in v 1.0.0, we've migrated from wrapping around the `@rive-app/canvas` runtime (which uses the `CanvasRendereringContext2D` renderer) to the `@rive-app/webgl` runtime (which uses the WebGL renderer). The high-level API doesn't require any change to upgrade, but there are some notes to consider about the backing renderer.
## Migration guides
The backing `WebGL` runtime allows for best performance across all devices, as well as support for some features that are not supported in the `canvas` renderer runtime. To allow the `react` runtime to support some of the newer features in Rive, we needed to switch the `rive-react` backing runtime to `@rive-app/webgl`.
Using an older version of the runtime and need to learn how to upgrade to the latest version? Check out the migration guides below in our help center that help guide you through version bumps; breaking changes and all!
One note about this switch is that some browsers may limit the number of concurrent WebGL contexts. For example, Chrome may only support up to 16 contexts concurrently. We pass a property called `useOffscreenRenderer` set to true to the backing runtime when instantiating Rive by default, which helps to manage the lifecycle of the `canvas` with a single offscreen `WebGL` context, even if there are many Rive animations on the screen (i.e 16+). If you need a single `WebGL` context per Rive animation/instance, pass in the `useOffscreenRenderer` property set to `false` in the `useRive` options, or as a prop in the default export component from this runtime. See below for an example:
[Migration guides](https://rive.app/community/doc/migrating-from-v3-to-v4/dociIPXVHKFF)
```js
const {rive, RiveComponent} = useRive({
src: 'foo.riv',
}, {
// Default (you don't need to set this)
useOffscreenRenderer: true,
// To override and use one context per Rive instance, uncomment and use the line below
// useOffscreenRenderer: false,
});
## Contributing
// or you can override the flag in JSX via props
return (
<Rive src="foo.riv" useOffscreenRenderer={false} />
);
```
We love contributions! Check out our [contributing docs](./CONTRIBUTING.md) to get more details into how to run this project, the examples, and more all locally.
### Migrating from version 1.x.x to 2.x.x
## Issues
#### Package split
Have an issue with using the runtime, or want to suggest a feature/API to help make your development life better? Log an issue in our [issues](https://github.com/rive-app/rive-react/issues) tab! You can also browse older issues and discussion threads there to see solutions that may have worked for common problems.
In most cases, you may be able to migrate safely. We are mainly enabling the React runtime to work with both backing renderers `@rive-app/webgl` and `@rive-app/canvas`, such that you can use either `@rive-app/react-canvas` or `@rive-app/react-webgl` as the dependency in your React applications. Another change that is mostly internal is that by default, `rive-react` will now use `@rive-app/canvas` (as opposed to `@rive-app/webgl`) to wrap around, as it currently yields the fastest performance across devices. Therefore, **we recommend installing `@rive-app/react-canvas` in your applicaions**. However, if you need a WebGL backing renderer, you may want to use `@rive-app/react-webgl`.
#### Classes, styles, and component props
Starting in v2.0, we introduce one breaking change where any non-style props set on the `RiveComponent` (i.e `aria-*`, `role`, etc.) will be set on the inner `<canvas>` element. Previously, all extra props would be set onto the containing `<div>` element. Both the `className` and `style` props will continue to be set on the `<div>` element that wraps the canvas, as this dictates the sizing of the Rive component.
### Migrating to 3.0
There are no breaking changes here. If you have migrated to v2.x.x, you can safely migrate to 3.0.

View File

25
examples/.gitignore vendored
View File

@@ -1,25 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*storybook.log

View File

@@ -1,74 +0,0 @@
import type { StorybookConfig } from '@storybook/react-webpack5';
import path from 'path';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/preset-create-react-app',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/react-webpack5',
options: {},
},
staticDirs: ['../public'],
webpackFinal: async (config) => {
if (!config.resolve) config.resolve = {};
if (!config.resolve.alias) config.resolve.alias = {};
config.resolve.alias['react'] = path.resolve(
__dirname,
'../../node_modules/react'
);
config.resolve.alias['react-dom'] = path.resolve(
__dirname,
'../../node_modules/react-dom'
);
config.resolve.alias['@rive-app/react-canvas'] = path.resolve(
__dirname,
'../../'
);
config.resolve.alias['@rive-app/react-canvas-lite'] = path.resolve(
__dirname,
'../../'
);
config.resolve.alias['@rive-app/react-webgl'] = path.resolve(
__dirname,
'../../'
);
config.resolve.alias['@rive-app/react-webgl2'] = path.resolve(
__dirname,
'../../'
);
config.module?.rules?.push({
test: /\.(ts|tsx|js|jsx)$/,
include: [
path.resolve(__dirname, '../src'),
path.resolve(__dirname, '../../'),
],
use: {
loader: require.resolve('babel-loader'),
options: {
presets: [
require.resolve('@babel/preset-env'),
require.resolve('@babel/preset-react'),
require.resolve('@babel/preset-typescript'),
],
},
},
});
config.watchOptions = {
ignored: /node_modules/,
poll: 1000,
aggregateTimeout: 300,
};
return config;
},
};
export default config;

View File

@@ -1,16 +0,0 @@
import type { Preview } from '@storybook/react';
import './stories.css';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

View File

@@ -1,7 +0,0 @@
html, body {
height: 100%;
}
#storybook-root {
height: 100%;
}

View File

@@ -1,56 +0,0 @@
{
"name": "examples",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test-storybook": "test-storybook"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest",
"plugin:storybook/recommended"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@storybook/addon-essentials": "^8.6.12",
"@storybook/addon-interactions": "^8.6.12",
"@storybook/addon-onboarding": "^8.6.12",
"@storybook/blocks": "^8.6.12",
"@storybook/preset-create-react-app": "^8.6.12",
"@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.

View File

Binary file not shown.

View File

@@ -1,17 +0,0 @@
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

@@ -1,147 +0,0 @@
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

@@ -1,348 +0,0 @@
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 } 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);
}
};

View File

@@ -1,524 +0,0 @@
import React, { useEffect, useState } from 'react';
import Rive, {
useRive,
useViewModel,
useViewModelInstance,
useViewModelInstanceBoolean,
useViewModelInstanceString,
useViewModelInstanceNumber,
useViewModelInstanceEnum,
useViewModelInstanceColor,
useViewModelInstanceTrigger
} 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>
);
};

View File

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

View File

@@ -1,34 +0,0 @@
import React, { useEffect } from 'react';
import { useRive, EventType, RiveEventType } from '@rive-app/react-canvas';
const Events = () => {
const { rive, RiveComponent } = useRive({
src: 'rating.riv',
stateMachines: 'State Machine 1',
autoplay: true,
automaticallyHandleEvents: true,
});
const onRiveEventReceived = (riveEvent: any) => {
console.log('Rive event received:', riveEvent);
const eventData = riveEvent.data;
const eventProperties = eventData.properties;
if (eventData.type === RiveEventType.General) {
console.log('Event name', eventData.name);
console.log('Rating', eventProperties.rating);
console.log('Message', eventProperties.message);
}
};
// Wait until the rive object is instantiated before adding the Rive
// event listener
useEffect(() => {
if (rive) {
rive.on(EventType.RiveEvent, onRiveEventReceived);
}
}, [rive]);
return <RiveComponent />;
};
export default Events;

View File

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

View File

@@ -1,14 +0,0 @@
import React from 'react';
import { useRive } from '@rive-app/react-canvas';
const Http = () => {
const { RiveComponent } = useRive({
src: 'https://cdn.rive.app/animations/vehicles.riv',
stateMachines: 'bumpy',
autoplay: true,
});
return <RiveComponent />;
};
export default Http;

View File

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

View File

@@ -1,18 +0,0 @@
import React from 'react';
import { Fit, useRive, Layout } from '@rive-app/react-canvas';
const ResponsiveLayout = () => {
const { RiveComponent } = useRive({
src: 'layout_test.riv',
artboard: 'Artboard',
stateMachines: 'State Machine 1',
autoplay: true,
layout: new Layout({
fit: Fit.Layout,
}),
});
return <RiveComponent />;
};
export default ResponsiveLayout;

View File

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

View File

@@ -1,14 +0,0 @@
import React from 'react';
import { useRive } from '@rive-app/react-canvas';
const Simple = () => {
const { RiveComponent } = useRive({
src: 'avatars.riv',
artboard: 'Avatar 3',
autoplay: true,
});
return <RiveComponent />;
};
export default Simple;

View File

@@ -0,0 +1,99 @@
import { useState } from 'react';
import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs';
import {useRive, useStateMachineInput} from '../../src';
import {Button} from './components/Button';
import './rive-overview.css';
<Meta title="React Runtime/Playback Controls" />
# Animation Playback
When rendering Rives, you may want to control animation playback for certain scenarios. Animation playback allows you to programatically pause, stop, play, reset, and scrub animations as needed. You may find this useful for coordinating certain user interaction or other programatic cases to Rive animations.
**Note:** Just like the Rive web runtime, you invoke playback methods on a `rive` instance. Because of this, you will need to use the `useRive` method to render Rives to your React applications, as it returns a `rive` instance for you to invoke controls on.
## User event-driven playback
You can control Rive animation playback with user interaction directly on the canvas, or even outside the canvas, as you'll see below.
### Play/pause with hover
The example below shows how to start with a Rive instance that does not autoplay initially, but plays whenever the cursor is hovered over the canvas, and returns to a paused state when the mouse leaves the canvas.
<Canvas withSource="open">
<Story name="Play/pause with hover">
{() => {
const { rive, RiveComponent } = useRive({
src: 'poison-loader.riv',
autoplay: false,
});
function onMouseEnter() {
// rive will return as null until the file as fully loaded, so we include this
// guard to prevent any unwanted errors.
if (rive) {
rive.play();
}
}
function onMouseLeave() {
if (rive) {
rive.pause();
}
}
return (
<div className="center">
<RiveComponent
className="base-canvas-size"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
/>
</div>
);
}}
</Story>
</Canvas>
## Play/pause with external elements
This example shows how you can control Rive elements via user interaction outside of the canvas, such as other buttons. Here, the play/pause button will toggle whether or not to play or pause the Rive animation.
<Canvas withSource="open">
<Story name="Play/pause with external elements">
{() => {
const [isPlaying, setIsPlaying] = useState(true);
const { rive, RiveComponent } = useRive({
src: 'truck.riv',
stateMachines: "drive",
artboard: 'Truck',
autoplay: true,
});
const togglePlaying = () => {
if (isPlaying) {
rive.pause();
setIsPlaying(false);
} else {
rive.play();
setIsPlaying(true);
}
};
return ((
<>
<div className="center">
<RiveComponent className="base-canvas-size" />
<Button onClick={togglePlaying}>{isPlaying ? 'Pause' : 'Play'}</Button>
</div>
</>
));
}}
</Story>
</Canvas>
## Additional ways to control playback
While user interaction is a common way to control animation playback for Rives, there are other ways to achieve the same means as well.
### API-driven playback
Another common way to control animation playback is through API responses. Imagine an API that you poll for loading progress of a task. Based on that progress response, you may play or stop a given animation. As long as you hold a reference to the `rive` instance returned from the `useRive` hook, you can invoke control methods on that instance in places such as callbacks from API responses.

View File

@@ -0,0 +1,112 @@
<!-- RiveTestHook.stories.mdx -->
import { useState } from 'react';
import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs';
import RiveComponent, {useRive, useStateMachineInput} from '../../src';
import {Button} from './components/Button';
import './rive-overview.css';
<Meta title="React Runtime/Overview" />
# Rive React Runtime
This is an examples/docs page for the official React runtime for Rive. Check out the various pages for how to use this SDK to incorporate Rive into your React-based web applications.
Want to follow along with these examples? Check out the `examples/` folder in the [rive-react](https://github.com/rive-app/rive-react) project to find all these examples and `.riv` assets you can inspect in the Rive editor by dragging and dropping them into your Rive editor file browser.
## What is Rive?
<img className="rive-logo" src="rive_logo_black_2x.png" alt="Rive logo" />
[Rive](https://rive.app/) is a real-time interactive design and animation tool. Use our collaborative editor to create motion graphics that respond to different states and user inputs. Then load your animations into apps, games, and websites with our lightweight open-source runtimes.
## How to use Rive at runtime
There's multiple ways to render Rive using the React runtime. See the associated code snippets that follow each example.
### Rive component
```tsx
import RiveComponent from '@rive-app/react-canvas';
```
The React runtime exports a default React component you can insert as JSX. Under the hood, it renders a `<canvas>` element that runs the animation, and a wrapping `<div>` element that handles sizing of the canvas based on the parent that wraps the component.
**When to use this**: Use this for simple rendering cases where you don't need to control playback or setup state machine inputs to advance state machines. It will simply autoplay the first animation it finds in the `.riv`, the animation name you provide it, or the state machine name if you provide one.
**Note:** Style-specific props set onto the component will pass down to the wrapping `<div>` element, while most other props will be set onto the `<canvas>` element itself.
<Canvas withSource="open">
<Story name="Rive Component">
{() => (
<div className="center">
<RiveComponent src="poison-loader.riv" className="base-canvas-size" />
</div>
)}
</Story>
</Canvas>
#### Props
In addition to the props laid out below, the component accepts other props that can be set on the `<canvas>` element.
<ArgsTable of={RiveComponent} />
### useRive Hook
```tsx
import {useRive} from '@rive-app/react-canvas';
```
The runtime also exports a named `useRive` hook that allows for more control at Rive instantiation, since it passes back a `rive` object you can use to manipulate state machines, control playback, and more.
**When to use this:** When you need to control your Rive animation in any aspect, such as controlling playback, using state machine inputs to advance state machines, add adding callbacks on certain Rive-specific events such as `onStateChange`, `onPause`, etc.
<Canvas withSource="open">
<Story name="useRive Hook">
{() => {
const [isPlaying, setIsPlaying] = useState(true);
const [animationText, setAnimationText] = useState('');
const { rive, RiveComponent: RiveComponentPlayback } = useRive({
src: 'truck.riv',
stateMachines: "drive",
artboard: 'Truck',
autoplay: true,
onPause: () => {
setAnimationText('Animation paused!');
},
onPlay: () => {
setAnimationText('Animation is playing..');
},
});
const togglePlaying = () => {
if (isPlaying) {
rive.pause();
setIsPlaying(false);
} else {
rive.play();
setIsPlaying(true);
}
};
return ((
<>
<div className="center">
<RiveComponentPlayback className="base-canvas-size" />
<p>{animationText}</p>
<Button onClick={togglePlaying}>{isPlaying ? 'Pause' : 'Play'}</Button>
</div>
</>
));
}}
</Story>
</Canvas>
#### useRive parameters
```tsx
useRive(params: UseRiveParameters, opts?: UseRiveOptions);
```
The parameters available to set on `useRive` can be found [here](https://github.com/rive-app/rive-wasm/blob/master/js/src/rive.ts#L843). These pass down to the web runtime `rive` instance created.
Additionally, there are other options to set on `useRive` that can be found [here](https://github.com/rive-app/rive-react/blob/main/src/types.ts#L6).

View File

@@ -0,0 +1,157 @@
<!-- RiveTestHook.stories.mdx -->
import { useState } from 'react';
import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs';
import RiveComponent, {useRive, useStateMachineInput} from '../../src';
import {Button} from './components/Button';
import './rive-overview.css';
<Meta title="React Runtime/State Machines" />
# State Machine Usage
Not familiar with Rive State Machines? Check out our [help docs](https://help.rive.app/editor/state-machine) on what these are first!
The `useStateMachineInput` hook is a helper that makes grabbing references to state machine inputs easier to setup. This hook should be used along with the `useRive` hook, as you need to pass in the `rive` instance returned from `useRive`. See each of the examples below to see usage of the hook creating instance of three types of inputs:
- Booleans
- Numbers
- Triggers
## Boolean inputs
Once you grab a reference to the state machine input, you can get/set the value of the input via the `.value` property.
**Note:** The input instance value itself is not a stateful React variable, therefore, any logic in the component dependent on an input value changing will not trigger a re-render like a React stateful variable change would. You can achieve this effect by keeping reference to the state machine input value inside a React state variable. See the example below for this pattern.
<Canvas withSource="open">
<Story name="Boolean input">
{() => {
const STATE_MACHINE_NAME = 'State Machine 1';
const ON_HOVER_INPUT_NAME = 'Hover';
const ON_PRESSED_INPUT_NAME = 'Pressed';
const { rive, RiveComponent: RiveComponentTouch } = useRive({
src: 'like.riv',
stateMachines: STATE_MACHINE_NAME,
artboard: 'New Artboard',
autoplay: true,
});
const [isHovered, setIsHovered] = useState(false);
// Both onHoverInput and onPressedInput are boolean inputs. To transition
// states we need to set the value property to true or false.
const onPressedInput = useStateMachineInput(
rive,
STATE_MACHINE_NAME,
ON_PRESSED_INPUT_NAME
);
const onHoverInput = useStateMachineInput(
rive,
STATE_MACHINE_NAME,
ON_HOVER_INPUT_NAME
);
function onMouseDown() {
onPressedInput.value = true;
}
function onMouseUp() {
onPressedInput.value = false;
}
function onMouseEnter() {
onHoverInput.value = true;
setIsHovered(true);
}
function onMouseLeave() {
onHoverInput.value = false;
setIsHovered(false);
}
return (
<>
<div className="center">
<RiveComponentTouch
className="base-canvas-size"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
/>
<p>Hover and click on the canvas</p>
<p>Is cursor hovering? {isHovered ? 'Yes' : 'No'}</p>
</div>
</>
);
}}
</Story>
</Canvas>
## Number inputs
Once you grab a reference to the state machine input, you can get/set the value of the input via the `.value` property.
<Canvas withSource="open">
<Story name="Number input">
{() => {
const STATE_MACHINE_NAME = 'State Machine ';
const INPUT_NAME = 'Level';
const { rive, RiveComponent: RiveComponentTouch } = useRive({
src: 'skills.riv',
stateMachines: STATE_MACHINE_NAME,
artboard: 'New Artboard',
autoplay: true,
});
// levelInput is a number state machine input. To transition the state machine,
// we need to set the value to a number. For this state machine input, we need
// to set to 0, 1 or 2 for a state transition to occur.
const levelInput = useStateMachineInput(rive, STATE_MACHINE_NAME, INPUT_NAME);
return (
// The animation will fit to the parent element, so we set a large height
// and width for this example.
<div className="center">
<RiveComponentTouch className="large-canvas-size" />
<div className="btn-group">
Choose a level:
<Button onClick={() => (levelInput.value = 0)}>0</Button>
<Button onClick={() => (levelInput.value = 1)}>1</Button>
<Button onClick={() => (levelInput.value = 2)}>2</Button>
</div>
</div>
);
}}
</Story>
</Canvas>
## Trigger inputs
Unlike the boolean and number inputs, you invoke the `.fire()` method on a trigger input.
<Canvas withSource="open">
<Story name="Trigger input">
{() => {
const STATE_MACHINE_NAME = 'State Machine 1';
const INPUT_NAME = 'Pressed';
const { rive, RiveComponent: RiveComponentTouch } = useRive({
src: 'piggy-bank.riv',
stateMachines: STATE_MACHINE_NAME,
artboard: 'New Artboard',
autoplay: true,
});
// pressedInput in a trigger state machine input. To transition the state
// we need to call the `fire()` method on the input.
const pressedInput = useStateMachineInput(
rive,
STATE_MACHINE_NAME,
INPUT_NAME
);
// The animation will fit to the parent element, so we set a large height
// and width for this example.
return (
<div className="center">
<RiveComponentTouch
className="base-canvas-size"
onClick={() => pressedInput.fire()}
/>
<p>Click on the canvas</p>
</div>
);
}}
</Story>
</Canvas>

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -0,0 +1,7 @@
import React from 'react';
export const Button = ({onClick, children}) => {
return (
<button className="btn" onClick={onClick}>{children}</button>
);
};

View File

@@ -0,0 +1,36 @@
.center {
display: flex;
align-content: center;
flex-direction: column;
flex-wrap: wrap;
}
.base-canvas-size {
height: 300px;
width: 300px;
}
.large-canvas-size {
height: 600px;
width: 600px;
}
.btn {
text-decoration: none;
border: none;
background: #0069ed;
border-radius: 2px;
height: 32px;
margin-top: 16px;
color: #ffffff;
cursor: pointer;
}
.btn-group .btn {
margin-left: 8px;
}
.rive-logo {
display: flex;
margin: 16px auto;
}

View File

@@ -1,32 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"paths": {
"@rive-app/react-canvas": ["../"],
"@rive-app/react-webgl": ["../"],
"@rive-app/react-webgl2": ["../"],
"@rive-app/react-canvas-lite": ["../"]
},
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
# @rive-app/react-canvas-lite
Output for `rive-react` using the backing `@rive-app/canvas-lite` JS runtime.
## Why Lite?
The current `@rive-app/react-canvas` dependency supports all Rive features and contains the necessary backing dependencies to render those graphics. This `lite` version has the same API, but does not compile and build with certain dependencies in order to keep the package size as small as possible.
At this time, this lite version of `@rive-app/react-canvas-lite` will not render Rive Text onto the canvas or play Rive Audio. Note however, that even if your Rive file may include Rive Text components, rendering the graphic should not cause any app errors, or cease to render. The same is true for playing audio.

View File

@@ -1,26 +0,0 @@
{
"name": "@rive-app/react-canvas-lite",
"version": "4.19.0",
"description": "React wrapper around the @rive-app/canvas-lite library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
"files": [
"dist/**"
],
"repository": {
"type": "git",
"url": "git+https://github.com/rive-app/rive-react.git"
},
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/rive-app/rive-react/issues"
},
"homepage": "https://github.com/rive-app/rive-react#readme",
"dependencies": {
"@rive-app/canvas-lite": "2.27.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0"
}
}

View File

@@ -1,26 +0,0 @@
{
"name": "@rive-app/react-canvas",
"version": "4.19.0",
"description": "React wrapper around the @rive-app/canvas library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
"files": [
"dist/**"
],
"repository": {
"type": "git",
"url": "git+https://github.com/rive-app/rive-react.git"
},
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/rive-app/rive-react/issues"
},
"homepage": "https://github.com/rive-app/rive-react#readme",
"dependencies": {
"@rive-app/canvas": "2.27.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0"
}
}

View File

@@ -1,26 +0,0 @@
{
"name": "@rive-app/react-webgl",
"version": "4.19.0",
"description": "React wrapper around the @rive-app/webgl library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
"files": [
"dist/**"
],
"repository": {
"type": "git",
"url": "git+https://github.com/rive-app/rive-react.git"
},
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/rive-app/rive-react/issues"
},
"homepage": "https://github.com/rive-app/rive-react#readme",
"dependencies": {
"@rive-app/webgl": "2.27.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0"
}
}

View File

@@ -1,26 +0,0 @@
{
"name": "@rive-app/react-webgl2",
"version": "4.19.0",
"description": "React wrapper around the @rive-app/webgl2 library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
"files": [
"dist/**"
],
"repository": {
"type": "git",
"url": "git+https://github.com/rive-app/rive-react.git"
},
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/rive-app/rive-react/issues"
},
"homepage": "https://github.com/rive-app/rive-react#readme",
"dependencies": {
"@rive-app/webgl2": "2.27.1"
},
"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": "3.0.9",
"description": "React wrapper around the rive-js library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
@@ -15,14 +15,8 @@
"format": "prettier --write src",
"types:check": "tsc --noEmit",
"release": "release-it",
"release:patch": "npm run release -- --ci",
"release:minor": "npm run release -- minor --ci",
"release:major": "npm run release -- major --ci",
"setup-builds": "./scripts/build.sh",
"setup-packages": "./scripts/setup_all_packages.sh",
"bump-versions": "./scripts/bump_all_versions.sh $npm_package_version",
"publish:all": "./scripts/publish_all.sh --access public",
"storybook": "yarn --cwd examples storybook"
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook -o docs-build"
},
"repository": {
"type": "git",
@@ -35,21 +29,28 @@
},
"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": "1.0.65",
"@rive-app/webgl": "1.0.62"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0"
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"devDependencies": {
"@babel/core": "^7.18.0",
"@storybook/addon-actions": "^6.5.3",
"@storybook/addon-essentials": "^6.5.3",
"@storybook/addon-interactions": "^6.5.3",
"@storybook/addon-links": "^6.5.3",
"@storybook/builder-webpack4": "^6.5.3",
"@storybook/manager-webpack4": "^6.5.3",
"@storybook/react": "^6.5.3",
"@storybook/testing-library": "^0.0.11",
"@testing-library/jest-dom": "^5.13.0",
"@testing-library/react": "^16.3.0",
"@testing-library/react": "^11.2.7",
"@testing-library/react-hooks": "^7.0.0",
"@types/jest": "^27.0.3",
"@types/offscreencanvas": "^2019.6.4",
"@types/react": "^18.0.0",
"@types/react": "^17.0.9",
"@types/testing-library__jest-dom": "^5.9.5",
"@typescript-eslint/eslint-plugin": "^5.7.0",
"@typescript-eslint/parser": "^5.7.0",
@@ -64,12 +65,11 @@
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-react": "^7.27.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-storybook": "^0.5.12",
"jest": "^27.0.4",
"prettier": "^2.3.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"release-it": "^14.10.0",
"ts-jest": "^27.1.1",
"typescript": "^4.5.4",

View File

@@ -5,8 +5,6 @@ set -e
# Copy the build to each react-variant build for npm release
cp -r ./dist ./npm/react-webgl
cp -r ./dist ./npm/react-canvas
cp -r ./dist ./npm/react-canvas-lite
cp -r ./dist ./npm/react-webgl2
echo "Replacing the canvas with webgl references in react-webgl"
pushd ./npm/react-webgl/dist
@@ -18,25 +16,3 @@ else
find . -type f -name "*.js" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/webgl/g'
fi
popd
echo "Replacing the canvas with webgl2 references in react-webgl2"
pushd ./npm/react-webgl2/dist
if [[ "$OSTYPE" == "darwin"* ]]; then
find . -type f -name "*.ts" -print0 | xargs -0 sed -i '' -e 's/@rive-app\/canvas/@rive-app\/webgl2/g'
find . -type f -name "*.js" -print0 | xargs -0 sed -i '' -e 's/@rive-app\/canvas/@rive-app\/webgl2/g'
else
find . -type f -name "*.ts" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/webgl2/g'
find . -type f -name "*.js" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/webgl2/g'
fi
popd
echo "Replacing the canvas with canvas-lite references in react-canvas-lite"
pushd ./npm/react-canvas-lite/dist
if [[ "$OSTYPE" == "darwin"* ]]; then
find . -type f -name "*.ts" -print0 | xargs -0 sed -i '' -e 's/@rive-app\/canvas/@rive-app\/canvas-lite/g'
find . -type f -name "*.js" -print0 | xargs -0 sed -i '' -e 's/@rive-app\/canvas/@rive-app\/canvas-lite/g'
else
find . -type f -name "*.ts" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/canvas-lite/g'
find . -type f -name "*.js" -print0 | xargs -0 sed -i -e 's/@rive-app\/canvas/@rive-app\/canvas-lite/g'
fi
popd

11
scripts/bump_all_versions.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
set -e
# Bump the version number of every npm module in the npm folder.
for dir in ./npm/*; do
pushd $dir > /dev/null
repo_name=`echo $dir | sed 's:.*/::' | sed 's/_/-/g'`
echo Bumping version of $repo_name
../../scripts/bump_version.sh $repo_name $RELEASE_VERSION
popd > /dev/null
done

12
scripts/bump_version.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
# Bumps the version of a single npm module found in the current working
# directory. Call bump_version.sh from the path with package.json in it.
set -e
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# RELEASE_VERSION will come from an env variable passed in from the GH action workflow
node $SCRIPT_DIR/nextVersion.js "$RELEASE_VERSION" `pwd`
# Replace the dist package json with the newly trimmed one
cp -f ./package.json ./dist/package.json

33
scripts/nextVersion.js Normal file
View File

@@ -0,0 +1,33 @@
const fs = require('fs');
const path = process.argv[3];
const package = require(path + '/package.json');
const currentVersion = package.version;
const nextVersion = process.argv[2].trim().replace(/\'/g, '"');
if (!nextVersion || nextVersion === currentVersion) {
throw new Error('Next version is not defined or is a version that already exists');
}
// Returns -1 if first is less than second, 1 if first is greater than second, otherwise 0 if equal.
function compareVersion(first, second) {
// Assumption: only numbers in our versions.
const firstParts = first.split('.').map((value) => parseInt(value));
const secondParts = second.split('.').map((value) => parseInt(value));
for (let i = 0; i < Math.max(firstParts.length, secondParts.length); i++) {
const first = i < firstParts.length ? firstParts[i] : 0;
const second = i < secondParts.length ? secondParts[i] : 0;
if (first < second) {
return -1;
}
if (second < first) {
return 1;
}
}
return 0;
}
if (compareVersion(currentVersion, nextVersion) <= 0) {
package.version = nextVersion;
fs.writeFileSync(path + '/package.json', JSON.stringify(package, null, 2));
}

View File

@@ -5,6 +5,6 @@ set -e
for dir in ./npm/*; do
pushd $dir > /dev/null
echo Publishing `echo $dir | sed 's:.*/::'`
npm publish $@
npm publish --access public
popd > /dev/null
done

View File

@@ -3,17 +3,14 @@ set -e
echo "Copying package.json to rive-react npm package folders"
cp package.json npm/react-canvas
cp package.json npm/react-webgl
# Bump the version number of every npm module in the npm folder.
for dir in ./npm/*; do
echo $dir
pushd $dir > /dev/null
echo $dir
if [ -f "./package.json" ]; then
echo "Removing existing package.json..."
rm "./package.json"
echo "package.json deleted from $dir"
fi
cp ../../package.json ./
repo_name=`echo $dir | sed 's:.*/::' | sed 's/_/-/g'`
echo Setting package.json on npm packages
echo $repo_name

View File

@@ -1,8 +1,7 @@
const fs = require('fs');
const path = process.argv[2];
const npmPackageSplit = process.argv[3].split('-');
// extracts "webgl" or "canvas-lite" from the npm package name
const renderer = npmPackageSplit.slice(1).join('-');
const renderer = npmPackageSplit[npmPackageSplit.length - 1];
const package = require(path + '/package.json');
function trimNpmPackage() {

View File

@@ -21,34 +21,14 @@ export interface RiveProps {
*/
stateMachines?: string | string[];
/**
* Specify a starting Layout object to set Fill and Alignment for the drawing surface. See docs at https://rive.app/community/doc/layout/docBl81zd1GB for more on layout configuration.
* Specify a starting Layout object to set Fill and Alignment for the drawing surface. See docs at https://help.rive.app/runtimes/layout for more on layout configuration.
*/
layout?: Layout;
/**
* For `@rive-app/react-webgl`, sets this property to maintain a single WebGL context for multiple canvases. **We recommend to keep the default value** when rendering multiple Rive instances on a page.
*/
useOffscreenRenderer?: boolean;
/**
* Specify whether to disable Rive listeners on the canvas, thus preventing any event listeners to be attached to the canvas element
*/
shouldDisableRiveListeners?: boolean;
/**
* Specify whether to resize the canvas to its container automatically
*/
shouldResizeCanvasToContainer?: boolean;
/**
* Enable Rive Events to be handled by the runtime. This means any special Rive Event may have
* functionality that can be invoked implicitly when detected.
*
* For example, if during the render loop an OpenUrlEvent is detected, the
* browser may try to open the specified URL in the payload.
*
* This flag is false by default to prevent any unwanted behaviors from taking place.
* This means any special Rive Event will have to be handled manually by subscribing to
* EventType.RiveEvent
*/
automaticallyHandleEvents?: boolean;
}
};
const Rive = ({
src,
@@ -57,10 +37,6 @@ const Rive = ({
stateMachines,
layout,
useOffscreenRenderer = true,
shouldDisableRiveListeners = false,
shouldResizeCanvasToContainer = true,
automaticallyHandleEvents = false,
children,
...rest
}: RiveProps & ComponentProps<'canvas'>) => {
const params = {
@@ -70,17 +46,14 @@ const Rive = ({
layout,
stateMachines,
autoplay: true,
shouldDisableRiveListeners,
automaticallyHandleEvents,
};
const options = {
useOffscreenRenderer,
shouldResizeCanvasToContainer,
};
const { RiveComponent } = useRive(params, options);
return <RiveComponent {...rest}>{children}</RiveComponent>;
return <RiveComponent {...rest} />;
};
export default Rive;

View File

@@ -1,38 +0,0 @@
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const MyIntersectionObserver =
globalThis.IntersectionObserver || FakeIntersectionObserver;
class ElementObserver {
private observer;
private elementsMap: Map<Element, Function> = new Map();
constructor() {
this.observer = new MyIntersectionObserver(this.onObserved);
}
public onObserved = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
const elementCallback = this.elementsMap.get(entry.target as Element);
if (elementCallback) {
elementCallback(entry);
}
});
};
public registerCallback(element: Element, callback: Function) {
this.observer.observe(element);
this.elementsMap.set(element, callback);
}
public removeCallback(element: Element) {
this.observer.unobserve(element);
this.elementsMap.delete(element);
}
}
export default ElementObserver;

View File

@@ -1,94 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Dimensions } from '../types';
// There are polyfills for this, but they add hundreds of lines of code
class FakeResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function throttle(f: Function, delay: number) {
let timer = 0;
return function (this: Function, ...args: any) {
clearTimeout(timer);
timer = window.setTimeout(() => f.apply(this, args), delay);
};
}
const MyResizeObserver = globalThis.ResizeObserver || FakeResizeObserver;
const hasResizeObserver = globalThis.ResizeObserver !== undefined;
const useResizeObserver = hasResizeObserver;
const useWindowListener = !useResizeObserver;
/**
* Hook to listen for a ref element's resize events being triggered. When resized,
* it sets state to an object of {width: number, height: number} indicating the contentRect
* size of the element at the new resize.
*
* @param containerRef - Ref element to listen for resize events on
* @returns - Size object with width and height attributes
*/
export default function useSize(
containerRef: React.MutableRefObject<HTMLElement | null>,
shouldResizeCanvasToContainer = true
) {
const [size, setSize] = useState<Dimensions>({
width: 0,
height: 0,
});
// internet explorer does not support ResizeObservers.
useEffect(() => {
if (typeof window !== 'undefined' && shouldResizeCanvasToContainer) {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
if (useWindowListener) {
// only pay attention to window size changes when we do not have the resizeObserver (IE only)
handleResize();
window.addEventListener('resize', handleResize);
}
return () => window.removeEventListener('resize', handleResize);
}
}, []);
const observer = useRef(
new MyResizeObserver(
throttle((entries: any) => {
if (useResizeObserver) {
setSize({
width: entries[entries.length - 1].contentRect.width,
height: entries[entries.length - 1].contentRect.height,
});
}
}, 0)
)
);
useEffect(() => {
const currentObserver = observer.current;
if (!shouldResizeCanvasToContainer) {
currentObserver.disconnect();
return;
}
const containerEl = containerRef.current;
if (containerRef.current && useResizeObserver) {
currentObserver.observe(containerRef.current);
}
return () => {
currentObserver.disconnect();
if (containerEl && useResizeObserver) {
currentObserver.unobserve(containerEl);
}
};
}, [containerRef, observer]);
return size;
}

View File

@@ -1,51 +0,0 @@
import { useEffect, useState } from 'react';
/**
* Listen for devicePixelRatio changes and set the new value accordingly. This could
* happen for reasons such as:
* - User moves window from retina screen display to a separate monitor
* - User controls zoom settings on the browser
*
* Source: https://github.com/rexxars/use-device-pixel-ratio/blob/main/index.ts
*
* @param customDevicePixelRatio - Number to force a dpr to abide by, rather than using the window's
*
* @returns dpr: Number - Device pixel ratio; ratio of physical px to resolution in CSS pixels for current device
*/
export default function useDevicePixelRatio(customDevicePixelRatio?: number) {
const dpr = customDevicePixelRatio || getDevicePixelRatio();
const [currentDpr, setCurrentDpr] = useState(dpr);
useEffect(() => {
const canListen = typeof window !== 'undefined' && 'matchMedia' in window;
if (!canListen) {
return;
}
const updateDpr = () => {
const newDpr = customDevicePixelRatio || getDevicePixelRatio();
setCurrentDpr(newDpr);
};
const mediaMatcher = window.matchMedia(
`screen and (resolution: ${currentDpr}dppx)`
);
mediaMatcher.hasOwnProperty('addEventListener')
? mediaMatcher.addEventListener('change', updateDpr)
: mediaMatcher.addListener(updateDpr);
return () => {
mediaMatcher.hasOwnProperty('removeEventListener')
? mediaMatcher.removeEventListener('change', updateDpr)
: mediaMatcher.removeListener(updateDpr);
};
}, [currentDpr, customDevicePixelRatio]);
return currentDpr;
}
function getDevicePixelRatio(): number {
const hasDprProp =
typeof window !== 'undefined' &&
typeof window.devicePixelRatio === 'number';
const dpr = hasDprProp ? window.devicePixelRatio : 1;
return Math.min(Math.max(1, dpr), 3);
}

View File

@@ -1,32 +0,0 @@
import { useCallback } from 'react';
import ElementObserver from './elementObserver';
let observer: ElementObserver;
const getObserver = () => {
if(!observer) {
observer = new ElementObserver();
}
return observer;
}
/**
* Hook to observe elements when they are intersecting with the viewport
*
* @returns - API to observer and unobserve elements
*/
export default function useIntersectionObserver() {
const observe = useCallback((element: Element, callback: Function) => {
const observer = getObserver();
observer.registerCallback(element, callback);
}, []);
const unobserve = useCallback((element: Element) => {
const observer = getObserver();
observer.removeCallback(element);
}, []);
return {
observe,
unobserve,
};
}

View File

@@ -1,195 +0,0 @@
import { useEffect, useState, MutableRefObject, useCallback } from 'react';
import { Bounds } from '@rive-app/canvas';
import { Dimensions, UseRiveOptions } from '../types';
import useDevicePixelRatio from './useDevicePixelRatio';
import useContainerSize from './useContainerSize';
import { getOptions } from '../utils';
interface UseResizeCanvasProps {
/**
* Whether or not Rive is loaded and renderer is associated with the canvas
*/
riveLoaded: boolean;
/**
* Ref to the canvas element
*/
canvasElem: HTMLCanvasElement | null;
/**
* Ref to the container element of the canvas
*/
containerRef: MutableRefObject<HTMLElement | null>;
/**
* (Optional) Callback to be invoked after the canvas has been resized due to a resize
* of its parent container. This is where you would want to reset the layout
* dimensions for the Rive renderer to dictate the new min/max bounds of the
* canvas.
*
* Using the high-level JS runtime, this might be a simple call to `rive.resizeToCanvas()`
* Using the low-level JSruntime, this might be invoking the renderer's `.align()` method
* with the Layout and min/max X/Y values of the canvas.
*
* @returns void
*/
onCanvasHasResized?: () => void;
/**
* (Optional) Options passed to the useRive hook, including the shouldResizeCanvasToContainer option
* which prevents the canvas element from resizing to its parent container
*/
options?: Partial<UseRiveOptions>;
/**
* (Optional) AABB bounds of the artboard. If provided, the canvas will be sized to the artboard
* height if the fitCanvasToArtboardHeight option is true.
*/
artboardBounds?: Bounds;
}
/**
* Helper hook to listen for changes in the <canvas> parent container size and size the <canvas>
* to match. If a resize event has occurred, a supplied callback (onCanvasHasResized)
* will be inokved to allow for any re-calculation needed (i.e. Rive layout on the canvas).
*
* This hook is useful if you are not intending to use the `useRive` hook yourself, but still
* want to use the auto-sizing logic on the canvas/container.
*
* @param props - Object to supply necessary props to the hook
*/
export default function useResizeCanvas({
riveLoaded = false,
canvasElem,
containerRef,
options = {},
onCanvasHasResized,
artboardBounds,
}: UseResizeCanvasProps) {
const presetOptions = getOptions(options);
const [
{ height: lastContainerHeight, width: lastContainerWidth },
setLastContainerDimensions,
] = useState<Dimensions>({
height: 0,
width: 0,
});
const [
{ height: lastCanvasHeight, width: lastCanvasWidth },
setLastCanvasSize,
] = useState<Dimensions>({
height: 0,
width: 0,
});
const [isFirstSizing, setIsFirstSizing] = useState(true);
const {
fitCanvasToArtboardHeight,
shouldResizeCanvasToContainer,
useDevicePixelRatio: shouldUseDevicePixelRatio,
customDevicePixelRatio,
} = presetOptions;
const containerSize = useContainerSize(
containerRef,
shouldResizeCanvasToContainer
);
const currentDevicePixelRatio = useDevicePixelRatio(customDevicePixelRatio);
const { maxX, maxY } = artboardBounds ?? {};
const getContainerDimensions = useCallback(() => {
const width = containerRef.current?.clientWidth ?? 0;
const height = containerRef.current?.clientHeight ?? 0;
if (fitCanvasToArtboardHeight && artboardBounds) {
const { maxY, maxX } = artboardBounds;
return { width, height: width * (maxY / maxX) };
}
return {
width,
height,
};
}, [containerRef, fitCanvasToArtboardHeight, maxX, maxY]);
useEffect(() => {
// If Rive is not ready, the container is not ready, or the user supplies a flag
// to not resize the canvas to the container, then return early
if (
!shouldResizeCanvasToContainer ||
!containerRef.current ||
!riveLoaded
) {
return;
}
const { width, height } = getContainerDimensions();
let hasResized = false;
if (canvasElem) {
// Check if the canvas parent container bounds have changed and set
// new values accordingly
const boundsChanged =
width !== lastContainerWidth || height !== lastContainerHeight;
if (presetOptions.fitCanvasToArtboardHeight && boundsChanged) {
containerRef.current.style.height = height + 'px';
hasResized = true;
}
if (presetOptions.useDevicePixelRatio) {
// Check if devicePixelRatio may have changed and get new canvas
// width/height values to set the size
const canvasSizeChanged =
width * currentDevicePixelRatio !== lastCanvasWidth ||
height * currentDevicePixelRatio !== lastCanvasHeight;
if (boundsChanged || canvasSizeChanged) {
const newCanvasWidthProp = currentDevicePixelRatio * width;
const newCanvasHeightProp = currentDevicePixelRatio * height;
canvasElem.width = newCanvasWidthProp;
canvasElem.height = newCanvasHeightProp;
canvasElem.style.width = width + 'px';
canvasElem.style.height = height + 'px';
setLastCanvasSize({
width: newCanvasWidthProp,
height: newCanvasHeightProp,
});
hasResized = true;
}
} else if (boundsChanged) {
canvasElem.width = width;
canvasElem.height = height;
setLastCanvasSize({
width: width,
height: height,
});
hasResized = true;
}
setLastContainerDimensions({ width, height });
}
// Callback to perform any Rive-related actions after resizing the canvas
// (i.e., reset the Rive layout in the render loop)
if (onCanvasHasResized && (isFirstSizing || hasResized)) {
onCanvasHasResized && onCanvasHasResized();
}
isFirstSizing && setIsFirstSizing(false);
}, [
canvasElem,
containerRef,
containerSize,
currentDevicePixelRatio,
getContainerDimensions,
isFirstSizing,
setIsFirstSizing,
lastCanvasHeight,
lastCanvasWidth,
lastContainerHeight,
lastContainerWidth,
onCanvasHasResized,
shouldResizeCanvasToContainer,
fitCanvasToArtboardHeight,
shouldUseDevicePixelRatio,
riveLoaded,
]);
// Reset width and height values when the canvas changes
useEffect(() => {
setLastCanvasSize({
width: 0,
height: 0,
});
}, [canvasElem]);
}

View File

@@ -6,12 +6,14 @@ import React, {
ComponentProps,
RefCallback,
} from 'react';
import { Rive, EventType, Fit } from '@rive-app/canvas';
import { UseRiveParameters, UseRiveOptions, RiveState } from '../types';
import useResizeCanvas from './useResizeCanvas';
import useDevicePixelRatio from './useDevicePixelRatio';
import { getOptions } from '../utils';
import useIntersectionObserver from './useIntersectionObserver';
import { Rive, EventType } from '@rive-app/canvas';
import {
UseRiveParameters,
UseRiveOptions,
RiveState,
Dimensions,
} from '../types';
import { useWindowSize } from '../utils';
type RiveComponentProps = {
setContainerRef: RefCallback<HTMLElement>;
@@ -23,7 +25,6 @@ function RiveComponent({
setCanvasRef,
className = '',
style,
children,
...rest
}: RiveComponentProps & ComponentProps<'canvas'>) {
const containerStyle = {
@@ -38,17 +39,27 @@ function RiveComponent({
className={className}
{...(!className && { style: containerStyle })}
>
<canvas
ref={setCanvasRef}
style={{ verticalAlign: 'top', width: 0, height: 0 }}
{...rest}
>
{children}
</canvas>
<canvas ref={setCanvasRef} style={{ verticalAlign: 'top' }} {...rest} />
</div>
);
}
const defaultOptions = {
useDevicePixelRatio: true,
fitCanvasToArtboardHeight: false,
useOffscreenRenderer: true,
};
/**
* Returns options, with defaults set.
*
* @param opts
* @returns
*/
function getOptions(opts: Partial<UseRiveOptions>) {
return Object.assign({}, defaultOptions, opts);
}
/**
* Custom Hook for loading a Rive file.
*
@@ -67,92 +78,113 @@ export default function useRive(
riveParams?: UseRiveParameters,
opts: Partial<UseRiveOptions> = {}
): RiveState {
const [canvasElem, setCanvasElem] = useState<HTMLCanvasElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLElement | null>(null);
const [rive, setRive] = useState<Rive | null>(null);
const [dimensions, setDimensions] = useState<Dimensions>({
height: 0,
width: 0,
});
// Listen to changes in the window sizes and update the bounds when changes
// occur.
const windowSize = useWindowSize();
const isParamsLoaded = Boolean(riveParams);
const options = getOptions(opts);
const devicePixelRatio = useDevicePixelRatio();
/**
* Gets the intended dimensions of the canvas element.
*
* The intended dimensions are those of the container element, unless the
* option `fitCanvasToArtboardHeight` is true, then they are adjusted to
* the height of the artboard.
*
* @returns Dimensions object.
*/
function getCanvasDimensions() {
const { width, height } =
containerRef.current?.getBoundingClientRect() ?? new DOMRect(0, 0, 0, 0);
if (rive && options.fitCanvasToArtboardHeight) {
const { maxY, maxX } = rive.bounds;
return { width, height: width * (maxY / maxX) };
}
return { width, height };
}
/**
* When the canvas/parent container resize, reset the Rive layout to match the
* new (0, 0, canvas.width, canvas.height) bounds in the render loop
* Updates the width and height of the canvas.
*/
const onCanvasHasResized = useCallback(() => {
if (rive) {
if (rive.layout && rive.layout.fit === Fit.Layout) {
if (canvasElem) {
const resizeFactor = devicePixelRatio * rive.layout.layoutScaleFactor;
rive.devicePixelRatioUsed = devicePixelRatio;
rive.artboardWidth = canvasElem?.width / resizeFactor;
rive.artboardHeight = canvasElem?.height / resizeFactor;
}
}
function updateBounds() {
if (!containerRef.current) {
return;
}
const { width, height } = getCanvasDimensions();
const boundsChanged =
width !== dimensions.width || height !== dimensions.height;
if (canvasRef.current && rive && boundsChanged) {
if (options.fitCanvasToArtboardHeight) {
containerRef.current.style.height = height + 'px';
}
if (options.useDevicePixelRatio) {
const dpr = window.devicePixelRatio || 1;
canvasRef.current.width = dpr * width;
canvasRef.current.height = dpr * height;
canvasRef.current.style.width = width + 'px';
canvasRef.current.style.height = height + 'px';
} else {
canvasRef.current.width = width;
canvasRef.current.height = height;
}
setDimensions({ width, height });
// Updating the canvas width or height will clear the canvas, so call
// startRendering() to redraw the current frame as the animation might
// be paused and not advancing.
rive.startRendering();
}
// Always resize to Canvas
if (rive) {
rive.resizeToCanvas();
}
}, [rive, devicePixelRatio]);
}
// Watch the canvas parent container resize and size the canvas to match
useResizeCanvas({
riveLoaded: !!rive,
canvasElem,
containerRef,
options,
onCanvasHasResized,
artboardBounds: rive?.bounds,
});
/**
* Listen to changes on the windowSize and the rive file being loaded
* and update the canvas bounds as needed.
*/
useEffect(() => {
if (rive) {
updateBounds();
}
}, [rive, windowSize]);
/**
* Ref callback called when the canvas element mounts and unmounts.
*/
const setCanvasRef: RefCallback<HTMLCanvasElement> = useCallback(
(canvas: HTMLCanvasElement | null) => {
if (canvas === null && canvasElem) {
canvasElem.height = 0;
canvasElem.width = 0;
if (canvas && riveParams) {
const { useOffscreenRenderer } = options;
const r = new Rive({
useOffscreenRenderer,
...riveParams,
canvas,
});
r.on(EventType.Load, () => setRive(r));
} else if (canvas === null && canvasRef.current) {
canvasRef.current.height = 0;
canvasRef.current.width = 0;
}
setCanvasElem(canvas);
canvasRef.current = canvas;
},
[]
[isParamsLoaded]
);
useEffect(() => {
if (!canvasElem || !riveParams) {
return;
}
let isLoaded = rive != null;
let r: Rive | null;
if (rive == null) {
const { useOffscreenRenderer } = options;
r = new Rive({
useOffscreenRenderer,
...riveParams,
canvas: canvasElem,
});
r.on(EventType.Load, () => {
isLoaded = true;
// Check if the component/canvas is mounted before setting state to avoid setState
// on an unmounted component in some rare cases
if (canvasElem) {
setRive(r);
} else {
// If unmounted, cleanup the rive object immediately
r!.cleanup();
}
});
}
return () => {
if (!isLoaded) {
r?.cleanup();
}
};
}, [canvasElem, isParamsLoaded, rive]);
/**
* Ref callback called when the container element mounts
*/
@@ -167,75 +199,33 @@ export default function useRive(
* Set up IntersectionObserver to stop rendering if the animation is not in
* view.
*/
const { observe, unobserve } = useIntersectionObserver();
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
let isPaused = false;
// This is a workaround to retest whether an element is offscreen or not.
// There seems to be a bug in Chrome that triggers an intersection change when an element
// is moved within the DOM using insertBefore.
// For some reason, when this is called whithin the context of a React application, the
// intersection callback is called only once reporting isIntersecting as false but never
// triggered back with isIntersecting as true.
// For this reason we retest after 10 millisecond whether the element is actually off the
// viewport or not.
const retestIntersection = () => {
if (canvasElem && isPaused) {
const size = canvasElem.getBoundingClientRect();
const isIntersecting =
size.width > 0 &&
size.height > 0 &&
size.top <
(window.innerHeight || document.documentElement.clientHeight) &&
size.bottom > 0 &&
size.left <
(window.innerWidth || document.documentElement.clientWidth) &&
size.right > 0;
if (isIntersecting) {
rive?.startRendering();
isPaused = false;
}
}
};
const onChange = (entry: IntersectionObserverEntry) => {
const observer = new IntersectionObserver(([entry]) => {
entry.isIntersecting
? rive && rive.startRendering()
: rive && rive.stopRendering();
isPaused = !entry.isIntersecting;
clearTimeout(timeoutId);
if (!entry.isIntersecting && entry.boundingClientRect.width === 0) {
timeoutId = setTimeout(retestIntersection, 10);
}
};
if (canvasElem && options.shouldUseIntersectionObserver !== false) {
observe(canvasElem, onChange);
});
if (canvasRef.current) {
observer.observe(canvasRef.current);
}
return () => {
if (canvasElem) {
unobserve(canvasElem);
}
observer.disconnect();
};
}, [
observe,
unobserve,
rive,
canvasElem,
options.shouldUseIntersectionObserver,
]);
}, [rive]);
/**
* On unmount, call cleanup to cleanup any WASM generated objects that need
* to be manually destroyed.
* On unmount, stop rive from rendering.
*/
useEffect(() => {
return () => {
if (rive) {
rive.cleanup();
rive.stop();
setRive(null);
}
};
}, [rive, canvasElem]);
}, [rive]);
/**
* Listen for changes in the animations params
@@ -263,12 +253,11 @@ export default function useRive(
/>
);
},
[setCanvasRef, setContainerRef]
[]
);
return {
canvas: canvasElem,
container: containerRef.current,
canvas: canvasRef.current,
setCanvasRef,
setContainerRef,
rive,

View File

@@ -1,57 +0,0 @@
import { useState, useEffect } from 'react';
import type {
UseRiveFileParameters,
RiveFileState,
FileStatus,
} from '../types';
import { EventType, RiveFile } from '@rive-app/canvas';
/**
* Custom hook for initializing and managing a RiveFile instance within a component.
* It sets up a RiveFile based on provided source parameters (URL or ArrayBuffer) and ensures
* proper cleanup to avoid memory leaks when the component unmounts or inputs change.
*
* @param params - Object containing parameters accepted by the Rive file in the @rive-app/canvas runtime,
*
* @returns {RiveFileState} Contains the active RiveFile instance (`riveFile`) and the loading status.
*/
function useRiveFile(params: UseRiveFileParameters): RiveFileState {
const [riveFile, setRiveFile] = useState<RiveFile | null>(null);
const [status, setStatus] = useState<FileStatus>('idle');
useEffect(() => {
let file: RiveFile | null = null;
const loadRiveFile = async () => {
try {
setStatus('loading');
file = new RiveFile(params);
file.init();
file.on(EventType.Load, () => {
// We request an instance to add +1 to the referencesCount so it doesn't get destroyed
// while this hook is active
file?.getInstance();
setRiveFile(file);
setStatus('success');
});
file.on(EventType.LoadError, () => {
setStatus('failed');
});
setRiveFile(file);
} catch (error) {
console.error(error);
setStatus('failed');
}
};
loadRiveFile();
return () => {
file?.cleanup();
};
}, [params.src, params.buffer]);
return { riveFile, status };
}
export default useRiveFile;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { EventType, Rive, StateMachineInput } from '@rive-app/canvas';
import { Rive, StateMachineInput } from '@rive-app/canvas';
/**
* Custom hook for fetching a stateMachine input from a rive file.
@@ -18,33 +18,21 @@ export default function useStateMachineInput(
const [input, setInput] = useState<StateMachineInput | null>(null);
useEffect(() => {
function setStateMachineInput() {
if (!rive || !stateMachineName || !inputName) {
setInput(null);
}
if (rive && stateMachineName && inputName) {
const inputs = rive.stateMachineInputs(stateMachineName);
if (inputs) {
const selectedInput = inputs.find(
(input) => input.name === inputName
);
if (initialValue !== undefined && selectedInput) {
selectedInput.value = initialValue;
}
setInput(selectedInput || null);
}
} else {
setInput(null);
}
if (!rive || !stateMachineName || !inputName) {
setInput(null);
}
setStateMachineInput();
if (rive) {
rive.on(EventType.Load, () => {
// Check if the component/canvas is mounted before setting state to avoid setState
// on an unmounted component in some rare cases
setStateMachineInput();
});
if (rive && stateMachineName && inputName) {
const inputs = rive.stateMachineInputs(stateMachineName);
if (inputs) {
const selectedInput = inputs.find((input) => input.name === inputName);
if (initialValue !== undefined && selectedInput) {
selectedInput.value = initialValue;
}
setInput(selectedInput || null);
}
} else {
setInput(null);
}
}, [rive]);

View File

@@ -1,94 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { Rive, ViewModel, EventType } from '@rive-app/canvas';
import { UseViewModelParameters } from '../types';
function areParamsEqual(
prev?: UseViewModelParameters,
next?: UseViewModelParameters
): boolean {
if (prev === next) return true;
if (!prev || !next) return prev === next;
if ('name' in prev && 'name' in next) {
return prev.name === next.name;
}
if ('useDefault' in prev && 'useDefault' in next) {
return prev.useDefault === next.useDefault;
}
return false;
}
/**
* 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 riveRef = useRef<Rive | null>(null);
const paramsRef = useRef<UseViewModelParameters | undefined>(params);
const [viewModel, setViewModel] = useState<ViewModel | null>(null);
const shouldUpdate = useRef(true);
useEffect(() => {
const isRiveChanged = riveRef.current !== rive;
const areParamsChanged = !areParamsEqual(paramsRef.current, params);
shouldUpdate.current = isRiveChanged || areParamsChanged;
riveRef.current = rive;
paramsRef.current = params;
if (!shouldUpdate.current && viewModel) {
return;
}
function fetchViewModel() {
const currentRive = riveRef.current;
const currentParams = paramsRef.current;
if (!currentRive) {
setViewModel(null);
return;
}
let model: ViewModel | null = null;
if (currentParams?.name != null) {
model = currentRive.viewModelByName?.(currentParams.name) || null;
} else if (currentParams?.useDefault) {
model = currentRive.defaultViewModel() || null;
} else {
model = currentRive.defaultViewModel() || null;
}
setViewModel(model);
shouldUpdate.current = false;
}
fetchViewModel();
const currentRive = riveRef.current;
if (currentRive) {
currentRive.on(EventType.Load, fetchViewModel);
}
return () => {
if (currentRive) {
currentRive.off(EventType.Load, fetchViewModel);
}
};
}, [rive, name, useDefault]);
return viewModel;
}

View File

@@ -1,96 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { ViewModel, ViewModelInstance } from '@rive-app/canvas';
import { UseViewModelInstanceParameters } from '../types';
function areParamsEqual(
prev?: UseViewModelInstanceParameters,
next?: UseViewModelInstanceParameters
): boolean {
if (prev === next) return true;
if (!prev || !next) return prev === next;
if ('name' in prev && 'name' in next) {
return prev.name === next.name;
}
if ('useDefault' in prev && 'useDefault' in next) {
return prev.useDefault === next.useDefault;
}
if ('useNew' in prev && 'useNew' in next) {
return prev.useNew === next.useNew;
}
return false;
}
/**
* 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);
const viewModelRef = useRef<ViewModel | null>(viewModel);
const paramsRef = useRef<UseViewModelInstanceParameters | undefined>(params);
const instanceRef = useRef<ViewModelInstance | null>(null);
const shouldUpdate = useRef(true);
useEffect(() => {
const isViewModelChanged = viewModelRef.current !== viewModel;
const areParamsChanged = !areParamsEqual(paramsRef.current, params);
shouldUpdate.current = isViewModelChanged || areParamsChanged;
viewModelRef.current = viewModel;
paramsRef.current = params;
if (!shouldUpdate.current && instanceRef.current) {
return;
}
const currentViewModel = viewModelRef.current;
const currentParams = paramsRef.current;
if (!currentViewModel) {
setInstance(null);
instanceRef.current = null;
return;
}
let result: ViewModelInstance | null = null;
if (currentParams?.name != null) {
result = currentViewModel.instanceByName(currentParams.name) || null;
} else if (currentParams?.useDefault) {
result = currentViewModel.defaultInstance?.() || null;
} else if (currentParams?.useNew) {
result = currentViewModel.instance?.() || null;
} else {
result = currentViewModel.defaultInstance?.() || null;
}
instanceRef.current = result;
setInstance(result);
shouldUpdate.current = false;
// Bind instance to Rive if needed
if (rive && result && rive.viewModelInstance !== result) {
rive.bindViewModelInstance(result);
}
}, [viewModel, name, useDefault, useNew, rive]);
return instance;
}

View File

@@ -1,36 +0,0 @@
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

@@ -1,56 +0,0 @@
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

@@ -1,45 +0,0 @@
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

@@ -1,37 +0,0 @@
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

@@ -1,175 +0,0 @@
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

@@ -1,38 +0,0 @@
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

@@ -1,42 +0,0 @@
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,37 +1,8 @@
import Rive, { RiveProps } from './components/Rive';
import Rive 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 useResizeCanvas from './hooks/useResizeCanvas';
import useRiveFile from './hooks/useRiveFile';
export default Rive;
export {
useRive,
useStateMachineInput,
useResizeCanvas,
useRiveFile,
useViewModel,
useViewModelInstance,
useViewModelInstanceNumber,
useViewModelInstanceString,
useViewModelInstanceBoolean,
useViewModelInstanceColor,
useViewModelInstanceEnum,
useViewModelInstanceTrigger,
RiveProps,
};
export {
RiveState,
UseRiveParameters,
UseRiveFileParameters,
UseRiveOptions,
} from './types';
export * from '@rive-app/canvas';
export { useRive, useStateMachineInput };
export { RiveState, UseRiveParameters, UseRiveOptions } from './types';
export * from '@rive-app/canvas';

View File

@@ -1,20 +1,12 @@
import {
Rive,
RiveFile,
RiveFileParameters,
RiveParameters,
} from '@rive-app/canvas';
import { ComponentProps, RefCallback } from 'react';
import { RefCallback, ComponentProps } from 'react';
import { Rive, RiveParameters } from '@rive-app/canvas';
export type UseRiveParameters = Partial<Omit<RiveParameters, 'canvas'>> | null;
export type UseRiveOptions = {
useDevicePixelRatio: boolean;
customDevicePixelRatio: number;
fitCanvasToArtboardHeight: boolean;
useOffscreenRenderer: boolean;
shouldResizeCanvasToContainer: boolean;
shouldUseIntersectionObserver?: boolean;
};
export type Dimensions = {
@@ -25,162 +17,17 @@ export type Dimensions = {
/**
* @typedef RiveState
* @property canvas - Canvas element the Rive Animation is attached to.
* @property container - Container element of the canvas.
* @property setCanvasRef - Ref callback to be passed to the canvas element.
* @property setContainerRef - Ref callback to be passed to the container
* element of the canvas. This is optional, however if not used then the hook
* will not take care of automatically resizing the canvas to it's outer
* @property setContainerRef - Ref callback to be passed to the container element
* of the canvas. This is optional, however if not used then the hook will
* not take care of automatically resizing the canvas to it's outer
* container if the window resizes.
* @property rive - The loaded Rive Animation
*/
export type RiveState = {
canvas: HTMLCanvasElement | null;
container: HTMLElement | null;
setCanvasRef: RefCallback<HTMLCanvasElement>;
setContainerRef: RefCallback<HTMLElement>;
rive: Rive | null;
RiveComponent: (props: ComponentProps<'canvas'>) => JSX.Element;
};
export type UseRiveFileParameters = Partial<
Omit<RiveFileParameters, 'onLoad' | 'onLoadError'>
>;
export type FileStatus = 'idle' | 'loading' | 'failed' | 'success';
/**
* @typedef RiveFileState
* @property data - The RiveFile instance
* @property status - The status of the file
*/
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;
};

View File

@@ -1,12 +1,24 @@
import { UseRiveOptions } from './types';
import { useState, useEffect } from 'react';
import { Dimensions } from './types';
const defaultOptions = {
useDevicePixelRatio: true,
fitCanvasToArtboardHeight: false,
useOffscreenRenderer: true,
shouldResizeCanvasToContainer: true,
};
export function useWindowSize() {
const [windowSize, setWindowSize] = useState<Dimensions>({
width: 0,
height: 0,
});
export function getOptions(opts: Partial<UseRiveOptions>) {
return Object.assign({}, defaultOptions, opts);
useEffect(() => {
if (typeof window !== 'undefined') {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}
}, []);
return windowSize;
}

View File

@@ -1,12 +1,11 @@
import React from 'react';
import RiveComponent from '../src/components/Rive';
import { render } from '@testing-library/react';
import {render} from '@testing-library/react'
jest.mock('@rive-app/canvas', () => ({
Rive: jest.fn().mockImplementation(() => ({
on: jest.fn(),
stop: jest.fn(),
cleanup: jest.fn(),
})),
Layout: jest.fn(),
Fit: {
@@ -27,29 +26,8 @@ jest.mock('@rive-app/canvas', () => ({
describe('Rive Component', () => {
it('renders the component as a canvas and a div wrapper', () => {
const { container, getByLabelText } = render(
<RiveComponent
src="foo.riv"
className="container-styles"
aria-label="Foo label"
/>
);
const {container, getByLabelText} = render(<RiveComponent src="foo.riv" className="container-styles" aria-label="Foo label" />);
expect(container.firstChild).toHaveClass('container-styles');
expect(getByLabelText('Foo label').tagName).toEqual('CANVAS');
});
it('allows children to render in the canvas body', () => {
const accessibleFallbackText = 'An animated test';
const { getByText } = render(
<RiveComponent
src="foo.riv"
className="container-styles"
aria-label="Foo label"
>
<p>{accessibleFallbackText}</p>
</RiveComponent>
);
expect(getByText(accessibleFallbackText)).not.toBeNull();
});
});

View File

@@ -1,39 +0,0 @@
// TODO move this
const observe = jest.fn();
const unobserve = jest.fn();
const disconnect = jest.fn();
jest.spyOn(globalThis, 'IntersectionObserver').mockImplementation(() => {
return {
observe,
unobserve,
disconnect,
root: null,
thresholds: [],
rootMargin: '',
takeRecords: () => [],
};
});
import ElementObserver from '../src/hooks/elementObserver';
describe('elementObserver', () => {
it('registers a callback and observes the element', () => {
const observer = new ElementObserver();
const element = document.createElement('li');
observer.registerCallback(element, ()=>{});
expect(observe).toHaveBeenCalled();
expect(observe).toHaveBeenCalledWith(element);
});
it('unregisters a callback and unobserves the element', () => {
const observer = new ElementObserver();
const element = document.createElement('li');
observer.removeCallback(element);
expect(unobserve).toHaveBeenCalled();
expect(unobserve).toHaveBeenCalledWith(element);
});
});
jest.clearAllMocks();

View File

@@ -1,42 +0,0 @@
import { renderHook, act } from '@testing-library/react';
import ElementObserver from '../src/hooks/elementObserver';
jest.mock('../src/hooks/elementObserver');
import useIntersectionObserver from '../src/hooks/useIntersectionObserver';
describe('useIntersectionObserver', () => {
it('returns an object on initialization', () => {
const { result } = renderHook(() => useIntersectionObserver());
expect(result.current).toBeDefined();
});
it('registers a callback', () => {
const { result } = renderHook(() => useIntersectionObserver());
const element = document.createElement('li');
const callback = () => {};
act(() => {
result.current.observe(element, callback);
});
const mockElementObserver = (ElementObserver as jest.Mock).mock
.instances[0];
const registerCallback = mockElementObserver.registerCallback;
expect(registerCallback.mock.calls.length).toBe(1);
expect(registerCallback.mock.calls[0].length).toBe(2);
expect(registerCallback.mock.calls[0][0]).toBe(element);
expect(registerCallback.mock.calls[0][1]).toBe(callback);
});
it('unregisters a callback', () => {
const { result } = renderHook(() => useIntersectionObserver());
const element = document.createElement('li');
act(() => {
result.current.unobserve(element);
});
const mockElementObserver = (ElementObserver as jest.Mock).mock
.instances[0];
const removeCallback = mockElementObserver.removeCallback;
expect(removeCallback.mock.calls.length).toBe(1);
expect(removeCallback.mock.calls[0].length).toBe(1);
expect(removeCallback.mock.calls[0][0]).toBe(element);
});
});

View File

@@ -1,9 +1,10 @@
import React from 'react';
import { mocked } from 'jest-mock';
import { renderHook, act, render, waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react-hooks';
import useRive from '../src/hooks/useRive';
import * as rive from '@rive-app/canvas';
import { render } from '@testing-library/react';
jest.mock('@rive-app/canvas', () => ({
Rive: jest.fn().mockImplementation(() => ({
@@ -28,25 +29,6 @@ jest.mock('@rive-app/canvas', () => ({
}));
describe('useRive', () => {
let controlledRiveloadCb: () => void;
let baseRiveMock: Partial<rive.Rive>;
beforeEach(() => {
baseRiveMock = {
on: (_: rive.EventType, cb: rive.EventCallback) =>
((controlledRiveloadCb as rive.EventCallback) = cb),
stop: jest.fn(),
stopRendering: jest.fn(),
startRendering: jest.fn(),
cleanup: jest.fn(),
resizeToCanvas: jest.fn(),
};
});
afterEach(() => {
controlledRiveloadCb = () => {};
});
it('returns rive as null if no params are passed', () => {
const { result } = renderHook(() => useRive());
expect(result.current.rive).toBe(null);
@@ -58,8 +40,14 @@ describe('useRive', () => {
src: 'file-src',
};
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
stopRendering: jest.fn(),
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => baseRiveMock);
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement('canvas');
const { result } = renderHook(() => useRive(params));
@@ -67,13 +55,8 @@ describe('useRive', () => {
await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
expect(result.current.rive).toBe(baseRiveMock);
expect(result.current.rive).toBe(riveMock);
expect(result.current.canvas).toBe(canvasSpy);
});
@@ -85,7 +68,9 @@ describe('useRive', () => {
const resizeToCanvasMock = jest.fn();
const riveMock = {
...baseRiveMock,
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
stopRendering: jest.fn(),
resizeToCanvas: resizeToCanvasMock,
};
@@ -100,17 +85,6 @@ describe('useRive', () => {
result.current.setCanvasRef(canvasSpy);
result.current.setContainerRef(containerSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
await act(async () => {
jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(500);
jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(500);
containerSpy.dispatchEvent(new Event('resize'));
});
expect(result.current.rive).toBe(riveMock);
expect(result.current.canvas).toBe(canvasSpy);
@@ -118,16 +92,16 @@ describe('useRive', () => {
expect(resizeToCanvasMock).toBeCalled();
});
it('calls cleanup on the rive object on unmount', async () => {
it('stops the rive object on unmount', async () => {
const params = {
src: 'file-src',
};
const cleanupMock = jest.fn();
const stopMock = jest.fn();
const riveMock = {
...baseRiveMock,
cleanup: cleanupMock,
on: (_: string, cb: () => void) => cb(),
stop: stopMock,
};
// @ts-ignore
@@ -139,32 +113,33 @@ describe('useRive', () => {
await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
unmount();
expect(cleanupMock).toBeCalled();
expect(stopMock).toBeCalled();
});
it('sets the bounds with the devicePixelRatio by default', async () => {
it('sets the a bounds with the devicePixelRatio by default', async () => {
const params = {
src: 'file-src',
};
global.devicePixelRatio = 2;
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => baseRiveMock);
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement('canvas');
const containerSpy = document.createElement('div');
jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100);
jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100);
containerSpy.getBoundingClientRect = jest.fn().mockImplementation(() => ({
width: 100,
height: 100,
}));
const { result } = renderHook(() => useRive(params));
@@ -172,12 +147,6 @@ describe('useRive', () => {
result.current.setCanvasRef(canvasSpy);
result.current.setContainerRef(containerSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
// Height and width should be 2* the width and height returned from containers
// bounding rect
@@ -189,46 +158,6 @@ describe('useRive', () => {
expect(canvasSpy).toHaveAttribute('style', 'width: 100px; height: 100px;');
});
it('sets the bounds with a specified customDevicePixelRatio if one is set', async () => {
const params = {
src: 'file-src',
};
global.devicePixelRatio = 2;
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => baseRiveMock);
const canvasSpy = document.createElement('canvas');
const containerSpy = document.createElement('div');
jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100);
jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100);
const { result } = renderHook(() =>
useRive(params, { customDevicePixelRatio: 1 })
);
await act(async () => {
result.current.setCanvasRef(canvasSpy);
result.current.setContainerRef(containerSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
// Height and width should be 2* the width and height returned from containers
// bounding rect
expect(canvasSpy).toHaveAttribute('height', '100');
expect(canvasSpy).toHaveAttribute('width', '100');
// Style height and width should be the same as returned from containers
// bounding rect
expect(canvasSpy).toHaveAttribute('style', 'width: 100px; height: 100px;');
});
it('sets the a bounds without the devicePixelRatio if useDevicePixelRatio is false', async () => {
const params = {
src: 'file-src',
@@ -237,13 +166,20 @@ describe('useRive', () => {
useDevicePixelRatio: false,
};
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => baseRiveMock);
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement('canvas');
const containerSpy = document.createElement('div');
jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100);
jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100);
containerSpy.getBoundingClientRect = jest.fn().mockImplementation(() => ({
width: 100,
height: 100,
}));
const { result } = renderHook(() => useRive(params, opts));
@@ -251,19 +187,13 @@ describe('useRive', () => {
result.current.setCanvasRef(canvasSpy);
result.current.setContainerRef(containerSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
// Height and width should be same as containers bounding rect
expect(canvasSpy).toHaveAttribute('height', '100');
expect(canvasSpy).toHaveAttribute('width', '100');
});
it('uses artboard height to set bounds if fitCanvasToArtboardHeight is true', async () => {
it('uses artbound height to set bounds if fitCanvasToArtboardHeight is true', async () => {
const params = {
src: 'file-src',
};
@@ -273,7 +203,8 @@ describe('useRive', () => {
};
const riveMock = {
...baseRiveMock,
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
bounds: {
maxX: 100,
maxY: 50,
@@ -285,8 +216,10 @@ describe('useRive', () => {
const canvasSpy = document.createElement('canvas');
const containerSpy = document.createElement('div');
jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100);
jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100);
containerSpy.getBoundingClientRect = jest.fn().mockImplementation(() => ({
width: 100,
height: 100,
}));
const { result } = renderHook(() => useRive(params, opts));
@@ -294,12 +227,6 @@ describe('useRive', () => {
result.current.setContainerRef(containerSpy);
result.current.setCanvasRef(canvasSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
// Height and width should be same as containers bounding rect
expect(canvasSpy).toHaveAttribute('height', '50');
@@ -309,6 +236,43 @@ describe('useRive', () => {
expect(containerSpy).toHaveAttribute('style', 'height: 50px;');
});
it('configures a IntersectionObserver on mounting', async () => {
const params = {
src: 'file-src',
};
const observeMock = jest.fn();
const restore = global.IntersectionObserver;
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
observe: observeMock,
}));
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
bounds: {
maxX: 100,
maxY: 50,
},
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement('canvas');
const { result } = renderHook(() => useRive(params));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
expect(observeMock).toBeCalledWith(canvasSpy);
global.IntersectionObserver = restore;
});
it('updates the playing animations when the animations param changes', async () => {
const params = {
src: 'file-src',
@@ -319,7 +283,7 @@ describe('useRive', () => {
const stopMock = jest.fn();
const riveMock = {
...baseRiveMock,
on: (_: string, cb: () => void) => cb(),
stop: stopMock,
play: playMock,
animationNames: ['light'],
@@ -338,12 +302,6 @@ describe('useRive', () => {
await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
rerender({
src: 'file-src',
@@ -365,7 +323,7 @@ describe('useRive', () => {
const stopMock = jest.fn();
const riveMock = {
...baseRiveMock,
on: (_: string, cb: () => void) => cb(),
stop: stopMock,
play: playMock,
pause: pauseMock,
@@ -386,12 +344,6 @@ describe('useRive', () => {
await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
rerender({
src: 'file-src',
@@ -408,8 +360,14 @@ describe('useRive', () => {
src: 'file-src',
};
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
stopRendering: jest.fn(),
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => baseRiveMock);
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement('canvas');
const { result } = renderHook(() => useRive(params));
@@ -417,12 +375,6 @@ describe('useRive', () => {
await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
const { RiveComponent: RiveTestComponent } = result.current;
const { container } = render(
@@ -430,142 +382,4 @@ describe('useRive', () => {
);
expect(container.firstChild).not.toHaveStyle('width: 50%');
});
it('has a canvas size of 0 by default', async () => {
const params = {
src: 'file-src',
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => baseRiveMock);
const canvasSpy = document.createElement('canvas');
const { result } = renderHook(() => useRive(params));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
const { RiveComponent: RiveTestComponent } = result.current;
const { container } = render(<RiveTestComponent />);
expect(container.querySelector('canvas')).toHaveStyle('width: 0');
});
it('sets the canvas width and height after calculating the container size', async () => {
const params = {
src: 'file-src',
};
global.devicePixelRatio = 2;
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => baseRiveMock);
const canvasSpy = document.createElement('canvas');
const containerSpy = document.createElement('div');
jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100);
jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100);
const { result } = renderHook(() => useRive(params));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
result.current.setContainerRef(containerSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
expect(canvasSpy).toHaveStyle('height: 100px');
expect(canvasSpy).toHaveStyle('width: 100px');
});
it('updates the canvas dimensions and size if there is a new canvas size calculation', async () => {
const params = {
src: 'file-src',
};
window.devicePixelRatio = 2;
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => baseRiveMock);
const canvasSpy = document.createElement('canvas');
const containerSpy = document.createElement('div');
jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(100);
jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(100);
const { result } = renderHook(() => useRive(params));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
result.current.setContainerRef(containerSpy);
jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(200);
jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(200);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
await act(async () => {
containerSpy.dispatchEvent(new Event('resize'));
});
expect(canvasSpy).toHaveAttribute('width', '400');
expect(canvasSpy).toHaveAttribute('height', '400');
});
it('prevents resizing if shouldResizeCanvasToContainer option is false', async () => {
const params = {
src: 'file-src',
};
const options = {
shouldResizeCanvasToContainer: false,
};
window.devicePixelRatio = 2;
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => baseRiveMock);
const canvasSpy = document.createElement('canvas');
canvasSpy.width = 200;
canvasSpy.height = 200;
const containerSpy = document.createElement('div');
const { result } = renderHook(() => useRive(params, options));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
result.current.setContainerRef(containerSpy);
});
await waitFor(() => {
expect(result.current.canvas).toBe(canvasSpy);
});
await act(async () => {
controlledRiveloadCb();
});
await act(async () => {
jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(500);
jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(500);
containerSpy.dispatchEvent(new Event('resize'));
});
expect(canvasSpy.width).toBe(200);
expect(canvasSpy.height).toBe(200);
});
});

View File

@@ -1,140 +0,0 @@
import { renderHook } from '@testing-library/react';
import { mocked } from 'jest-mock';
import useRiveFile from '../src/hooks/useRiveFile';
import { RiveFile } from '@rive-app/canvas';
jest.mock('@rive-app/canvas', () => ({
RiveFile: jest.fn().mockImplementation(() => ({
cleanup: jest.fn(),
on: jest.fn(),
init: jest.fn(),
getInstance: jest.fn(),
})),
EventType: {
Load: 'load',
loadError: 'loadError',
},
}));
describe('useRiveFile', () => {
beforeEach(() => {
mocked(RiveFile).mockClear();
});
it('initializes RiveFile with provided parameters', async () => {
const params = {
src: 'file-src',
enableRiveAssetCDN: false,
};
const { result } = renderHook(() => useRiveFile(params));
expect(RiveFile).toHaveBeenCalledWith(params);
expect(result.current.riveFile).toBeDefined();
});
it('cleans up RiveFile on unmount', async () => {
const params = {
src: 'file-src',
enableRiveAssetCDN: false,
};
const { result, unmount } = renderHook(() => useRiveFile(params));
const riveInstance = result.current.riveFile;
expect(riveInstance).toBeDefined();
unmount();
expect(riveInstance?.cleanup).toHaveBeenCalled();
});
it('does not reinitialize RiveFile if src has not changed', async () => {
const params = {
src: 'file-src',
enableRiveAssetCDN: false,
};
const { rerender } = renderHook(() => useRiveFile(params));
rerender();
expect(RiveFile).toHaveBeenCalledTimes(1);
});
it('does not reinitialize RiveFile if buffer has not changed', async () => {
const params = {
buffer: new ArrayBuffer(10),
enableRiveAssetCDN: false,
};
const { rerender } = renderHook(() => useRiveFile(params));
rerender();
expect(RiveFile).toHaveBeenCalledTimes(1);
});
it('reinitializes RiveFile if src changes', async () => {
let params = {
src: 'file-src',
enableRiveAssetCDN: false,
};
const { rerender } = renderHook(() => useRiveFile(params));
params = {
src: 'new-file-src',
enableRiveAssetCDN: false,
};
rerender();
expect(RiveFile).toHaveBeenCalledTimes(2);
});
it('reinitializes RiveFile if buffer changes', async () => {
let params = {
buffer: new ArrayBuffer(10),
enableRiveAssetCDN: false,
};
const { rerender } = renderHook(() => useRiveFile(params));
params = {
buffer: new ArrayBuffer(20),
enableRiveAssetCDN: false,
};
rerender();
expect(RiveFile).toHaveBeenCalledTimes(2);
});
it('handles RiveFile initialization failure gracefully', async () => {
const consoleSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const error = new Error('Initialization failed');
mocked(RiveFile).mockImplementation(() => {
throw error;
});
const params = {
src: 'file-src',
enableRiveAssetCDN: false,
};
const { result, rerender } = renderHook(() => useRiveFile(params));
rerender();
expect(result.current.status).toBe('failed');
expect(result.current.riveFile).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(error);
consoleSpy.mockRestore();
});
});

View File

@@ -1,14 +1,13 @@
import { mocked } from 'jest-mock';
import { renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import useStateMachineInput from '../src/hooks/useStateMachineInput';
import { Rive, StateMachineInput } from '@rive-app/canvas';
import {Rive, StateMachineInput} from '@rive-app/canvas';
jest.mock('@rive-app/canvas', () => ({
Rive: jest.fn().mockImplementation(() => ({
on: jest.fn(),
stop: jest.fn(),
stateMachineInputs: jest.fn(),
})),
Layout: jest.fn(),
Fit: {
@@ -27,21 +26,6 @@ jest.mock('@rive-app/canvas', () => ({
},
}));
function getRiveMock({
smiInputs,
}: {
smiInputs?: null | StateMachineInput[];
} = {}) {
const riveMock = new Rive({
canvas: undefined as unknown as HTMLCanvasElement,
});
if (smiInputs) {
riveMock.stateMachineInputs = jest.fn().mockReturnValue(smiInputs);
}
return riveMock;
}
describe('useStateMachineInput', () => {
it('returns null if there is null rive object passed', () => {
const { result } = renderHook(() => useStateMachineInput(null));
@@ -49,33 +33,29 @@ describe('useStateMachineInput', () => {
});
it('returns null if there is no state machine name', () => {
const riveMock = getRiveMock();
const riveMock = {};
mocked(Rive).mockImplementation(() => riveMock as Rive);
mocked(Rive).mockImplementation(() => riveMock);
const { result } = renderHook(() =>
useStateMachineInput(riveMock, '', 'testInput')
);
const { result } = renderHook(() => useStateMachineInput(riveMock as Rive, '', 'testInput'));
expect(result.current).toBeNull();
});
it('returns null if there is no state machine input name', () => {
const riveMock = getRiveMock();
const riveMock = {};
mocked(Rive).mockImplementation(() => riveMock as Rive);
const { result } = renderHook(() =>
useStateMachineInput(riveMock, 'smName', '')
);
const { result } = renderHook(() => useStateMachineInput(riveMock as Rive, 'smName', ''));
expect(result.current).toBeNull();
});
it('returns null if there are no inputs for the state machine', () => {
const riveMock = getRiveMock({ smiInputs: [] });
const riveMock = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
stateMachineInputs: (_: string) => [] as StateMachineInput[],
};
mocked(Rive).mockImplementation(() => riveMock as Rive);
mocked(Rive).mockImplementation(() => riveMock);
const { result } = renderHook(() =>
useStateMachineInput(riveMock as Rive, 'smName', '')
);
const { result } = renderHook(() => useStateMachineInput(riveMock as Rive, 'smName', ''));
expect(result.current).toBeNull();
});
@@ -83,13 +63,13 @@ describe('useStateMachineInput', () => {
const smInput = {
name: 'boolInput',
} as StateMachineInput;
const riveMock = getRiveMock({ smiInputs: [smInput] });
const riveMock = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
stateMachineInputs: (_: string) => [smInput] as StateMachineInput[],
};
mocked(Rive).mockImplementation(() => riveMock as Rive);
mocked(Rive).mockImplementation(() => riveMock);
const { result } = renderHook(() =>
useStateMachineInput(riveMock, 'smName', 'numInput')
);
const { result } = renderHook(() => useStateMachineInput(riveMock as Rive, 'smName', 'numInput'));
expect(result.current).toBeNull();
});
@@ -97,13 +77,13 @@ describe('useStateMachineInput', () => {
const smInput = {
name: 'boolInput',
} as StateMachineInput;
const riveMock = getRiveMock({ smiInputs: [smInput] });
const riveMock = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
stateMachineInputs: (_: string) => [smInput] as StateMachineInput[],
};
mocked(Rive).mockImplementation(() => riveMock as Rive);
mocked(Rive).mockImplementation(() => riveMock);
const { result } = renderHook(() =>
useStateMachineInput(riveMock, 'smName', 'boolInput')
);
const { result } = renderHook(() => useStateMachineInput(riveMock as Rive, 'smName', 'boolInput'));
expect(result.current).toBe(smInput);
});
@@ -112,12 +92,13 @@ describe('useStateMachineInput', () => {
name: 'boolInput',
value: false,
} as StateMachineInput;
const riveMock = getRiveMock({ smiInputs: [smInput] });
mocked(Rive).mockImplementation(() => riveMock);
const riveMock = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
stateMachineInputs: (_: string) => [smInput] as StateMachineInput[],
};
mocked(Rive).mockImplementation(() => riveMock as Rive);
const { result } = renderHook(() =>
useStateMachineInput(riveMock, 'smName', 'boolInput', true)
);
const { result } = renderHook(() => useStateMachineInput(riveMock as Rive, 'smName', 'boolInput', true));
expect(result.current).toStrictEqual({
...smInput,
value: true,