diff --git a/src/lib/viewers/box3d/Box3DViewer.js b/src/lib/viewers/box3d/Box3DViewer.js index 06f8c217e..2506d0635 100644 --- a/src/lib/viewers/box3d/Box3DViewer.js +++ b/src/lib/viewers/box3d/Box3DViewer.js @@ -92,7 +92,8 @@ class Box3DViewer extends BaseViewer { * @return {void} */ attachEventHandlers() { - if (this.controls) { + if (this.controls && !this.getViewerOption('useReactControls')) { + // TODO: This can be removed once Image360 and Video360 controls are migrated to React this.controls.on(EVENT_TOGGLE_FULLSCREEN, this.toggleFullscreen); this.controls.on(EVENT_TOGGLE_VR, this.handleToggleVr); this.controls.on(EVENT_RESET, this.handleReset); @@ -118,7 +119,7 @@ class Box3DViewer extends BaseViewer { * @return {void} */ detachEventHandlers() { - if (this.controls) { + if (this.controls && !this.getViewerOption('useReactControls')) { this.controls.removeListener(EVENT_TOGGLE_FULLSCREEN, this.toggleFullscreen); this.controls.removeListener(EVENT_TOGGLE_VR, this.handleToggleVr); this.controls.removeListener(EVENT_RESET, this.handleReset); @@ -298,7 +299,7 @@ class Box3DViewer extends BaseViewer { this.wrapperEl.classList.remove(CLASS_VR_ENABLED); } - if (this.controls) { + if (this.controls && !this.getViewerOption('useReactControls')) { this.controls.vrEnabled = vrDevice && vrDevice.isPresenting; } } diff --git a/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js b/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js index bfcfdde21..23b6019ee 100644 --- a/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js +++ b/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js @@ -137,6 +137,13 @@ describe('lib/viewers/box3d/Box3DViewer', () => { expect(onSpy).toBeCalledWith(EVENT_RESET, expect.any(Function)); }); + + test('should not invoke any box3d.controls.on() if using react controls', () => { + jest.spyOn(box3d, 'getViewerOption').mockImplementation(() => true); + box3d.controls = { destroy: jest.fn(), on: jest.fn() }; + box3d.attachEventHandlers(); + expect(box3d.controls.on).not.toBeCalled(); + }); }); test("should not attach handlers to controls if controls instance doesn't exist", () => { @@ -216,6 +223,13 @@ describe('lib/viewers/box3d/Box3DViewer', () => { expect(detachSpy).toBeCalledWith(EVENT_RESET, expect.any(Function)); }); + + test('should not invoke any box3d.controls.removeListener() if using react controls', () => { + jest.spyOn(box3d, 'getViewerOption').mockImplementation(() => true); + box3d.controls = { destroy: jest.fn(), removeListener: jest.fn() }; + box3d.detachEventHandlers(); + expect(box3d.controls.removeListener).not.toBeCalled(); + }); }); test('should not invoke controls.removeListener() when controls is undefined', () => { @@ -311,6 +325,15 @@ describe('lib/viewers/box3d/Box3DViewer', () => { expect(box3d.controls.destroy).toBeCalled(); }); + test('should still call controls.destroy() if using react controls', () => { + jest.spyOn(box3d, 'getViewerOption').mockImplementation(() => true); + box3d.controls = { destroy: jest.fn(), on: jest.fn() }; + + box3d.destroy(); + + expect(box3d.controls.destroy).toBeCalled(); + }); + test('should call renderer.destroy() if it exists', () => { jest.spyOn(box3d.renderer, 'destroy'); @@ -474,6 +497,15 @@ describe('lib/viewers/box3d/Box3DViewer', () => { expect(box3d.wrapperEl).not.toHaveClass('vr-enabled'); expect(box3d.controls.vrEnabled).not.toBe(true); }); + + test('should not add vr-enabled if use react controls is enabled', () => { + jest.spyOn(box3d, 'getViewerOption').mockImplementation(() => true); + jest.spyOn(Browser, 'isMobile').mockReturnValue(true); + + box3d.onVrPresentChange(); + + expect(box3d.controls.vrEnabled).toBe(false); + }); }); describe('handleSceneLoaded()', () => { diff --git a/src/lib/viewers/box3d/model3d/Model3DControlsNew.tsx b/src/lib/viewers/box3d/model3d/Model3DControlsNew.tsx new file mode 100644 index 000000000..c728b6500 --- /dev/null +++ b/src/lib/viewers/box3d/model3d/Model3DControlsNew.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import AnimationControls, { Props as AnimationControlsProps } from '../../controls/model3d/AnimationControls'; +import ControlsBar from '../../controls/controls-bar'; +import FullscreenToggle, { Props as FullscreenToggleProps } from '../../controls/fullscreen'; +import ResetControl, { Props as ResetControlProps } from '../../controls/model3d/ResetControl'; + +export type Props = AnimationControlsProps & FullscreenToggleProps & ResetControlProps; + +export default function Model3DControls({ + animationClips, + currentAnimationClipId, + isPlaying, + onAnimationClipSelect, + onFullscreenToggle, + onPlayPause, + onReset, +}: Props): JSX.Element { + const handleReset = (): void => { + // TODO: will need to reset the state to defaults + onReset(); + }; + + return ( + + + + {/* TODO: VR button */} + {/* TODO: Settings button */} + + + ); +} diff --git a/src/lib/viewers/box3d/model3d/Model3DViewer.js b/src/lib/viewers/box3d/model3d/Model3DViewer.js index bd5d40f43..7550b73ca 100644 --- a/src/lib/viewers/box3d/model3d/Model3DViewer.js +++ b/src/lib/viewers/box3d/model3d/Model3DViewer.js @@ -1,5 +1,8 @@ +import React from 'react'; import Box3DViewer from '../Box3DViewer'; +import ControlsRoot from '../../controls/controls-root'; import Model3DControls from './Model3DControls'; +import Model3DControlsNew from './Model3DControlsNew'; import Model3DRenderer from './Model3DRenderer'; import { CAMERA_PROJECTION_PERSPECTIVE, @@ -28,8 +31,8 @@ const LOAD_TIMEOUT = 180000; // 3 minutes * This is the entry point for the model3d preview. */ class Model3DViewer extends Box3DViewer { - /** @property {Object[]} - List of Box3D instances added to the scene */ - instances = []; + /** @property {Object[]} - List of animation clips for the given Box3D file */ + animationClips = []; /** @property {Object} - Tracks up and forward axes for the model alignment in the scene */ axes = { @@ -37,6 +40,12 @@ class Model3DViewer extends Box3DViewer { forward: null, }; + /** @property {Object[]} - List of Box3D instances added to the scene */ + instances = []; + + /** @property {boolean} - Boolean indicating whether the animation is playihng */ + isAnimationPlaying = false; + /** @inheritdoc */ constructor(option) { super(option); @@ -51,6 +60,7 @@ class Model3DViewer extends Box3DViewer { this.handleToggleAnimation = this.handleToggleAnimation.bind(this); this.handleToggleHelpers = this.handleToggleHelpers.bind(this); this.handleCanvasClick = this.handleCanvasClick.bind(this); + this.initViewer = this.initViewer.bind(this); this.onMetadataError = this.onMetadataError.bind(this); } @@ -75,7 +85,9 @@ class Model3DViewer extends Box3DViewer { * @inheritdoc */ createSubModules() { - this.controls = new Model3DControls(this.wrapperEl); + this.controls = this.getViewerOption('useReactControls') + ? new ControlsRoot({ containerEl: this.wrapperEl, fileId: this.options.file.id }) + : new Model3DControls(this.wrapperEl); this.renderer = new Model3DRenderer(this.wrapperEl, this.boxSdk, { api: this.api }); } @@ -85,7 +97,7 @@ class Model3DViewer extends Box3DViewer { attachEventHandlers() { super.attachEventHandlers(); - if (this.controls) { + if (this.controls && !this.getViewerOption('useReactControls')) { this.controls.on(EVENT_ROTATE_ON_AXIS, this.handleRotateOnAxis); this.controls.on(EVENT_SELECT_ANIMATION_CLIP, this.handleSelectAnimationClip); this.controls.on(EVENT_SET_CAMERA_PROJECTION, this.handleSetCameraProjection); @@ -108,7 +120,7 @@ class Model3DViewer extends Box3DViewer { detachEventHandlers() { super.detachEventHandlers(); - if (this.controls) { + if (this.controls && !this.getViewerOption('useReactControls')) { this.controls.removeListener(EVENT_ROTATE_ON_AXIS, this.handleRotateOnAxis); this.controls.removeListener(EVENT_SELECT_ANIMATION_CLIP, this.handleSelectAnimationClip); this.controls.removeListener(EVENT_SET_CAMERA_PROJECTION, this.handleSetCameraProjection); @@ -174,39 +186,45 @@ class Model3DViewer extends Box3DViewer { return response.response; }) .catch(this.onMetadataError) - .then(defaults => { - if (this.controls) { - this.controls.addUi(); - } + .then(this.initViewer); + } - this.axes.up = defaults.upAxis || DEFAULT_AXIS_UP; - this.axes.forward = defaults.forwardAxis || DEFAULT_AXIS_FORWARD; - this.renderMode = defaults.defaultRenderMode || RENDER_MODE_LIT; - this.projection = defaults.cameraProjection || CAMERA_PROJECTION_PERSPECTIVE; - if (defaults.renderGrid === 'true') { - this.renderGrid = true; - } else if (defaults.renderGrid === 'false') { - this.renderGrid = false; - } else { - this.renderGrid = DEFAULT_RENDER_GRID; - } + initViewer(defaults) { + if (this.controls) { + if (this.getViewerOption('useReactControls')) { + this.renderUI(); + } else { + this.controls.addUi(); + } + } - if (this.axes.up !== DEFAULT_AXIS_UP || this.axes.forward !== DEFAULT_AXIS_FORWARD) { - this.handleRotationAxisSet(this.axes.up, this.axes.forward, false); - } + this.axes.up = defaults.upAxis || DEFAULT_AXIS_UP; + this.axes.forward = defaults.forwardAxis || DEFAULT_AXIS_FORWARD; + this.renderMode = defaults.defaultRenderMode || RENDER_MODE_LIT; + this.projection = defaults.cameraProjection || CAMERA_PROJECTION_PERSPECTIVE; + if (defaults.renderGrid === 'true') { + this.renderGrid = true; + } else if (defaults.renderGrid === 'false') { + this.renderGrid = false; + } else { + this.renderGrid = DEFAULT_RENDER_GRID; + } - // Update controls ui - this.handleReset(); + if (this.axes.up !== DEFAULT_AXIS_UP || this.axes.forward !== DEFAULT_AXIS_FORWARD) { + this.handleRotationAxisSet(this.axes.up, this.axes.forward, false); + } + + // Update controls ui + this.handleReset(); - // Initialize animation controls when animations are present. - this.populateAnimationControls(); + // Initialize animation controls when animations are present. + this.populateAnimationControls(); - this.showWrapper(); + this.showWrapper(); - this.emit(EVENT_LOAD); + this.emit(EVENT_LOAD); - return true; - }); + return true; } /** @@ -239,15 +257,31 @@ class Model3DViewer extends Box3DViewer { if (animations.length > 0) { const clipIds = animations[0].getClipIds(); - clipIds.forEach(clipId => { - const clip = animations[0].getClip(clipId); - const duration = clip.stop - clip.start; - this.controls.addAnimationClip(clipId, clip.name, duration); - }); - - if (clipIds.length > 0) { - this.controls.showAnimationControls(); - this.controls.selectAnimationClip(clipIds[0]); + if (this.getViewerOption('useReactControls')) { + this.animationClips = clipIds.map(clipId => { + const { name, start, stop } = animations[0].getClip(clipId); + const duration = stop - start; + return { + duration, + id: clipId, + name, + }; + }); + + this.renderer.setAnimationClip(this.animationClips[0].id); + + this.renderUI(); + } else { + clipIds.forEach(clipId => { + const clip = animations[0].getClip(clipId); + const duration = clip.stop - clip.start; + this.controls.addAnimationClip(clipId, clip.name, duration); + }); + + if (clipIds.length > 0) { + this.controls.showAnimationControls(); + this.controls.selectAnimationClip(clipIds[0]); + } } } } @@ -260,7 +294,16 @@ class Model3DViewer extends Box3DViewer { * @return {void} */ handleToggleAnimation(play) { - this.renderer.toggleAnimation(play); + if (this.getViewerOption('useReactControls')) { + this.isAnimationPlaying = !this.isAnimationPlaying; + this.renderer.toggleAnimation(this.isAnimationPlaying); + + if (this.controls) { + this.renderUI(); + } + } else { + this.renderer.toggleAnimation(play); + } } /** @@ -270,7 +313,9 @@ class Model3DViewer extends Box3DViewer { * @return {void} */ handleCanvasClick() { - this.controls.hidePullups(); + if (!this.getViewerOption('useReactControls')) { + this.controls.hidePullups(); + } } /** @@ -288,12 +333,18 @@ class Model3DViewer extends Box3DViewer { handleReset() { super.handleReset(); + this.isAnimationPlaying = false; + if (this.controls) { - this.controls.handleSetRenderMode(this.renderMode); - this.controls.setCurrentProjectionMode(this.projection); - this.controls.handleSetSkeletonsVisible(false); - this.controls.handleSetWireframesVisible(false); - this.controls.handleSetGridVisible(this.renderGrid); + if (this.getViewerOption('useReactControls')) { + this.renderUI(); + } else { + this.controls.handleSetRenderMode(this.renderMode); + this.controls.setCurrentProjectionMode(this.projection); + this.controls.handleSetSkeletonsVisible(false); + this.controls.handleSetWireframesVisible(false); + this.controls.handleSetGridVisible(this.renderGrid); + } } if (this.renderer) { @@ -369,6 +420,23 @@ class Model3DViewer extends Box3DViewer { handleShowGrid(visible) { this.renderer.setGridVisible(visible); } + + renderUI() { + if (!this.controls) { + return; + } + + this.controls.render( + , + ); + } } export default Model3DViewer; diff --git a/src/lib/viewers/box3d/model3d/__tests__/Model3DControlsNew-test.tsx b/src/lib/viewers/box3d/model3d/__tests__/Model3DControlsNew-test.tsx new file mode 100644 index 000000000..bcfdbe1c0 --- /dev/null +++ b/src/lib/viewers/box3d/model3d/__tests__/Model3DControlsNew-test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import Model3DControls, { Props } from '../Model3DControlsNew'; +import ResetControl from '../../../controls/model3d/ResetControl'; +import AnimationControls from '../../../controls/model3d/AnimationControls'; +import FullscreenToggle from '../../../controls/fullscreen'; + +describe('lib/viewers/box3d/model3d/Model3DControlsNew', () => { + const getDefaults = (): Props => ({ + animationClips: [], + currentAnimationClipId: '123', + isPlaying: false, + onAnimationClipSelect: jest.fn(), + onFullscreenToggle: jest.fn(), + onPlayPause: jest.fn(), + onReset: jest.fn(), + }); + + const getWrapper = (props: Partial): ShallowWrapper => + shallow(); + describe('render()', () => { + test('should return a valid wrapper', () => { + const onAnimationClipSelect = jest.fn(); + const onFullscreenToggle = jest.fn(); + const onPlayPause = jest.fn(); + + const wrapper = getWrapper({ onAnimationClipSelect, onFullscreenToggle, onPlayPause }); + + expect(wrapper.find(AnimationControls).props()).toMatchObject({ + animationClips: [], + currentAnimationClipId: '123', + isPlaying: false, + onAnimationClipSelect, + onPlayPause, + }); + expect(wrapper.find(FullscreenToggle).prop('onFullscreenToggle')).toEqual(onFullscreenToggle); + }); + }); + + describe('onReset()', () => { + test('should call onReset prop when reset button is clicked', () => { + const onReset = jest.fn(); + const wrapper = getWrapper({ onReset }); + + wrapper.find(ResetControl).prop('onReset')(); + + expect(onReset).toBeCalled(); + }); + }); +}); diff --git a/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js b/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js index 8eb1849ea..6159d85ba 100644 --- a/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js +++ b/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js @@ -1,6 +1,7 @@ /* eslint-disable no-unused-expressions */ import BaseViewer from '../../../BaseViewer'; import Box3DRuntime from '../../__mocks__/Box3DRuntime'; +import ControlsRoot from '../../../controls/controls-root'; import Model3DControls from '../Model3DControls'; import Model3DRenderer from '../Model3DRenderer'; import Model3DViewer from '../Model3DViewer'; @@ -135,6 +136,32 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { expect(m3d.controls).toBeInstanceOf(Model3DControls); expect(m3d.renderer).toBeInstanceOf(Model3DRenderer); }); + + test('should create ControlsRoot if using react controls', () => { + const m3d = new Model3DViewer({ + file: { + id: 0, + file_version: { + id: 1, + }, + }, + container: containerEl, + representation: { + content: { + url_template: 'foo', + }, + }, + }); + jest.spyOn(m3d, 'getViewerOption').mockImplementation(() => true); + Object.defineProperty(BaseViewer.prototype, 'setup', { value: sandbox.mock() }); + m3d.containerEl = containerEl; + m3d.setup(); + + m3d.createSubModules(); + + expect(m3d.controls).toBeInstanceOf(ControlsRoot); + expect(m3d.renderer).toBeInstanceOf(Model3DRenderer); + }); }); describe('event handlers', () => { @@ -229,6 +256,17 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { }); }); }); + + describe('with react controls enabled', () => { + eventBindings.forEach(binding => { + test(`should not create an event listener for ${binding.event} events`, () => { + jest.spyOn(m3d, 'getViewerOption').mockImplementation(() => true); + const onStub = jest.spyOn(m3d.controls, 'on'); + m3d.attachEventHandlers(); + expect(onStub).not.toBeCalledWith(binding.event, m3d[binding.callback]); + }); + }); + }); }); describe('detachEventHandlers()', () => { @@ -247,6 +285,17 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { }); }); }); + + describe('with react controls enabled', () => { + eventBindings.forEach(binding => { + test(`should not remove an event listener for ${binding.event} events`, () => { + jest.spyOn(m3d, 'getViewerOption').mockImplementation(() => true); + const removeStub = jest.spyOn(m3d.controls, 'removeListener'); + m3d.detachEventHandlers(); + expect(removeStub).not.toBeCalledWith(binding.event, m3d[binding.callback]); + }); + }); + }); }); }); @@ -301,6 +350,8 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { }; b3dMock = sandbox.mock(model3d.renderer.box3d); controlMock = sandbox.mock(model3d.controls); + jest.spyOn(model3d.renderer, 'setAnimationClip').mockImplementation(() => {}); + jest.spyOn(model3d, 'renderUI').mockImplementation(() => {}); }); afterEach(() => { @@ -434,6 +485,47 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { model3d.populateAnimationControls(); }); + + describe('with react controls enabled', () => { + beforeEach(() => { + const animation = { + getClipIds: () => {}, + getClip: () => {}, + }; + const clipOne = { + start: 0, + stop: 1, + name: 'one', + }; + const animMock = sandbox.mock(animation); + animMock.expects('getClipIds').returns(['1']); + animMock + .expects('getClip') + .withArgs('1') + .returns(clipOne); + b3dMock.expects('getEntitiesByType').returns([animation]); + + jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true); + }); + + test('should call renderUI', () => { + model3d.populateAnimationControls(); + + expect(model3d.renderUI).toBeCalled(); + }); + + test('should set animationClips', () => { + model3d.populateAnimationControls(); + + expect(model3d.animationClips).toEqual([{ duration: 1, id: '1', name: 'one' }]); + }); + + test('should set first animation clip to the renderer', () => { + model3d.populateAnimationControls(); + + expect(model3d.renderer.setAnimationClip).toBeCalledWith('1'); + }); + }); }); }); @@ -638,6 +730,15 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { sandbox.mock(model3d.controls).expects('hidePullups'); model3d.handleCanvasClick(); }); + + test('should not invoke controls.hidePullups() if using react controls', () => { + jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true); + jest.spyOn(model3d.controls, 'hidePullups'); + + model3d.handleCanvasClick(); + + expect(model3d.controls.hidePullups).not.toBeCalled(); + }); }); describe('handleReset()', () => { @@ -651,5 +752,152 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => { renderMock.expects('stopAnimation').once(); model3d.handleReset(); }); + + describe('with react controls enabled', () => { + beforeEach(() => { + jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true); + jest.spyOn(model3d, 'renderUI').mockImplementation(() => {}); + }); + + test('should not reset control settings', () => { + jest.spyOn(model3d.controls, 'handleSetRenderMode'); + jest.spyOn(model3d.controls, 'setCurrentProjectionMode'); + jest.spyOn(model3d.controls, 'handleSetSkeletonsVisible'); + jest.spyOn(model3d.controls, 'handleSetWireframesVisible'); + jest.spyOn(model3d.controls, 'handleSetGridVisible'); + + model3d.handleReset(); + + expect(model3d.controls.handleSetRenderMode).not.toBeCalled(); + expect(model3d.controls.setCurrentProjectionMode).not.toBeCalled(); + expect(model3d.controls.handleSetSkeletonsVisible).not.toBeCalled(); + expect(model3d.controls.handleSetWireframesVisible).not.toBeCalled(); + expect(model3d.controls.handleSetGridVisible).not.toBeCalled(); + }); + + test('should reset controls state and call renderUI', () => { + model3d.isAnimationPlaying = true; + + model3d.handleReset(); + + expect(model3d.isAnimationPlaying).toBe(false); + expect(model3d.renderUI).toBeCalled(); + }); + }); + }); + + describe('initViewer()', () => { + let addUISpy; + let renderUISpy; + beforeEach(() => { + jest.spyOn(model3d, 'handleReset').mockImplementation(() => {}); + jest.spyOn(model3d, 'handleRotationAxisSet').mockImplementation(() => {}); + jest.spyOn(model3d, 'populateAnimationControls').mockImplementation(() => {}); + jest.spyOn(model3d, 'showWrapper').mockImplementation(() => {}); + + renderUISpy = jest.spyOn(model3d, 'renderUI').mockImplementation(() => {}); + addUISpy = jest.spyOn(model3d.controls, 'addUi').mockImplementation(() => {}); + }); + + test('should add the controls UI', () => { + model3d.initViewer({}); + + expect(addUISpy).toBeCalled(); + }); + + test('should initialize viewer settings to defaults if meta is empty', () => { + model3d.initViewer({}); + + expect(model3d.axes.up).toBe('+Y'); + expect(model3d.axes.forward).toBe('+Z'); + expect(model3d.renderMode).toBe('Lit'); + expect(model3d.projection).toBe('Perspective'); + expect(model3d.renderGrid).toBe(true); + expect(model3d.handleRotationAxisSet).not.toBeCalled(); + }); + + test('should initialize viewer settings to provided defaults', () => { + const defaults = { + cameraProjection: 'Orthographic', + defaultRenderMode: 'Unlit', + forwardAxis: 'forward', + renderGrid: 'false', + upAxis: 'up', + }; + model3d.initViewer(defaults); + + expect(model3d.axes.up).toBe(defaults.upAxis); + expect(model3d.axes.forward).toBe(defaults.forwardAxis); + expect(model3d.renderMode).toBe(defaults.defaultRenderMode); + expect(model3d.projection).toBe(defaults.cameraProjection); + expect(model3d.renderGrid).toBe(false); + expect(model3d.handleRotationAxisSet).toBeCalledWith(defaults.upAxis, defaults.forwardAxis, false); + }); + + describe('with react controls enabled', () => { + beforeEach(() => jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true)); + + test('should call renderUI if using react controls', () => { + model3d.initViewer({}); + + expect(renderUISpy).toBeCalled(); + expect(addUISpy).not.toBeCalled(); + }); + }); + }); + + describe('handleToggleAnimation()', () => { + beforeEach(() => { + jest.spyOn(model3d.renderer, 'toggleAnimation'); + jest.spyOn(model3d, 'renderUI').mockImplementation(() => {}); + }); + + test.each([true, false])('should toggle the animation for the renderer as %s', play => { + model3d.handleToggleAnimation(play); + + expect(model3d.renderer.toggleAnimation).toBeCalledWith(play); + }); + + describe('with react controls enabled', () => { + beforeEach(() => { + jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true); + }); + + test.each([true, false])('should toggle the animation when initially %s', isPlaying => { + const nextIsPlaying = !isPlaying; + model3d.isAnimationPlaying = isPlaying; + + model3d.handleToggleAnimation(); + + expect(model3d.isAnimationPlaying).toBe(nextIsPlaying); + expect(model3d.renderer.toggleAnimation).toBeCalledWith(nextIsPlaying); + expect(model3d.renderUI).toBeCalled(); + }); + }); + }); + + describe('renderUI()', () => { + const getProps = instance => instance.controls.render.mock.calls[0][0].props; + + beforeEach(() => { + jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true); + model3d.controls = { + destroy: jest.fn(), + render: jest.fn(), + }; + }); + + test('should render react controls with the correct props', () => { + model3d.renderUI(); + + expect(getProps(model3d)).toMatchObject({ + animationClips: [], + isPlaying: false, + onAnimationClipSelect: model3d.handleSelectAnimationClip, + onFullscreenToggle: model3d.toggleFullscreen, + onPlayPause: model3d.handleToggleAnimation, + onReset: model3d.handleReset, + }); + }); }); }); diff --git a/src/lib/viewers/controls/icons/IconAnimation24.tsx b/src/lib/viewers/controls/icons/IconAnimation24.tsx new file mode 100644 index 000000000..3358d8801 --- /dev/null +++ b/src/lib/viewers/controls/icons/IconAnimation24.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; + +function IconAnimation24(props: React.SVGProps): JSX.Element { + return ( + + + + + ); +} + +export default IconAnimation24; diff --git a/src/lib/viewers/controls/icons/IconReset24.tsx b/src/lib/viewers/controls/icons/IconReset24.tsx new file mode 100644 index 000000000..39aec50b1 --- /dev/null +++ b/src/lib/viewers/controls/icons/IconReset24.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; + +function IconReset24(props: React.SVGProps): JSX.Element { + return ( + + + + ); +} + +export default IconReset24; diff --git a/src/lib/viewers/controls/model3d/AnimationClipsControl.tsx b/src/lib/viewers/controls/model3d/AnimationClipsControl.tsx new file mode 100644 index 000000000..74b2683ae --- /dev/null +++ b/src/lib/viewers/controls/model3d/AnimationClipsControl.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import AnimationClipsToggle from './AnimationClipsToggle'; + +type AnimationClip = { + duration: number; + id: string; + name: string; +}; + +export type Props = { + animationClips: Array; + currentAnimationClipId?: string; + onAnimationClipSelect: () => void; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function AnimationClipsControl(props: Props): JSX.Element { + return ( + <> + + {/* TODO: AnimationClipsFlyout */} + + ); +} diff --git a/src/lib/viewers/controls/model3d/AnimationClipsToggle.scss b/src/lib/viewers/controls/model3d/AnimationClipsToggle.scss new file mode 100644 index 000000000..88a801af2 --- /dev/null +++ b/src/lib/viewers/controls/model3d/AnimationClipsToggle.scss @@ -0,0 +1,5 @@ +@import '../styles'; + +.bp-AnimationClipsToggle { + @include bp-Control; +} diff --git a/src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx b/src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx new file mode 100644 index 000000000..6684ca02b --- /dev/null +++ b/src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import IconAnimation24 from '../icons/IconAnimation24'; +import './AnimationClipsToggle.scss'; + +export type Props = { + onClick?: () => void; +}; + +export default function AnimationClipsToggle({ onClick }: Props): JSX.Element { + return ( + + ); +} diff --git a/src/lib/viewers/controls/model3d/AnimationControls.scss b/src/lib/viewers/controls/model3d/AnimationControls.scss new file mode 100644 index 000000000..6df42cdf9 --- /dev/null +++ b/src/lib/viewers/controls/model3d/AnimationControls.scss @@ -0,0 +1,11 @@ +@import '../styles'; + +.bp-AnimationControls { + display: flex; + align-items: center; + + .bp-PlayPauseToggle { + width: $bp-controls-size-default; + height: $bp-controls-size-default; + } +} diff --git a/src/lib/viewers/controls/model3d/AnimationControls.tsx b/src/lib/viewers/controls/model3d/AnimationControls.tsx new file mode 100644 index 000000000..805af1785 --- /dev/null +++ b/src/lib/viewers/controls/model3d/AnimationControls.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import AnimationClipsControl, { Props as AnimationClipsControlProps } from './AnimationClipsControl'; +import PlayPauseToggle, { Props as PlayPauseToggleProps } from '../media/PlayPauseToggle'; +import './AnimationControls.scss'; + +export type Props = AnimationClipsControlProps & PlayPauseToggleProps; + +export default function AnimationControls({ + animationClips, + currentAnimationClipId, + isPlaying, + onAnimationClipSelect, + onPlayPause, +}: Props): JSX.Element | null { + if (!animationClips.length) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/src/lib/viewers/controls/model3d/ResetControl.scss b/src/lib/viewers/controls/model3d/ResetControl.scss new file mode 100644 index 000000000..4a421875d --- /dev/null +++ b/src/lib/viewers/controls/model3d/ResetControl.scss @@ -0,0 +1,5 @@ +@import '../styles'; + +.bp-ResetControl { + @include bp-Control; +} diff --git a/src/lib/viewers/controls/model3d/ResetControl.tsx b/src/lib/viewers/controls/model3d/ResetControl.tsx new file mode 100644 index 000000000..25acf0c25 --- /dev/null +++ b/src/lib/viewers/controls/model3d/ResetControl.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import IconReset24 from '../icons/IconReset24'; +import './ResetControl.scss'; + +export type Props = { + onReset: () => void; +}; + +export default function ResetControl({ onReset }: Props): JSX.Element { + const handleClick = (): void => { + onReset(); + }; + + return ( + + ); +}