Initial commit of existing implementation

This commit is contained in:
Arthur Vivian
2021-06-04 16:43:13 +01:00
parent 098894bfdd
commit 3b1d7593fe
14 changed files with 5504 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.DS_Store
.env
.idea
.vscode

11
jest.config.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
preset: "ts-jest",
testRegex: "/test/.*\\.test\\.tsx$",
setupFilesAfterEnv: ["<rootDir>/setupTests.ts"],
testEnvironment: "jsdom",
globals: {
"ts-jest": {
tsconfig: "tsconfig.test.json",
},
},
};

4734
package-lock.json generated Normal file
View File

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "rive-react",
"version": "0.0.1",
"description": "React wrapper around the rive-js library",
"main": "dist/index.js",
"typings": "dist/types/index.d.ts",
"scripts": {
"test": "jest",
"build": "bunchee src/index.ts -m --no-sourcemap"
},
"repository": {
"type": "git",
"url": "git+https://github.com/rive-app/rive-react.git"
},
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/rive-app/rive-react/issues"
},
"homepage": "https://github.com/rive-app/rive-react#readme",
"dependencies": {
"react": "^17.0.2",
"react-dom": "^17.0.2",
"rive-js": "^0.7.14"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.13.0",
"@testing-library/react": "^11.2.7",
"@testing-library/react-hooks": "^7.0.0",
"@types/jest": "^26.0.23",
"@types/react": "^17.0.9",
"@types/testing-library__jest-dom": "^5.9.5",
"bunchee": "^1.6.0",
"jest": "^27.0.4",
"ts-jest": "^27.0.2"
}
}

47
setupTests.ts Normal file
View File

@@ -0,0 +1,47 @@
import "@testing-library/jest-dom";
window.IntersectionObserver = class IntersectionObserver {
readonly root: Element | null;
readonly rootMargin: string;
readonly thresholds: ReadonlyArray<number>;
constructor() {
this.root = null;
this.rootMargin = "";
this.thresholds = [];
}
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
unobserve() {}
};
jest.mock("rive-js", () => ({
Rive: jest.fn().mockImplementation(() => ({
on: jest.fn(),
stop: jest.fn(),
})),
Layout: jest.fn(),
Fit: {
Cover: "cover",
},
Alignment: {
Center: "center",
},
EventType: {
Load: "load",
},
StateMachineInputType: {
Number: 1,
Boolean: 2,
Trigger: 3,
},
}));

34
src/components/Rive.tsx Normal file
View File

@@ -0,0 +1,34 @@
import React from "react";
import { Layout } from "rive-js";
import { ComponentProps } from "react";
import useRive from "../hooks/useRive";
export type RiveProps = {
src: string;
artboard?: string;
animations?: string;
layout?: Layout;
autoplay?: boolean;
};
const Rive = ({
src,
artboard,
animations,
layout,
autoplay,
...rest
}: RiveProps & ComponentProps<"div">) => {
const params = {
src,
artboard,
animations,
layout,
autoplay,
};
const { RiveComponent } = useRive(params);
return <RiveComponent {...rest} />;
};
export default Rive;

238
src/hooks/useRive.tsx Normal file
View File

