From 09eefd9d0723a43f9e7428eb4dc332d8e4f9eb2c Mon Sep 17 00:00:00 2001 From: Conrad Chan Date: Wed, 5 May 2021 14:11:53 -0700 Subject: [PATCH] feat(model3d): Add flyout for animation clips (#1369) --- .../viewers/box3d/model3d/Model3DRenderer.js | 14 ++++ .../viewers/box3d/model3d/Model3DViewer.js | 20 +++--- .../model3d/__tests__/Model3DRenderer-test.js | 24 +++++++ .../model3d/__tests__/Model3DViewer-test.js | 32 ++++++++- .../model3d/AnimationClipsControl.scss | 20 ++++++ .../model3d/AnimationClipsControl.tsx | 40 ++++++++++-- .../controls/model3d/AnimationClipsToggle.tsx | 14 +++- .../__tests__/AnimationClipsControl-test.tsx | 65 +++++++++++++++++++ .../__tests__/AnimationControls-test.tsx | 40 ++++++++++++ .../viewers/controls/settings/Settings.tsx | 10 ++- .../controls/settings/SettingsRadioItem.tsx | 5 +- .../settings/__tests__/Settings-test.tsx | 47 ++++++++++---- 12 files changed, 299 insertions(+), 32 deletions(-) create mode 100644 src/lib/viewers/controls/model3d/AnimationClipsControl.scss create mode 100644 src/lib/viewers/controls/model3d/__tests__/AnimationClipsControl-test.tsx create mode 100644 src/lib/viewers/controls/model3d/__tests__/AnimationControls-test.tsx diff --git a/src/lib/viewers/box3d/model3d/Model3DRenderer.js b/src/lib/viewers/box3d/model3d/Model3DRenderer.js index 61c0467a2..15e77094b 100644 --- a/src/lib/viewers/box3d/model3d/Model3DRenderer.js +++ b/src/lib/viewers/box3d/model3d/Model3DRenderer.js @@ -652,6 +652,20 @@ class Model3DRenderer extends Box3DRenderer { initVrGamepadControls() { this.vrControls = new Model3DVrControls(this.vrGamepads, this.box3d); } + + /** + * Gets any selected animation clip + * + * @return {string} selected animation clip id + */ + getAnimationClip() { + if (!this.instance) { + return ''; + } + + const component = this.instance.getComponentByScriptId('animation'); + return component ? component.clipId : ''; + } } export default Model3DRenderer; diff --git a/src/lib/viewers/box3d/model3d/Model3DViewer.js b/src/lib/viewers/box3d/model3d/Model3DViewer.js index 7550b73ca..2532b291e 100644 --- a/src/lib/viewers/box3d/model3d/Model3DViewer.js +++ b/src/lib/viewers/box3d/model3d/Model3DViewer.js @@ -146,6 +146,7 @@ class Model3DViewer extends Box3DViewer { */ handleSelectAnimationClip(clipId) { this.renderer.setAnimationClip(clipId); + this.setAnimationState(false); } /** @@ -294,15 +295,15 @@ class Model3DViewer extends Box3DViewer { * @return {void} */ handleToggleAnimation(play) { - if (this.getViewerOption('useReactControls')) { - this.isAnimationPlaying = !this.isAnimationPlaying; - this.renderer.toggleAnimation(this.isAnimationPlaying); + this.setAnimationState(play); + } - if (this.controls) { - this.renderUI(); - } - } else { - this.renderer.toggleAnimation(play); + setAnimationState(play) { + this.isAnimationPlaying = play; + this.renderer.toggleAnimation(this.isAnimationPlaying); + + if (this.controls && this.getViewerOption('useReactControls')) { + this.renderUI(); } } @@ -422,13 +423,14 @@ class Model3DViewer extends Box3DViewer { } renderUI() { - if (!this.controls) { + if (!this.controls || !this.renderer) { return; } this.controls.render( { }; animComp = { asset: animAsset, + clipId: '123', onUpdate: () => {}, pause: () => {}, play: () => {}, @@ -691,6 +692,29 @@ describe('lib/viewers/box3d/model3d/Model3DRenderer', () => { renderer.stopAnimation(); }); }); + + describe('getAnimationClip()', () => { + test('should return empty string if no model instance is present', () => { + renderer.instance = undefined; + expect(renderer.getAnimationClip()).toBe(''); + }); + + test('should return empty string if no componet is present', () => { + sandbox + .mock(renderer.instance) + .expects('getComponentByScriptId') + .returns(undefined); + expect(renderer.getAnimationClip()).toBe(''); + }); + + test('should get the animation component on the model instance', () => { + sandbox + .mock(renderer.instance) + .expects('getComponentByScriptId') + .returns(animComp); + expect(renderer.getAnimationClip()).toBe('123'); + }); + }); }); describe('onUnsupportedRepresentation()', () => { diff --git a/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js b/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js index 6159d85ba..8feb54ff1 100644 --- a/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js +++ b/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js @@ -75,6 +75,7 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { toggleAnimation: () => {}, load: () => Promise.resolve(), initVr: () => {}, + getAnimationClip: () => {}, getCamera: () => {}, initVrGamepadControls: () => {}, on: () => {}, @@ -867,7 +868,7 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { const nextIsPlaying = !isPlaying; model3d.isAnimationPlaying = isPlaying; - model3d.handleToggleAnimation(); + model3d.handleToggleAnimation(nextIsPlaying); expect(model3d.isAnimationPlaying).toBe(nextIsPlaying); expect(model3d.renderer.toggleAnimation).toBeCalledWith(nextIsPlaying); @@ -876,6 +877,33 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { }); }); + describe('handleSelectAnimationClip()', () => { + beforeEach(() => { + jest.spyOn(model3d.renderer, 'setAnimationClip'); + jest.spyOn(model3d, 'renderUI').mockImplementation(() => {}); + }); + + test('should set the clipId to the renderer', () => { + model3d.handleSelectAnimationClip('123'); + + expect(model3d.renderer.setAnimationClip).toBeCalledWith('123'); + }); + + describe('with react controls enabled', () => { + beforeEach(() => { + jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true); + }); + + test('should set the clipId to the renderer and also reset animation state and render the UI', () => { + model3d.handleSelectAnimationClip('123'); + + expect(model3d.renderer.setAnimationClip).toBeCalledWith('123'); + expect(model3d.isAnimationPlaying).toBe(false); + expect(model3d.renderUI).toBeCalled(); + }); + }); + }); + describe('renderUI()', () => { const getProps = instance => instance.controls.render.mock.calls[0][0].props; @@ -885,6 +913,7 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { destroy: jest.fn(), render: jest.fn(), }; + jest.spyOn(model3d.renderer, 'getAnimationClip').mockImplementation(() => '123'); }); test('should render react controls with the correct props', () => { @@ -892,6 +921,7 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { expect(getProps(model3d)).toMatchObject({ animationClips: [], + currentAnimationClipId: '123', isPlaying: false, onAnimationClipSelect: model3d.handleSelectAnimationClip, onFullscreenToggle: model3d.toggleFullscreen, diff --git a/src/lib/viewers/controls/model3d/AnimationClipsControl.scss b/src/lib/viewers/controls/model3d/AnimationClipsControl.scss new file mode 100644 index 000000000..84048f8c6 --- /dev/null +++ b/src/lib/viewers/controls/model3d/AnimationClipsControl.scss @@ -0,0 +1,20 @@ +.bp-AnimationClipsControl { + position: relative; + display: flex; + align-items: center; + + .bp-SettingsFlyout { + right: auto; + left: 0; + max-width: 240px; + } +} + +.bp-AnimationClipsControl-radioItem { + .bp-SettingsRadioItem-value { + max-width: 180px; + overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} diff --git a/src/lib/viewers/controls/model3d/AnimationClipsControl.tsx b/src/lib/viewers/controls/model3d/AnimationClipsControl.tsx index 74b2683ae..bd7f8ce2a 100644 --- a/src/lib/viewers/controls/model3d/AnimationClipsControl.tsx +++ b/src/lib/viewers/controls/model3d/AnimationClipsControl.tsx @@ -1,5 +1,7 @@ import React from 'react'; import AnimationClipsToggle from './AnimationClipsToggle'; +import Settings, { Menu } from '../settings'; +import './AnimationClipsControl.scss'; type AnimationClip = { duration: number; @@ -13,12 +15,38 @@ export type Props = { onAnimationClipSelect: () => void; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export default function AnimationClipsControl(props: Props): JSX.Element { +export function formatDuration(time: number): string { + const hours = Math.floor(time / 3600); + const minutes = Math.floor((time % 3600) / 60); + const seconds = Math.floor((time % 3600) % 60); + const hour = hours < 10 ? `0${hours.toString()}` : hours.toString(); + const min = minutes < 10 ? `0${minutes.toString()}` : minutes.toString(); + const sec = seconds < 10 ? `0${seconds.toString()}` : seconds.toString(); + + return `${hour}:${min}:${sec}`; +} + +export default function AnimationClipsControl({ + animationClips, + currentAnimationClipId, + onAnimationClipSelect, +}: Props): JSX.Element { return ( - <> - - {/* TODO: AnimationClipsFlyout */} - + + + {animationClips.map(({ duration, id, name }) => { + return ( + + ); + })} + + ); } diff --git a/src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx b/src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx index 6684ca02b..69d50aa5d 100644 --- a/src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx +++ b/src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx @@ -6,10 +6,20 @@ export type Props = { onClick?: () => void; }; -export default function AnimationClipsToggle({ onClick }: Props): JSX.Element { +function AnimationClipsToggle(props: Props, ref: React.Ref): JSX.Element { + const { onClick } = props; + return ( - ); } + +export default React.forwardRef(AnimationClipsToggle); diff --git a/src/lib/viewers/controls/model3d/__tests__/AnimationClipsControl-test.tsx b/src/lib/viewers/controls/model3d/__tests__/AnimationClipsControl-test.tsx new file mode 100644 index 000000000..c9d7012c6 --- /dev/null +++ b/src/lib/viewers/controls/model3d/__tests__/AnimationClipsControl-test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import AnimationClipsControl, { formatDuration, Props as AnimationClipsControlProps } from '../AnimationClipsControl'; +import AnimationClipsToggle from '../AnimationClipsToggle'; +import Settings from '../../settings'; + +describe('AnimationClipsControl', () => { + const animationClips = [ + { duration: 1, id: '1', name: 'first' }, + { duration: 2, id: '2', name: 'second' }, + ]; + const getDefaults = (): AnimationClipsControlProps => ({ + animationClips, + currentAnimationClipId: '1', + onAnimationClipSelect: jest.fn(), + }); + const getWrapper = (props = {}): ShallowWrapper => shallow(); + + describe('render', () => { + test('should return a valid wrapper', () => { + const wrapper = getWrapper(); + + expect(wrapper.find(Settings).props()).toMatchObject({ + className: 'bp-AnimationClipsControl', + toggle: AnimationClipsToggle, + }); + expect(wrapper.exists(Settings.Menu)).toBe(true); + }); + + test('should return the animationClips as RadioItems', () => { + const onAnimationClipSelect = jest.fn(); + const wrapper = getWrapper({ onAnimationClipSelect }); + const radioItems = wrapper.find(Settings.RadioItem); + + expect(radioItems).toHaveLength(2); + expect(radioItems.at(0).props()).toMatchObject({ + className: 'bp-AnimationClipsControl-radioItem', + isSelected: true, + label: '00:00:01 first', + onChange: onAnimationClipSelect, + value: animationClips[0].id, + }); + expect(radioItems.at(1).props()).toMatchObject({ + className: 'bp-AnimationClipsControl-radioItem', + isSelected: false, + label: '00:00:02 second', + onChange: onAnimationClipSelect, + value: animationClips[1].id, + }); + }); + }); + + describe('formatDuration()', () => { + test.each` + time | expectedString + ${0} | ${'00:00:00'} + ${59} | ${'00:00:59'} + ${61} | ${'00:01:01'} + ${3599} | ${'00:59:59'} + ${3661} | ${'01:01:01'} + `('should format $time as $expectedString', ({ time, expectedString }) => { + expect(formatDuration(time)).toBe(expectedString); + }); + }); +}); diff --git a/src/lib/viewers/controls/model3d/__tests__/AnimationControls-test.tsx b/src/lib/viewers/controls/model3d/__tests__/AnimationControls-test.tsx new file mode 100644 index 000000000..2c2f9c569 --- /dev/null +++ b/src/lib/viewers/controls/model3d/__tests__/AnimationControls-test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import AnimationClipsControl from '../AnimationClipsControl'; +import AnimationControls, { Props as AnimationControlsProps } from '../AnimationControls'; +import PlayPauseToggle from '../../media/PlayPauseToggle'; + +describe('AnimationControls', () => { + const getDefaults = (): AnimationControlsProps => ({ + animationClips: [{ duration: 1, id: '1', name: 'one' }], + currentAnimationClipId: '1', + isPlaying: false, + onAnimationClipSelect: jest.fn(), + onPlayPause: jest.fn(), + }); + const getWrapper = (props = {}): ShallowWrapper => shallow(); + + describe('render', () => { + test('should return null if animationClips is empty', () => { + const wrapper = getWrapper({ animationClips: [] }); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + test('should return valid wrapper', () => { + const onAnimationClipSelect = jest.fn(); + const onPlayPause = jest.fn(); + const wrapper = getWrapper({ onAnimationClipSelect, onPlayPause }); + + expect(wrapper.find(PlayPauseToggle).props()).toMatchObject({ + isPlaying: false, + onPlayPause, + }); + expect(wrapper.find(AnimationClipsControl).props()).toMatchObject({ + animationClips: expect.any(Array), + currentAnimationClipId: '1', + onAnimationClipSelect, + }); + }); + }); +}); diff --git a/src/lib/viewers/controls/settings/Settings.tsx b/src/lib/viewers/controls/settings/Settings.tsx index c872a3889..24b6eddc3 100644 --- a/src/lib/viewers/controls/settings/Settings.tsx +++ b/src/lib/viewers/controls/settings/Settings.tsx @@ -2,18 +2,24 @@ import React from 'react'; import classNames from 'classnames'; import SettingsContext, { Menu, Rect } from './SettingsContext'; import SettingsFlyout from './SettingsFlyout'; +import SettingsGearToggle, { Ref as SettingsToggleRef } from './SettingsToggle'; import SettingsMenu from './SettingsMenu'; import SettingsMenuBack from './SettingsMenuBack'; import SettingsMenuItem from './SettingsMenuItem'; import SettingsRadioItem from './SettingsRadioItem'; -import SettingsToggle, { Ref as SettingsToggleRef } from './SettingsToggle'; import { decodeKeydown } from '../../../util'; export type Props = React.PropsWithChildren<{ className?: string; + toggle?: React.ElementType; }>; -export default function Settings({ children, className, ...rest }: Props): JSX.Element | null { +export default function Settings({ + children, + className, + toggle: SettingsToggle = SettingsGearToggle, + ...rest +}: Props): JSX.Element | null { const [activeMenu, setActiveMenu] = React.useState(Menu.MAIN); const [activeRect, setActiveRect] = React.useState(); const [isFocused, setIsFocused] = React.useState(false); diff --git a/src/lib/viewers/controls/settings/SettingsRadioItem.tsx b/src/lib/viewers/controls/settings/SettingsRadioItem.tsx index cc5d822a3..d0b973cd6 100644 --- a/src/lib/viewers/controls/settings/SettingsRadioItem.tsx +++ b/src/lib/viewers/controls/settings/SettingsRadioItem.tsx @@ -16,6 +16,7 @@ export type Value = boolean | number | string; function SettingsRadioItem(props: Props, ref: React.Ref): JSX.Element { const { className, isSelected, label, onChange, value } = props; + const displayedValue = label || value.toString(); const handleClick = (): void => { onChange(value); @@ -46,7 +47,9 @@ function SettingsRadioItem(props: Props, ref: React.Ref
-
{label || value}
+
+ {displayedValue} +
); } diff --git a/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx b/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx index 3b18f77bb..4b667ac82 100644 --- a/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx +++ b/src/lib/viewers/controls/settings/__tests__/Settings-test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { mount, ReactWrapper } from 'enzyme'; import Settings from '../Settings'; -import SettingsToggle from '../SettingsToggle'; +import SettingsGearToggle from '../SettingsToggle'; import SettingsFlyout from '../SettingsFlyout'; describe('Settings', () => { @@ -16,12 +16,12 @@ describe('Settings', () => { const wrapper = getWrapper(); expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(false); - expect(wrapper.find(SettingsToggle).prop('isOpen')).toBe(false); + expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(false); - wrapper.find(SettingsToggle).simulate('click'); + wrapper.find(SettingsGearToggle).simulate('click'); expect(wrapper.find(SettingsFlyout).prop('isOpen')).toBe(true); - expect(wrapper.find(SettingsToggle).prop('isOpen')).toBe(true); + expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(true); }); test.each` @@ -56,20 +56,20 @@ describe('Settings', () => { return event; }; - wrapper.find(SettingsToggle).simulate('click'); // Open the controls - expect(wrapper.find(SettingsToggle).prop('isOpen')).toBe(true); + wrapper.find(SettingsGearToggle).simulate('click'); // Open the controls + expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(true); act(() => { document.dispatchEvent(getEvent(document.body)); // Click outside the controls }); wrapper.update(); - expect(wrapper.find(SettingsToggle).prop('isOpen')).toBe(false); + expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(false); - wrapper.find(SettingsToggle).simulate('click'); // Re-open the controls - expect(wrapper.find(SettingsToggle).prop('isOpen')).toBe(true); + wrapper.find(SettingsGearToggle).simulate('click'); // Re-open the controls + expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(true); wrapper.find(SettingsFlyout).simulate('click'); // Click within the controls - expect(wrapper.find(SettingsToggle).prop('isOpen')).toBe(true); + expect(wrapper.find(SettingsGearToggle).prop('isOpen')).toBe(true); }); test('should stop propagation on all keydown events to prevent triggering global event listeners', () => { @@ -91,7 +91,32 @@ describe('Settings', () => { expect(wrapper.getDOMNode()).toHaveClass('bp-Settings'); expect(wrapper.exists(SettingsFlyout)).toBe(true); - expect(wrapper.exists(SettingsToggle)).toBe(true); + expect(wrapper.exists(SettingsGearToggle)).toBe(true); + }); + + describe('toggle prop', () => { + function CustomToggle( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + { isOpen, ...rest }: { isOpen: boolean; onClick: () => void }, + ref: React.Ref, + ): JSX.Element { + return