diff --git a/resources/assets/src/components/Viewer.scss b/resources/assets/src/components/Viewer.scss new file mode 100644 index 00000000..93327b26 --- /dev/null +++ b/resources/assets/src/components/Viewer.scss @@ -0,0 +1,20 @@ +@use '../styles/breakpoints'; + +.viewer { + @include breakpoints.greater-than('lg') { + min-height: 500px; + } + + canvas { + cursor: move; + } +} + +.actions i { + display: inline; + padding: 0.5em 0.5em; + &:hover { + color: #555; + cursor: pointer; + } +} diff --git a/resources/assets/src/components/Viewer.tsx b/resources/assets/src/components/Viewer.tsx new file mode 100644 index 00000000..74b68d85 --- /dev/null +++ b/resources/assets/src/components/Viewer.tsx @@ -0,0 +1,183 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react' +import * as skinview3d from 'skinview3d' +import { trans } from '../scripts/i18n' +import styles from './Viewer.scss' +import SkinSteve from '../../../misc/textures/steve.png' + +interface Props { + skin?: string + cape?: string + model?: 'steve' | 'alex' + showIndicator?: boolean + initPositionZ?: number +} + +type ViewerStuff = { + handles: { + walk: skinview3d.AnimationHandle + run: skinview3d.AnimationHandle + rotate: skinview3d.AnimationHandle + } + control: skinview3d.OrbitControls + firstRun: boolean +} + +const emptyStuff: ViewerStuff = { + handles: { + walk: {} as skinview3d.AnimationHandle, + run: {} as skinview3d.AnimationHandle, + rotate: {} as skinview3d.AnimationHandle, + }, + control: {} as skinview3d.OrbitControls, + firstRun: true, +} + +const Viewer: React.FC = props => { + const viewRef: React.MutableRefObject = useRef(null!) + const containerRef = useRef(null) + const stuffRef = useRef(emptyStuff) + + const [paused, setPaused] = useState(false) + const [reset, setReset] = useState(0) + const indicator = useMemo(() => { + const { skin, cape } = props + if (skin && cape) { + return `${trans('general.skin')} & ${trans('general.cape')}` + } else if (skin) { + return trans('general.skin') + } else if (cape) { + return trans('general.cape') + } + return '' + }, [props.skin, props.cape]) + + useEffect(() => { + const container = containerRef.current! + const viewer = new skinview3d.SkinViewer({ + domElement: container, + width: container.clientWidth, + height: container.clientWidth, + skinUrl: props.skin ?? SkinSteve, + capeUrl: props.cape ?? '', + detectModel: false, + }) + viewer.camera.position.z = props.initPositionZ! + + const animation = new skinview3d.CompositeAnimation() + stuffRef.current.handles = { + walk: animation.add(skinview3d.WalkingAnimation), + run: animation.add(skinview3d.RunningAnimation), + rotate: animation.add(skinview3d.RotatingAnimation), + } + stuffRef.current.handles.run.paused = true + // @ts-ignore + viewer.animation = animation as skinview3d.Animation + stuffRef.current.control = skinview3d.createOrbitControls(viewer) + + if (!stuffRef.current.firstRun) { + const { handles } = stuffRef.current + handles.walk.paused = true + handles.run.paused = true + handles.rotate.paused = true + viewer.camera.position.z = 70 + } + + viewRef.current = viewer + + return () => { + viewer.dispose() + stuffRef.current.firstRun = false + } + }, [reset]) + + useEffect(() => { + const viewer = viewRef.current + viewer.skinUrl = props.skin ?? SkinSteve + }, [props.skin]) + + useEffect(() => { + const viewer = viewRef.current + viewer.capeUrl = props.cape ?? '' + }, [props.cape]) + + useEffect(() => { + const viewer = viewRef.current + viewer.playerObject.skin.slim = props.model === 'alex' + }, [props.model]) + + const togglePause = () => { + setPaused(paused => !paused) + viewRef.current.animationPaused = !viewRef.current.animationPaused + } + + const toggleRun = () => { + const { handles } = stuffRef.current + handles.run.paused = !handles.run.paused + handles.walk.paused = false + } + + const toggleRotate = () => { + const { handles } = stuffRef.current + handles.rotate.paused = !handles.rotate.paused + } + + const handleReset = () => { + setReset(c => c + 1) + } + + return ( +
+
+
+

+ {trans('general.texturePreview')} + {props.showIndicator && ( + {indicator} + )} +

+
+ + + + +
+
+
+
+
+
+ {props.children &&
{props.children}
} +
+ ) +} + +Viewer.defaultProps = { + model: 'steve', + initPositionZ: 70, +} + +export default Viewer diff --git a/resources/assets/src/webpack.d.ts b/resources/assets/src/webpack.d.ts index e89f05da..ff5dbfdf 100644 --- a/resources/assets/src/webpack.d.ts +++ b/resources/assets/src/webpack.d.ts @@ -11,3 +11,7 @@ declare module '*.styl' { declare module '*.scss' { export default {} as Record } + +declare module '*.png' { + export default '' +} diff --git a/resources/assets/tests/components/Viewer.test.tsx b/resources/assets/tests/components/Viewer.test.tsx new file mode 100644 index 00000000..37086f40 --- /dev/null +++ b/resources/assets/tests/components/Viewer.test.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import { trans } from '@/scripts/i18n' +import Viewer from '@/components/Viewer' + +test('custom footer', () => { + const { queryByText } = render(footer) + expect(queryByText('footer')).toBeInTheDocument() +}) + +describe('indicator', () => { + it('hidden by default', () => { + const { queryByText } = render() + expect(queryByText(trans('general.skin'))).not.toBeInTheDocument() + }) + + it('nothing', () => { + const { queryByText } = render() + expect(queryByText(trans('general.skin'))).not.toBeInTheDocument() + expect(queryByText(trans('general.cape'))).not.toBeInTheDocument() + }) + + it('skin only', () => { + const { queryByText } = render() + expect(queryByText(trans('general.skin'))).toBeInTheDocument() + expect(queryByText(trans('general.cape'))).not.toBeInTheDocument() + }) + + it('cape only', () => { + const { queryByText } = render() + expect(queryByText(trans('general.skin'))).not.toBeInTheDocument() + expect(queryByText(trans('general.cape'))).toBeInTheDocument() + }) + + it('skin and cape', () => { + const { queryByText } = render( + , + ) + expect( + queryByText(`${trans('general.skin')} & ${trans('general.cape')}`), + ).toBeInTheDocument() + expect(queryByText(trans('general.skin'))).not.toBeInTheDocument() + expect(queryByText(trans('general.cape'))).not.toBeInTheDocument() + }) +}) + +describe('actions', () => { + it('toggle run', () => { + const { getByTitle } = render() + fireEvent.click( + getByTitle(`${trans('general.walk')} / ${trans('general.run')}`), + ) + }) + + it('toggle rotation', () => { + const { getByTitle } = render() + fireEvent.click(getByTitle(trans('general.rotation'))) + }) + + it('toggle pause', () => { + const { getByTitle } = render() + const icon = getByTitle(trans('general.pause')) + fireEvent.click(icon) + expect(icon).toHaveClass('fa-play') + }) + + it('reset', () => { + const { getByTitle } = render() + fireEvent.click(getByTitle(trans('general.reset'))) + }) +}) diff --git a/resources/assets/tests/webpack.d.ts b/resources/assets/tests/webpack.d.ts index e89f05da..ff5dbfdf 100644 --- a/resources/assets/tests/webpack.d.ts +++ b/resources/assets/tests/webpack.d.ts @@ -11,3 +11,7 @@ declare module '*.styl' { declare module '*.scss' { export default {} as Record } + +declare module '*.png' { + export default '' +}