@@ -0,0 +1,238 @@
import React, {
useCallback,
useEffect,
useRef,
useState,
ComponentProps,
RefCallback,
} from "react";
import { Rive, EventType } from "rive-js";
import {
UseRiveParameters,
UseRiveOptions,
RiveState,
Dimensions,
} from "../types";
import { useWindowSize } from "../utils";
type RiveComponentProps = {
setContainerRef: RefCallback<HTMLElement>;
setCanvasRef: RefCallback<HTMLCanvasElement>;
};
function RiveComponent({
setContainerRef,
setCanvasRef,
...rest
}: RiveComponentProps & ComponentProps<"div">) {
const containerStyle = {
width: "100%",
height: "100%",
};
return (
<div
ref={setContainerRef}
style={"className" in rest ? undefined : containerStyle}
{...rest}
>
<canvas ref={setCanvasRef} />
</div>
);
}
const defaultOptions = {
useDevicePixelRatio: true,
fitCanvasToArtboardHeight: false,
};
/**
* Returns options, with defaults set.
*
* @param opts
* @returns
*/
function getOptions(opts: Partial<UseRiveOptions>) {
return Object.assign({}, defaultOptions, opts);
}
/**
* Custom Hook for loading a Rive file.
*
* Waits until the load event has fired before returning it.
* We can then listen for changes to this animation in other hooks to detect
* when it has loaded.
*
* @param riveParams - Object containing parameters accepted by the Rive object
* in the rive-js runtime, with the exception of Canvas as that is attached
* via the ref callback `setCanvasRef`.
*
* @param opts - Optional list of options that are specific for this hook.
* @returns {RiveAnimationState}
*/
export default function useRive(
riveParams?: UseRiveParameters,
opts: Partial<UseRiveOptions> = {}
): RiveState {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLElement | null>(null);
const [rive, setRive] = useState<Rive | null>(null);
const [dimensions, setDimensions] = useState<Dimensions>({
height: 0,
width: 0,
});
// Listen to changes in the window sizes and update the bounds when changes
// occur.
const windowSize = useWindowSize();
const isParamsLoaded = Boolean(riveParams);
const options = getOptions(opts);
/**
* Gets the intended dimensions of the canvas element.
*
* The intended dimensions are those of the container element, unless the
* option `fitCanvasToArtboardHeight` is true, then they are adjusted to
* the height of the artboard.
*
* @returns Dimensions object.
*/
function getCanvasDimensions() {
const { width, height } =
containerRef.current?.getBoundingClientRect() ?? new DOMRect(0, 0, 0, 0);
if (rive && options.fitCanvasToArtboardHeight) {
const { maxY, maxX } = rive.bounds;
return { width, height: width * (maxY / maxX) };
}
return { width, height };
}
/**
* Updates the width and height of the canvas.
*/
function updateBounds() {
if (!containerRef.current) {
return;
}
const { width, height } = getCanvasDimensions();
const boundsChanged =
width !== dimensions.width || height !== dimensions.height;
if (canvasRef.current && rive && boundsChanged) {
if (options.fitCanvasToArtboardHeight) {
containerRef.current.style.height = height + "px";
}
if (options.useDevicePixelRatio) {
const dpr = window.devicePixelRatio || 1;
canvasRef.current.width = dpr * width;
canvasRef.current.height = dpr * height;
canvasRef.current.style.width = width + "px";
canvasRef.current.style.height = height + "px";
} else {
canvasRef.current.width = width;
canvasRef.current.height = height;
}
setDimensions({ width, height });
// Updating the canvas width or height will clear the canvas, so call
// startRendering() to redraw the current frame as the animation might
// be paused and not advancing.
rive.startRendering();
}
// Always resize to Canvas
if (rive) {
rive.resizeToCanvas();
}
}
/**
* Listen to changes on the windowSize and the rive file being loaded
* and update the canvas bounds as needed.
*/
useEffect(() => {
if (rive) {
updateBounds();
}
}, [rive, windowSize]);
/**
* Ref callback called when the canvas element mounts and unmounts.
*/
const setCanvasRef: RefCallback<HTMLCanvasElement> = useCallback(
(canvas: HTMLCanvasElement | null) => {
if (canvas && riveParams) {
const r = new Rive({ ...riveParams, canvas });
r.on(EventType.Load, () => setRive(r));
} else if (canvas === null && canvasRef.current) {
canvasRef.current.height = 0;
canvasRef.current.width = 0;
}
canvasRef.current = canvas;
},
[isParamsLoaded]
);
/**
* Ref callback called when the container element mounts
*/
const setContainerRef: RefCallback<HTMLElement> = useCallback(
(container: HTMLElement | null) => {
containerRef.current = container;
},
[]
);
/**
* Set up IntersectionObserver to stop rendering if the animation is not in
* view.
*/
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
entry.isIntersecting
? rive && rive.startRendering()
: rive && rive.stopRendering();
});
if (canvasRef.current) {
observer.observe(canvasRef.current);
}
return () => {
observer.disconnect();
};
}, [rive]);
/**
* On unmount, stop rive from rendering.
*/
useEffect(() => {
return () => {
if (rive) {
rive.stop();
setRive(null);
}
};
}, [rive]);
const Component = useCallback((props: ComponentProps<"div">): JSX.Element => {
return (
<RiveComponent
setContainerRef={setContainerRef}
setCanvasRef={setCanvasRef}
{...props}
/>
);
}, []);
return {
canvas: canvasRef.current,
setCanvasRef,
setContainerRef,
rive,
RiveComponent: Component,
};
}

