Compare commits

...

34 Commits

Author SHA1 Message Date
Adam
dab7a8d6f0 Merge branch 'main' into feature/initial-databinding 2025-05-05 18:21:14 -07:00
Adam
d2ccee1220 feat: add db test 2025-05-05 17:32:36 -07:00
Lance
5bf53fc238 Add onTrigger example 2025-05-05 15:14:01 -06:00
Lance
e49b9ae606 Add stocks data binding example 2025-05-01 23:10:49 -06:00
Lance
fc78d38a0b Merge storybook 2025-05-01 22:27:46 -06:00
lancesnider
04397e658c chore: release 4.19.0 2025-05-01 22:27:46 -06:00
Lance
52519885c6 Fix failing tests 2025-05-01 22:27:46 -06:00
Lance
2ad2df085b Upgrade testing 2025-05-01 22:27:46 -06:00
Lance
c9308db9dc Upgrade React and React DOM 2025-05-01 22:27:46 -06:00
Lance
082b9a6323 Update contributing docs 2025-05-01 22:27:46 -06:00
Lance
6aaee36ff0 Add storybook linter 2025-05-01 22:27:46 -06:00
Lance
b7581e5b25 Call storybook from parent 2025-05-01 22:27:46 -06:00
Lance
4879b95245 General cleanup 2025-05-01 22:27:46 -06:00
Lance
4407dec4fc Remove unnecessary react app stuff 2025-05-01 22:27:46 -06:00
Lance
b73c3f58b2 Storybookk page reloads when the package in the parent changes 2025-05-01 22:27:46 -06:00
Lance
cee7ca5a43 Add examples 2025-05-01 22:27:46 -06:00
Lance
cf1d7c4c1c Remove old storybook and auto docs 2025-05-01 22:27:46 -06:00
philter
c414f143fd chore: release 4.18.9 2025-05-01 22:27:46 -06:00
Phil Chung
1f8afe4635 chore: bump rive web to 2.27.1 2025-05-01 22:27:46 -06:00
bodymovin
469e9073ce chore: release 4.18.8 2025-05-01 22:27:46 -06:00
Hernan Torrisi
43290d1cfd rive canvas 2.27.0 2025-05-01 22:27:46 -06:00
Adam
37763e11c7 fix: use default view model if none provide in useViewModelInstance and useViewModel 2025-05-01 15:58:37 -07:00
Adam
384b62c1b7 fix: unify effects in useViewModelInstance 2025-04-29 17:18:34 -07:00
Adam
d9e61373b3 fix: change useViewModel property hook parameters to match conventions 2025-04-29 17:07:17 -07:00
Adam
66f1ae021c chore: simplify useViewModel 2025-04-29 16:30:09 -07:00
Adam
1fc4fa44e2 fix: avoid rebuilding operations unless necessary 2025-04-28 18:04:19 -07:00
Adam
3866cb9e06 fix: hot reload crash 2025-04-24 15:33:07 -07:00
Adam
bef03fc403 chore: update UseViewModelParameters documentation 2025-04-15 16:46:54 -07:00
Adam
c4da27c7b6 fix: remove viewmodel hooks from initial release 2025-04-15 15:18:38 -07:00
Adam
c2ec32350d chore: update color setter methods to be more explicit 2025-04-15 12:07:27 -07:00
Adam
ead1d8e0e3 fix: lint issue 2025-04-14 14:48:15 -07:00
Adam
26cb0b5dec feat: add instance property hooks 2025-04-13 12:38:27 -07:00
Hernan Torrisi
0e40840e6f hook with generic 2025-04-11 10:19:47 -07:00
Hernan Torrisi
480037c399 inital work for data binding hooks 2025-04-11 10:19:47 -07:00
19 changed files with 4706 additions and 1440 deletions

View File

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

View File

Binary file not shown.

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

Binary file not shown.

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,3 +57,130 @@ 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;
};