diff --git a/examples/public/font_db_test.riv b/examples/public/font_db_test.riv new file mode 100644 index 0000000..60f8c19 Binary files /dev/null and b/examples/public/font_db_test.riv differ diff --git a/examples/src/components/DataBindingTests.stories.tsx b/examples/src/components/DataBindingTests.stories.tsx index f327c04..e9232ba 100644 --- a/examples/src/components/DataBindingTests.stories.tsx +++ b/examples/src/components/DataBindingTests.stories.tsx @@ -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', @@ -387,6 +387,44 @@ export const ImagePropertyStory: StoryObj = { } }; +export const FontPropertyStory: StoryObj = { + name: 'Font Property', + render: () => , + 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', diff --git a/examples/src/components/DataBindingTests.tsx b/examples/src/components/DataBindingTests.tsx index 87e68da..144205c 100644 --- a/examples/src/components/DataBindingTests.tsx +++ b/examples/src/components/DataBindingTests.tsx @@ -11,7 +11,9 @@ import Rive, { useViewModelInstanceColor, useViewModelInstanceTrigger, useViewModelInstanceImage, + useViewModelInstanceFont, decodeImage, + decodeFont, ViewModelInstance, useViewModelInstanceList, useViewModelInstanceArtboard @@ -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(''); + const [isLoading, setIsLoading] = useState(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 ( +
+
+ +
+ + {rive === null ? ( +
Loading…
+ ) : ( +
+ {FONT_OPTIONS.map((font) => ( + + ))} + + +
+ )} + + {currentFont && ( +
+ Current font: {currentFont} +
+ )} +
+ ); +}; + // List Property Test const TodoItemComponent = ({ diff --git a/src/hooks/useViewModelInstanceFont.ts b/src/hooks/useViewModelInstanceFont.ts new file mode 100644 index 0000000..860ec3c --- /dev/null +++ b/src/hooks/useViewModelInstanceFont.ts @@ -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( + 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 + }; +} diff --git a/src/hooks/useViewModelInstanceImage.ts b/src/hooks/useViewModelInstanceImage.ts index 43d6284..2f20179 100644 --- a/src/hooks/useViewModelInstanceImage.ts +++ b/src/hooks/useViewModelInstanceImage.ts @@ -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, diff --git a/src/index.ts b/src/index.ts index 3ea3b50..0fa232b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -32,6 +33,7 @@ export { useViewModelInstanceEnum, useViewModelInstanceTrigger, useViewModelInstanceImage, + useViewModelInstanceFont, useViewModelInstanceList, useViewModelInstanceArtboard, RiveProps, diff --git a/src/types.ts b/src/types.ts index fe6d260..aff213f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { + decodeFont, decodeImage, Rive, RiveFile, @@ -213,6 +214,16 @@ export type UseViewModelInstanceImageResult = { setValue: (value: RiveRenderImage | null) => void; }; +export type RiveDecodedFont = Awaited>; + +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. diff --git a/test/useViewModelInstanceFont.test.tsx b/test/useViewModelInstanceFont.test.tsx new file mode 100644 index 0000000..6cf720e --- /dev/null +++ b/test/useViewModelInstanceFont.test.tsx @@ -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) { + 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(); + }); +});