View File

@@ -0,0 +1,36 @@
import { useState, useEffect } from "react";
import { Rive, StateMachineInput } from "rive-js";
/**
* Custom hook for fetching a stateMachine input from a rive file.
*
* @param rive - Rive instance
* @param stateMachineName - Name of the state machine
* @param inputName - Name of the input
* @returns
*/
export default function useStateMachineInput(
rive: Rive | null,
stateMachineName?: string,
inputName?: string
) {
const [input, setInput] = useState<StateMachineInput | null>(null);
useEffect(() => {
if (!rive || !stateMachineName || !inputName) {
setInput(null);
}
if (rive && stateMachineName && inputName) {
const inputs = rive.stateMachineInputs(stateMachineName);
if (inputs) {
const selectedInput = inputs.find((input) => input.name === inputName);
setInput(selectedInput || null);
}
} else {
setInput(null);
}
}, [rive]);
return input;
}

7
src/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import Rive from "./components/Rive";
import useRive from "./hooks/useRive";
import useStateMachineInput from "./hooks/useStateMachineInput";
export default Rive;
export { useRive, useStateMachineInput };
export { RiveState, UseRiveParameters, UseRiveOptions } from "./types";

32
src/types.ts Normal file
View File

@@ -0,0 +1,32 @@
import { RefCallback, ComponentProps } from "react";
import { Rive, RiveParameters } from "rive-js";
export type UseRiveParameters = Partial<Omit<RiveParameters, "canvas">> | null;
export type UseRiveOptions = {
useDevicePixelRatio: boolean;
fitCanvasToArtboardHeight: boolean;
};
export type Dimensions = {
width: number;
height: number;
};
/**
* @typedef RiveState
* @property canvas - Canvas element the Rive Animation is attached to.
* @property setCanvasRef - Ref callback to be passed to the canvas element.
* @property setContainerRef - Ref callback to be passed to the container element
* of the canvas. This is optional, however if not used then the hook will
* not take care of automatically resizing the canvas to it's outer
* container if the window resizes.
* @property rive - The loaded Rive Animation
*/
export type RiveState = {
canvas: HTMLCanvasElement | null;
setCanvasRef: RefCallback<HTMLCanvasElement>;
setContainerRef: RefCallback<HTMLElement>;
rive: Rive | null;
RiveComponent: (props: ComponentProps<"div">) => JSX.Element;
};

24
src/utils.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useState, useEffect } from "react";
import { Dimensions } from "./types";
export function useWindowSize() {
const [windowSize, setWindowSize] = useState<Dimensions>({
width: 0,
height: 0,
});
useEffect(() => {
if (typeof window !== "undefined") {
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener("resize", handleResize);
handleResize();
return () => window.removeEventListener("resize", handleResize);
}
}, []);
return windowSize;
}

270
test/useRive.test.tsx Normal file
View File

