Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added examples/public/font_db_test.riv
Binary file not shown.
40 changes: 39 additions & 1 deletion examples/src/components/DataBindingTests.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { within, expect, waitFor, userEvent } from '@storybook/test';

import { StringPropertyTest, NumberPropertyTest, BooleanPropertyTest, ColorPropertyTest, EnumPropertyTest, NestedViewModelTest, TriggerPropertyTest, PersonForm, PersonInstances, ImagePropertyTest, TodoListTest, ArtboardPropertyTest } from './DataBindingTests';
import { StringPropertyTest, NumberPropertyTest, BooleanPropertyTest, ColorPropertyTest, EnumPropertyTest, NestedViewModelTest, TriggerPropertyTest, PersonForm, PersonInstances, ImagePropertyTest, FontPropertyTest, TodoListTest, ArtboardPropertyTest } from './DataBindingTests';

const meta: Meta = {
title: 'Tests/DataBinding',
Expand Down Expand Up @@ -387,6 +387,44 @@ export const ImagePropertyStory: StoryObj = {
}
};

export const FontPropertyStory: StoryObj = {
name: 'Font Property',
render: () => <FontPropertyTest src="font_db_test.riv" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

await waitFor(() => {
expect(canvas.getByTestId('set-font-noto-thai')).toBeTruthy();
expect(canvas.getByTestId('set-font-noto-arabic')).toBeTruthy();
expect(canvas.getByTestId('clear-font')).toBeTruthy();
}, { timeout: 3000 });

expect(canvas.queryByTestId('current-font')).toBeNull();

await userEvent.click(canvas.getByTestId('set-font-noto-arabic'));

await waitFor(() => {
expect(canvas.getByTestId('current-font').textContent).toBe(
'Current font: Noto Sans Arabic'
);
}, { timeout: 5000 });

await userEvent.click(canvas.getByTestId('clear-font'));

await waitFor(() => {
expect(canvas.queryByTestId('current-font')).toBeNull();
}, { timeout: 2000 });

await userEvent.click(canvas.getByTestId('set-font-noto-arabic'));

await waitFor(() => {
expect(canvas.getByTestId('current-font').textContent).toBe(
'Current font: Noto Sans Arabic'
);
}, { timeout: 5000 });
}
};


export const TodoListStory: StoryObj = {
name: 'Todo List Property',
Expand Down
98 changes: 98 additions & 0 deletions examples/src/components/DataBindingTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import Rive, {
useViewModelInstanceColor,
useViewModelInstanceTrigger,
useViewModelInstanceImage,
useViewModelInstanceFont,
decodeImage,
decodeFont,
ViewModelInstance,
useViewModelInstanceList,
useViewModelInstanceArtboard
Expand Down Expand Up @@ -615,6 +617,102 @@ export const ImagePropertyTest = ({ src }: { src: string }) => {
);
};

const FONT_OPTIONS = [
{
name: 'Noto Serif Thai',
url: 'https://raw.githubusercontent.com/google/fonts/main/ofl/notoserifthai/NotoSerifThai%5Bwdth%2Cwght%5D.ttf',
testId: 'set-font-noto-thai',
},
{
name: 'Noto Sans Arabic',
url: './NotoSansArabic-VariableFont_wdth,wght.ttf',
testId: 'set-font-noto-arabic',
},
] as const;

export const FontPropertyTest = ({ src }: { src: string }) => {
const [currentFont, setCurrentFont] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);

const { rive, RiveComponent } = useRive({
src,
stateMachines: 'State Machine 1',
autoplay: true,
autoBind: true,
});

const { setValue: setFont } = useViewModelInstanceFont(
'fontProperty',
rive?.viewModelInstance
);

const loadFont = async (name: string, url: string) => {
if (!setFont) return;

setIsLoading(true);
try {
const response = await fetch(url);
const fontBuffer = await response.arrayBuffer();
const decodedFont = await decodeFont(new Uint8Array(fontBuffer));

setFont(decodedFont);
setCurrentFont(name);

decodedFont.unref();
} catch (error) {
console.error('Failed to load font:', error);
} finally {
setIsLoading(false);
}
};

const clearFont = () => {
if (setFont) {
setFont(null);
setCurrentFont('');
}
};

return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '20px' }}>
<div style={{ width: '400px', height: '400px', border: '1px solid #ccc' }}>
<RiveComponent />
</div>

{rive === null ? (
<div data-testid="loading-text">Loading…</div>
) : (
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
{FONT_OPTIONS.map((font) => (
<button
key={font.name}
onClick={() => loadFont(font.name, font.url)}
disabled={isLoading}
data-testid={font.testId}
>
{isLoading ? 'Loading...' : `Set ${font.name}`}
</button>
))}

<button
onClick={clearFont}
disabled={isLoading}
data-testid="clear-font"
>
Clear Font
</button>
</div>
)}

{currentFont && (
<div style={{ fontSize: '12px', color: '#666' }}>
<span data-testid="current-font">Current font: {currentFont}</span>
</div>
)}
</div>
);
};

// List Property Test

