build skin viewer with React
This commit is contained in:
parent
e01af8e7d9
commit
f0599d5a88
20
resources/assets/src/components/Viewer.scss
Normal file
20
resources/assets/src/components/Viewer.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
183
resources/assets/src/components/Viewer.tsx
Normal file
183
resources/assets/src/components/Viewer.tsx
Normal file
@ -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> = props => {
|
||||
const viewRef: React.MutableRefObject<skinview3d.SkinViewer> = useRef(null!)
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex justify-content-between">
|
||||
<h3 className="card-title">
|
||||
<span>{trans('general.texturePreview')}</span>
|
||||
{props.showIndicator && (
|
||||
<span className="badge bg-olive">{indicator}</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className={styles.actions}>
|
||||
<i
|
||||
className="fas fa-forward"
|
||||
data-toggle="tooltip"
|
||||
data-placement="bottom"
|
||||
title={`${trans('general.walk')} / ${trans('general.run')}`}
|
||||
onClick={toggleRun}
|
||||
></i>
|
||||
<i
|
||||
className="fas fa-redo-alt"
|
||||
data-toggle="tooltip"
|
||||
data-placement="bottom"
|
||||
title={trans('general.rotation')}
|
||||
onClick={toggleRotate}
|
||||
></i>
|
||||
<i
|
||||
className={`fas fa-${paused ? 'play' : 'pause'}`}
|
||||
data-toggle="tooltip"
|
||||
data-placement="bottom"
|
||||
title={trans('general.pause')}
|
||||
onClick={togglePause}
|
||||
></i>
|
||||
<i
|
||||
className="fas fa-stop"
|
||||
data-toggle="tooltip"
|
||||
data-placement="bottom"
|
||||
title={trans('general.reset')}
|
||||
onClick={handleReset}
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div ref={containerRef} className={styles.viewer}></div>
|
||||
</div>
|
||||
{props.children && <div className="card-footer">{props.children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Viewer.defaultProps = {
|
||||
model: 'steve',
|
||||
initPositionZ: 70,
|
||||
}
|
||||
|
||||
export default Viewer
|
4
resources/assets/src/webpack.d.ts
vendored
4
resources/assets/src/webpack.d.ts
vendored
@ -11,3 +11,7 @@ declare module '*.styl' {
|
||||
declare module '*.scss' {
|
||||
export default {} as Record<string, string>
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
export default ''
|
||||
}
|
||||
|
71
resources/assets/tests/components/Viewer.test.tsx
Normal file
71
resources/assets/tests/components/Viewer.test.tsx
Normal file
@ -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(<Viewer>footer</Viewer>)
|
||||
expect(queryByText('footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('indicator', () => {
|
||||
it('hidden by default', () => {
|
||||
const { queryByText } = render(<Viewer skin="skin" />)
|
||||
expect(queryByText(trans('general.skin'))).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('nothing', () => {
|
||||
const { queryByText } = render(<Viewer showIndicator />)
|
||||
expect(queryByText(trans('general.skin'))).not.toBeInTheDocument()
|
||||
expect(queryByText(trans('general.cape'))).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skin only', () => {
|
||||
const { queryByText } = render(<Viewer skin="skin" showIndicator />)
|
||||
expect(queryByText(trans('general.skin'))).toBeInTheDocument()
|
||||
expect(queryByText(trans('general.cape'))).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cape only', () => {
|
||||
const { queryByText } = render(<Viewer cape="cape" showIndicator />)
|
||||
expect(queryByText(trans('general.skin'))).not.toBeInTheDocument()
|
||||
expect(queryByText(trans('general.cape'))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skin and cape', () => {
|
||||
const { queryByText } = render(
|
||||
<Viewer skin="skin" cape="cape" showIndicator />,
|
||||
)
|
||||
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(<Viewer />)
|
||||
fireEvent.click(
|
||||
getByTitle(`${trans('general.walk')} / ${trans('general.run')}`),
|
||||
)
|
||||
})
|
||||
|
||||
it('toggle rotation', () => {
|
||||
const { getByTitle } = render(<Viewer />)
|
||||
fireEvent.click(getByTitle(trans('general.rotation')))
|
||||
})
|
||||
|
||||
it('toggle pause', () => {
|
||||
const { getByTitle } = render(<Viewer />)
|
||||
const icon = getByTitle(trans('general.pause'))
|
||||
fireEvent.click(icon)
|
||||
expect(icon).toHaveClass('fa-play')
|
||||
})
|
||||
|
||||
it('reset', () => {
|
||||
const { getByTitle } = render(<Viewer />)
|
||||
fireEvent.click(getByTitle(trans('general.reset')))
|
||||
})
|
||||
})
|
4
resources/assets/tests/webpack.d.ts
vendored
4
resources/assets/tests/webpack.d.ts
vendored
@ -11,3 +11,7 @@ declare module '*.styl' {
|
||||
declare module '*.scss' {
|
||||
export default {} as Record<string, string>
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
export default ''
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user