@@ -0,0 +1,270 @@
import { renderHook, act } from "@testing-library/react-hooks";
import { mocked } from "ts-jest/utils";
import useRive from "../src/hooks/useRive";
import * as rive from "rive-js";
jest.mock("rive-js", () => ({
Rive: jest.fn().mockImplementation(() => ({
on: jest.fn(),
stop: jest.fn(),
})),
Layout: jest.fn(),
Fit: {
Cover: "cover",
},
Alignment: {
Center: "center",
},
EventType: {
Load: "load",
},
StateMachineInputType: {
Number: 1,
Boolean: 2,
Trigger: 3,
},
}));
describe("useRive", () => {
it("returns rive as null if no params are passed", () => {
const { result } = renderHook(() => useRive());
expect(result.current.rive).toBe(null);
expect(result.current.canvas).toBe(null);
});
it("returns a rive object if the src object is set on the rive params and setCanvas is called", async () => {
const params = {
src: "file-src",
};
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
stopRendering: jest.fn(),
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement("canvas");
const { result } = renderHook(() => useRive(params));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
expect(result.current.rive).toBe(riveMock);
expect(result.current.canvas).toBe(canvasSpy);
});
it("updates the bounds if the container ref is set", async () => {
const params = {
src: "file-src",
};
const resizeToCanvasMock = jest.fn();
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
stopRendering: jest.fn(),
resizeToCanvas: resizeToCanvasMock,
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement("canvas");
const containerSpy = document.createElement("div");
const { result } = renderHook(() => useRive(params));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
result.current.setContainerRef(containerSpy);
});
expect(result.current.rive).toBe(riveMock);
expect(result.current.canvas).toBe(canvasSpy);
expect(resizeToCanvasMock).toBeCalled();
});
it("stops the rive object on unmount", async () => {
const params = {
src: "file-src",
};
const stopMock = jest.fn();
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: stopMock,
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement("canvas");
const { result, unmount } = renderHook(() => useRive(params));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
unmount();
expect(stopMock).toBeCalled();
});
it("sets the a bounds with the devicePixelRatio by default", async () => {
const params = {
src: "file-src",
};
global.devicePixelRatio = 2;
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement("canvas");
const containerSpy = document.createElement("div");
containerSpy.getBoundingClientRect = jest.fn().mockImplementation(() => ({
width: 100,
height: 100,
}));
const { result } = renderHook(() => useRive(params));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
result.current.setContainerRef(containerSpy);
});
// Height and width should be 2* the width and height returned from containers
// bounding rect
expect(canvasSpy).toHaveAttribute("height", "200");
expect(canvasSpy).toHaveAttribute("width", "200");
// Style height and width should be the same as returned from containers
// bounding rect
expect(canvasSpy).toHaveAttribute("style", "width: 100px; height: 100px;");
});
it("sets the a bounds without the devicePixelRatio if useDevicePixelRatio is false", async () => {
const params = {
src: "file-src",
};
const opts = {
useDevicePixelRatio: false,
};
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement("canvas");
const containerSpy = document.createElement("div");
containerSpy.getBoundingClientRect = jest.fn().mockImplementation(() => ({
width: 100,
height: 100,
}));
const { result } = renderHook(() => useRive(params, opts));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
result.current.setContainerRef(containerSpy);
});
// Height and width should be same as containers bounding rect
expect(canvasSpy).toHaveAttribute("height", "100");
expect(canvasSpy).toHaveAttribute("width", "100");
});
it("uses artbound height to set bounds if fitCanvasToArtboardHeight is true", async () => {
const params = {
src: "file-src",
};
const opts = {
useDevicePixelRatio: false,
fitCanvasToArtboardHeight: true,
};
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
bounds: {
maxX: 100,
maxY: 50,
},
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement("canvas");
const containerSpy = document.createElement("div");
containerSpy.getBoundingClientRect = jest.fn().mockImplementation(() => ({
width: 100,
height: 100,
}));
const { result } = renderHook(() => useRive(params, opts));
await act(async () => {
result.current.setContainerRef(containerSpy);
result.current.setCanvasRef(canvasSpy);
});
// Height and width should be same as containers bounding rect
expect(canvasSpy).toHaveAttribute("height", "50");
expect(canvasSpy).toHaveAttribute("width", "100");
// Container should have style set to height
expect(containerSpy).toHaveAttribute("style", "height: 50px;");
});
it("configures a IntersectionObserver on mounting", async () => {
const params = {
src: "file-src",
};
const observeMock = jest.fn();
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
observe: observeMock,
}));
const riveMock = {
on: (_: string, cb: () => void) => cb(),
stop: jest.fn(),
bounds: {
maxX: 100,
maxY: 50,
},
};
// @ts-ignore
mocked(rive.Rive).mockImplementation(() => riveMock);
const canvasSpy = document.createElement("canvas");
const { result } = renderHook(() => useRive(params));
await act(async () => {
result.current.setCanvasRef(canvasSpy);
});
expect(observeMock).toBeCalledWith(canvasSpy);
});
});

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"declaration": true,
"esModuleInterop": true,
"jsx": "react",
"lib": ["esnext", "dom"],
"module": "commonjs",
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "./dist",
"types": ["node", "jest"],
"rootDir": "src",
"strict": true,
"target": "es5",
"typeRoots": ["./types", "./node_modules/@types"]
},
"include": ["src/**/*"]
}

8
tsconfig.test.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"strict": false
},
"include": ["test/**/*", "./setupTests.ts"]
}