const TodoItemComponent = ({
Expand Down
38 changes: 38 additions & 0 deletions src/hooks/useViewModelInstanceFont.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { ViewModelInstance, ViewModelInstanceAssetFont } from '@rive-app/canvas';
import { UseViewModelInstanceFontResult, RiveDecodedFont } from '../types';
import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';

/**
* Hook for interacting with font properties of a ViewModelInstance.
*
* @param path - Path to the font property (e.g. "boundFont" or "group/titleFont")
* @param viewModelInstance - The ViewModelInstance containing the font property
* @returns An object with a setter function to set a new font value
*/
export default function useViewModelInstanceFont(
path: string,
viewModelInstance?: ViewModelInstance | null
): UseViewModelInstanceFontResult {
const result = useViewModelInstanceProperty<ViewModelInstanceAssetFont, undefined, UseViewModelInstanceFontResult>(
path,
viewModelInstance,
{
getProperty: useCallback((vm, p) => vm.font(p), []),
getValue: useCallback(() => undefined, []),
defaultValue: null,
buildPropertyOperations: useCallback((safePropertyAccess) => ({
setValue: (newValue: RiveDecodedFont | null) => {
safePropertyAccess(prop => {
// TODO: Can remove the type assertion once JS has value setter with FontWrapper
prop.value = newValue as unknown as typeof prop.value;
});
}
}), [])
}
);

return {
setValue: result.setValue
};
}
2 changes: 1 addition & 1 deletion src/hooks/useViewModelInstanceImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useViewModelInstanceProperty } from './useViewModelInstanceProperty';
*
* @param path - Path to the image property (e.g. "profileImage" or "group/avatar")
* @param viewModelInstance - The ViewModelInstance containing the image property
* @returns An object with a setter function
* @returns An object with a setter function to set a new image value
*/
export default function useViewModelInstanceImage(
path: string,
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import useViewModelInstanceColor from './hooks/useViewModelInstanceColor';
import useViewModelInstanceEnum from './hooks/useViewModelInstanceEnum';
import useViewModelInstanceTrigger from './hooks/useViewModelInstanceTrigger';
import useViewModelInstanceImage from './hooks/useViewModelInstanceImage';
import useViewModelInstanceFont from './hooks/useViewModelInstanceFont';
import useViewModelInstanceList from './hooks/useViewModelInstanceList';
import useResizeCanvas from './hooks/useResizeCanvas';
import useRiveFile from './hooks/useRiveFile';
Expand All @@ -32,6 +33,7 @@ export {
useViewModelInstanceEnum,
useViewModelInstanceTrigger,
useViewModelInstanceImage,
useViewModelInstanceFont,
useViewModelInstanceList,
useViewModelInstanceArtboard,
RiveProps,
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
decodeFont,
decodeImage,
Rive,
RiveFile,
Expand Down Expand Up @@ -213,6 +214,16 @@ export type UseViewModelInstanceImageResult = {
setValue: (value: RiveRenderImage | null) => void;
};

export type RiveDecodedFont = Awaited<ReturnType<typeof decodeFont>>;

export type UseViewModelInstanceFontResult = {
/**
* Set the value of the font.
* @param value - The decoded font to set (from `decodeFont`), or null to clear.
*/
setValue: (value: RiveDecodedFont | null) => void;
};

export type UseViewModelInstanceListResult = {
/**
* The current length of the list.
Expand Down
112 changes: 112 additions & 0 deletions test/useViewModelInstanceFont.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { act, renderHook } from '@testing-library/react';

import useViewModelInstanceFont from '../src/hooks/useViewModelInstanceFont';

jest.mock('@rive-app/canvas', () => ({}));

function makeFontProperty() {
let value: unknown = undefined;
return {
on: jest.fn(),
off: jest.fn(),
set value(next: unknown) {
value = next;
},
get value() {
return value;
},
};
}

function makeViewModelInstance(fontProperty: ReturnType<typeof makeFontProperty>) {
return {
font: jest.fn(() => fontProperty),
} as any;
}

beforeEach(() => jest.clearAllMocks());

describe('useViewModelInstanceFont', () => {
it('looks up the font property by path and exposes setValue', () => {
const fontProperty = makeFontProperty();
const viewModelInstance = makeViewModelInstance(fontProperty);

const { result } = renderHook(() =>
useViewModelInstanceFont('fontProperty', viewModelInstance)
);

expect(viewModelInstance.font).toHaveBeenCalledWith('fontProperty');
expect(typeof result.current.setValue).toBe('function');
});

it('sets the decoded font on the property', () => {
const fontProperty = makeFontProperty();
const viewModelInstance = makeViewModelInstance(fontProperty);
const decodedFont = { nativeFont: {}, unref: jest.fn() };

const { result } = renderHook(() =>
useViewModelInstanceFont('titleFont', viewModelInstance)
);

act(() => {
result.current.setValue(decodedFont as any);
});

expect(fontProperty.value).toBe(decodedFont);
});

it('clears the font when setValue is called with null', () => {
const fontProperty = makeFontProperty();
const viewModelInstance = makeViewModelInstance(fontProperty);
const decodedFont = { nativeFont: {}, unref: jest.fn() };

const { result } = renderHook(() =>
useViewModelInstanceFont('fontProperty', viewModelInstance)
);

act(() => {
result.current.setValue(decodedFont as any);
result.current.setValue(null);
});

expect(fontProperty.value).toBeNull();
});

it('supports nested property paths', () => {
const fontProperty = makeFontProperty();
const viewModelInstance = makeViewModelInstance(fontProperty);

renderHook(() =>
useViewModelInstanceFont('group/titleFont', viewModelInstance)
);

expect(viewModelInstance.font).toHaveBeenCalledWith('group/titleFont');
});

it('does not throw when setValue is called without a view model instance', () => {
const { result } = renderHook(() =>
useViewModelInstanceFont('fontProperty', null)
);

expect(() => {
act(() => {
result.current.setValue(null);
});
}).not.toThrow();
});

it('subscribes to property changes and cleans up on unmount', () => {
const fontProperty = makeFontProperty();
const viewModelInstance = makeViewModelInstance(fontProperty);

const { unmount } = renderHook(() =>
useViewModelInstanceFont('fontProperty', viewModelInstance)
);

expect(fontProperty.on).toHaveBeenCalled();

unmount();

expect(fontProperty.off).toHaveBeenCalled();
});
});
Loading