diff --git a/src/components/Rive.tsx b/src/components/Rive.tsx index 2b28797..d6408ab 100644 --- a/src/components/Rive.tsx +++ b/src/components/Rive.tsx @@ -32,6 +32,10 @@ export interface RiveProps { * 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; } const Rive = ({ @@ -42,6 +46,7 @@ const Rive = ({ layout, useOffscreenRenderer = true, shouldDisableRiveListeners = false, + shouldResizeCanvasToContainer = true, children, ...rest }: RiveProps & ComponentProps<'canvas'>) => { @@ -57,6 +62,7 @@ const Rive = ({ const options = { useOffscreenRenderer, + shouldResizeCanvasToContainer, }; const { RiveComponent } = useRive(params, options); diff --git a/src/hooks/useContainerSize.ts b/src/hooks/useContainerSize.ts new file mode 100644 index 0000000..41fda6f --- /dev/null +++ b/src/hooks/useContainerSize.ts @@ -0,0 +1,94 @@ +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, + shouldResizeCanvasToContainer = true +) { + const [size, setSize] = useState({ + 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; +} diff --git a/src/hooks/useDevicePixelRatio.ts b/src/hooks/useDevicePixelRatio.ts new file mode 100644 index 0000000..1a96f4b --- /dev/null +++ b/src/hooks/useDevicePixelRatio.ts @@ -0,0 +1,49 @@ +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 + * + * @returns dpr: Number - Device pixel ratio; ratio of physical px to resolution in CSS pixels for current device + */ +export default function useDevicePixelRatio() { + const dpr = getDevicePixelRatio(); + const [currentDpr, setCurrentDpr] = useState(dpr); + + useEffect(() => { + const canListen = typeof window !== 'undefined' && 'matchMedia' in window; + if (!canListen) { + return; + } + + const updateDpr = () => { + const newDpr = 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]); + + 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); +} diff --git a/src/hooks/useResizeCanvas.ts b/src/hooks/useResizeCanvas.ts new file mode 100644 index 0000000..60fa25b --- /dev/null +++ b/src/hooks/useResizeCanvas.ts @@ -0,0 +1,153 @@ +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 { + riveLoaded: boolean; + canvasRef: MutableRefObject; + containerRef: MutableRefObject; + onCanvasHasResized?: () => void; + options?: Partial; + artboardBounds?: Bounds; +} + +/** + * Helper hook to listen for changes in the parent container size and size the + * 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 + */ +export default function useResizeCanvas({ + riveLoaded = false, + canvasRef, + containerRef, + options = {}, + onCanvasHasResized, + artboardBounds, +}: UseResizeCanvasProps) { + const presetOptions = getOptions(options); + const [ + { height: lastContainerHeight, width: lastContainerWidth }, + setLastContainerDimensions, + ] = useState({ + height: 0, + width: 0, + }); + const [ + { height: lastCanvasHeight, width: lastCanvasWidth }, + setLastCanvasSize, + ] = useState({ + height: 0, + width: 0, + }); + + const [isFirstSizing, setIsFirstSizing] = useState(true); + + const { + fitCanvasToArtboardHeight, + shouldResizeCanvasToContainer, + useDevicePixelRatio: shouldUseDevicePixelRatio, + } = presetOptions; + + const containerSize = useContainerSize( + containerRef, + shouldResizeCanvasToContainer + ); + const currentDevicePixelRatio = useDevicePixelRatio(); + + const getCanvasDimensions = 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, artboardBounds]); + + useEffect(() => { + if ( + !shouldResizeCanvasToContainer || + !containerRef.current || + !riveLoaded + ) { + return; + } + + const { width, height } = getCanvasDimensions(); + let hasResized = false; + if (canvasRef.current) { + // 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; + canvasRef.current.width = newCanvasWidthProp; + canvasRef.current.height = newCanvasHeightProp; + canvasRef.current.style.width = width + 'px'; + canvasRef.current.style.height = height + 'px'; + setLastCanvasSize({ + width: newCanvasWidthProp, + height: newCanvasHeightProp, + }); + hasResized = true; + } + } else if (boundsChanged) { + canvasRef.current.width = width; + canvasRef.current.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(); + } + setIsFirstSizing(false); + }, [ + canvasRef, + containerRef, + containerSize, + currentDevicePixelRatio, + getCanvasDimensions, + isFirstSizing, + setIsFirstSizing, + lastCanvasHeight, + lastCanvasWidth, + lastContainerHeight, + lastContainerWidth, + onCanvasHasResized, + shouldResizeCanvasToContainer, + fitCanvasToArtboardHeight, + shouldUseDevicePixelRatio, + riveLoaded, + ]); +} diff --git a/src/hooks/useRive.tsx b/src/hooks/useRive.tsx index 42426db..42e0b82 100644 --- a/src/hooks/useRive.tsx +++ b/src/hooks/useRive.tsx @@ -7,13 +7,9 @@ import React, { RefCallback, } from 'react'; import { Rive, EventType } from '@rive-app/canvas'; -import { - UseRiveParameters, - UseRiveOptions, - RiveState, - Dimensions, -} from '../types'; -import { useSize, useDevicePixelRatio } from '../utils'; +import { UseRiveParameters, UseRiveOptions, RiveState } from '../types'; +import useResizeCanvas from './useResizeCanvas'; +import { getOptions } from '../utils'; type RiveComponentProps = { setContainerRef: RefCallback; @@ -51,22 +47,6 @@ function RiveComponent({ ); } -const defaultOptions = { - useDevicePixelRatio: true, - fitCanvasToArtboardHeight: false, - useOffscreenRenderer: true, -}; - -/** - * Returns options, with defaults set. - * - * @param opts - * @returns - */ -function getOptions(opts: Partial) { - return Object.assign({}, defaultOptions, opts); -} - /** * Custom Hook for loading a Rive file. * @@ -89,117 +69,30 @@ export default function useRive( const containerRef = useRef(null); const [rive, setRive] = useState(null); - const [lastContainerDimensions, setLastContainerDimensions] = - useState({ - height: 0, - width: 0, - }); - const [lastCanvasSize, setLastCanvasSize] = useState({ - height: 0, - width: 0, - }); - - // Listen to changes in the window sizes and update the bounds when changes - // occur. - const size = useSize(containerRef); - const currentDevicePixelRatio = useDevicePixelRatio(); const isParamsLoaded = Boolean(riveParams); const options = getOptions(opts); /** - * 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() { - // getBoundingClientRect returns the scaled width and height - // this will result in double scaling - // https://developer.mozilla.org/en-US/docs/Web/API/CSS_Object_Model/Determining_the_dimensions_of_elements - - const width = containerRef.current?.clientWidth ?? 0; - const height = containerRef.current?.clientHeight ?? 0; - - if (rive && options.fitCanvasToArtboardHeight) { - const { maxY, maxX } = rive.bounds; - return { width, height: width * (maxY / maxX) }; - } - return { width, height }; - } - - /** - * Updates the width and height of the canvas. + * 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 */ - function updateBounds() { - if (!containerRef.current) { - return; - } - - const { width, height } = getCanvasDimensions(); - if (canvasRef.current && rive) { - // Check if the canvas parent container bounds have changed and set - // new values accordingly - const boundsChanged = - width !== lastContainerDimensions.width || - height !== lastContainerDimensions.height; - if (options.fitCanvasToArtboardHeight && boundsChanged) { - containerRef.current.style.height = height + 'px'; - } - if (options.useDevicePixelRatio) { - // Check if devicePixelRatio may have changed and get new canvas - // width/height values to set the size - const canvasSizeChanged = - width * currentDevicePixelRatio !== lastCanvasSize.width || - height * currentDevicePixelRatio !== lastCanvasSize.height; - if (boundsChanged || canvasSizeChanged) { - const newCanvasWidthProp = currentDevicePixelRatio * width; - const newCanvasHeightProp = currentDevicePixelRatio * height; - canvasRef.current.width = newCanvasWidthProp; - canvasRef.current.height = newCanvasHeightProp; - canvasRef.current.style.width = width + 'px'; - canvasRef.current.style.height = height + 'px'; - setLastCanvasSize({ - width: newCanvasWidthProp, - height: newCanvasHeightProp, - }); - } - } else if (boundsChanged) { - canvasRef.current.width = width; - canvasRef.current.height = height; - setLastCanvasSize({ - width: width, - height: height, - }); - } - setLastContainerDimensions({ 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 + const onCanvasHasResized = useCallback(() => { if (rive) { + rive.startRendering(); rive.resizeToCanvas(); } - } + }, [rive]); - /** - * Listen to changes on the windowSize and the rive file being loaded - * and update the canvas bounds as needed. - * - * ie does not support ResizeObservers, so we fallback to the window listener there - */ - useEffect(() => { - if (rive) { - updateBounds(); - } - }, [rive, size, currentDevicePixelRatio]); + // Watch the canvas parent container resize and size the canvas to match + useResizeCanvas({ + riveLoaded: !!rive, + canvasRef, + containerRef, + options, + onCanvasHasResized, + artboardBounds: rive?.bounds, + }); /** * Ref callback called when the canvas element mounts and unmounts. @@ -301,7 +194,7 @@ export default function useRive( /> ); }, - [] + [setCanvasRef, setContainerRef] ); return { diff --git a/src/index.ts b/src/index.ts index 28b2951..61b7374 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,9 @@ import Rive from './components/Rive'; import useRive from './hooks/useRive'; import useStateMachineInput from './hooks/useStateMachineInput'; +import useResizeCanvas from './hooks/useResizeCanvas'; export default Rive; -export { useRive, useStateMachineInput }; +export { useRive, useStateMachineInput, useResizeCanvas }; export { RiveState, UseRiveParameters, UseRiveOptions } from './types'; export * from '@rive-app/canvas'; diff --git a/src/types.ts b/src/types.ts index bec2477..0f69171 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ export type UseRiveOptions = { useDevicePixelRatio: boolean; fitCanvasToArtboardHeight: boolean; useOffscreenRenderer: boolean; + shouldResizeCanvasToContainer: boolean; }; export type Dimensions = { diff --git a/src/utils.ts b/src/utils.ts index 698845e..3f52adb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,129 +1,12 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Dimensions } from './types'; +import { UseRiveOptions } 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; - -export function useSize( - containerRef: React.MutableRefObject -) { - const [size, setSize] = useState({ - width: 0, - height: 0, - }); - - // internet explorer does not support ResizeObservers. - useEffect(() => { - if (typeof window !== 'undefined') { - 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 current = observer.current; - if (containerRef.current && useResizeObserver) { - current.observe(containerRef.current); - } - - return () => { - current.disconnect(); - if (containerRef.current && useResizeObserver) { - current.unobserve(containerRef.current); - } - }; - }, [containerRef, observer]); - - return size; -} - -/** - * 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 - * - * @returns dpr: Number - Device pixel ratio; ratio of physical px to resolution in CSS pixels for current device - */ -export function useDevicePixelRatio() { - const dpr = getDevicePixelRatio(); - const [currentDpr, setCurrentDpr] = useState(dpr); - - useEffect(() => { - const canListen = typeof window !== 'undefined' && 'matchMedia' in window; - if (!canListen) { - return; - } - - const updateDpr = () => { - const newDpr = 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]); - - return currentDpr; -} +const defaultOptions = { + useDevicePixelRatio: true, + fitCanvasToArtboardHeight: false, + useOffscreenRenderer: true, + shouldResizeCanvasToContainer: true, +}; -export 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); +export function getOptions(opts: Partial) { + return Object.assign({}, defaultOptions, opts); } diff --git a/test/useRive.test.tsx b/test/useRive.test.tsx index 926f1a3..f930e97 100644 --- a/test/useRive.test.tsx +++ b/test/useRive.test.tsx @@ -94,6 +94,9 @@ describe('useRive', () => { result.current.setCanvasRef(canvasSpy); result.current.setContainerRef(containerSpy); controlledRiveloadCb(); + 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); @@ -192,7 +195,7 @@ describe('useRive', () => { expect(canvasSpy).toHaveAttribute('width', '100'); }); - it('uses artbound height to set bounds if fitCanvasToArtboardHeight is true', async () => { + it('uses artboard height to set bounds if fitCanvasToArtboardHeight is true', async () => { const params = { src: 'file-src', }; @@ -446,9 +449,45 @@ describe('useRive', () => { result.current.setCanvasRef(canvasSpy); result.current.setContainerRef(containerSpy); controlledRiveloadCb(); + jest.spyOn(containerSpy, 'clientWidth', 'get').mockReturnValue(200); + jest.spyOn(containerSpy, 'clientHeight', 'get').mockReturnValue(200); + containerSpy.dispatchEvent(new Event('resize')); }); - expect(canvasSpy).toHaveAttribute('width', '200'); - expect(canvasSpy).toHaveAttribute('height', '200'); + 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); + controlledRiveloadCb(); + 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); }); });