Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/leva/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Row } from '../UI'
import { StyledButton } from './StyledButton'

type ButtonProps = {
label: string
label: string | JSX.Element
} & Omit<ButtonInput, 'type'>

export function Button({ onClick, settings, label }: ButtonProps) {
Expand Down
53 changes: 53 additions & 0 deletions packages/leva/src/components/UI/JoyCube.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react'
import { StyledJoyCubeFace, StyledJoyCube } from './StyledJoystick3d'

type JoyCubeProps = {
isTop?: boolean
isRight?: boolean
showFront?: boolean
showMid?: boolean
showRear?: boolean
}

export function JoyCube({
isTop,
isRight,
showFront = true,
showMid = true,
showRear = false,
}: JoyCubeProps) {
return (
<StyledJoyCube top={isTop} right={isRight}>
{showFront && (
<>
<StyledJoyCubeFace className="joycube-face--front" />
<StyledJoyCubeFace className="joycube-face--back" />
<StyledJoyCubeFace className="joycube-face--right" />
<StyledJoyCubeFace className="joycube-face--left" />
<StyledJoyCubeFace className="joycube-face--top" />
<StyledJoyCubeFace className="joycube-face--bottom" />
</>
)}
{showMid && (
<>
<StyledJoyCubeFace className="joycube-face--front-mid" />
<StyledJoyCubeFace className="joycube-face--back-mid" />
<StyledJoyCubeFace className="joycube-face--right-mid" />
<StyledJoyCubeFace className="joycube-face--left-mid" />
<StyledJoyCubeFace className="joycube-face--top-mid" />
<StyledJoyCubeFace className="joycube-face--bottom-mid" />
</>
)}
{showRear && (
<>
<StyledJoyCubeFace className="joycube-face--front-rear" />
<StyledJoyCubeFace className="joycube-face--back-rear" />
<StyledJoyCubeFace className="joycube-face--right-rear" />
<StyledJoyCubeFace className="joycube-face--left-rear" />
<StyledJoyCubeFace className="joycube-face--top-rear" />
<StyledJoyCubeFace className="joycube-face--bottom-rear" />
</>
)}
</StyledJoyCube>
)
}
141 changes: 141 additions & 0 deletions packages/leva/src/components/UI/Joystick.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect } from 'react'
import { useDrag } from '../../hooks'
import { clamp, multiplyStep } from '../../utils'
import { JoystickTrigger, JoystickPlayground, JoystickGrid } from './StyledJoystick'
import { useTh } from '../../styles'
import { Portal } from '.'
import { useTransform } from '../../hooks'
import type { Vector2d, Vector3d } from '../../types'
import type { Vector2dProps } from '../../plugins/Vector2d/vector2d-types'

type JoystickProps = { value: Vector2d | Vector3d } & Pick<Vector2dProps, 'onUpdate' | 'settings'> & {
children?: React.ReactNode
}

export function Joystick({ value, settings, onUpdate, children }: JoystickProps) {
const timeout = useRef<number | undefined>()
const outOfBoundsX = useRef(0)
const outOfBoundsY = useRef(0)
const stepMultiplier = useRef(1)

const [joystickShown, setShowJoystick] = useState(false)
const [isOutOfBounds, setIsOutOfBounds] = useState(false)

const [spanRef, set] = useTransform<HTMLSpanElement>()

const joystickeRef = useRef<HTMLDivElement>(null)
const playgroundRef = useRef<HTMLDivElement>(null)

useLayoutEffect(() => {
if (joystickShown) {
const { top, left, width, height } = joystickeRef.current!.getBoundingClientRect()
playgroundRef.current!.style.left = left + width / 2 + 'px'
playgroundRef.current!.style.top = top + height / 2 + 'px'
}
}, [joystickShown])

const {
keys: [v1, v2],
joystick,
} = settings
const yFactor = joystick === 'invertY' ? 1 : -1
// prettier-ignore
const {[v1]: { step: stepV1 }, [v2]: { step: stepV2 }} = settings

const wpx = useTh('sizes', 'joystickWidth')
const hpx = useTh('sizes', 'joystickHeight')

const w = (parseFloat(wpx) * 0.8) / 2
const h = (parseFloat(hpx) * 0.8) / 2

const startOutOfBounds = useCallback(() => {
if (timeout.current) return
setIsOutOfBounds(true)
if (outOfBoundsX.current) set({ x: outOfBoundsX.current * w })
if (outOfBoundsY.current) set({ y: outOfBoundsY.current * -h })
timeout.current = window.setInterval(() => {
onUpdate((v: Vector2d | Vector3d) => {
const incX = stepV1 * outOfBoundsX.current * stepMultiplier.current
const incY = yFactor * stepV2 * outOfBoundsY.current * stepMultiplier.current

return Array.isArray(v)
? {
[v1]: v[['x', 'y', 'z'].indexOf(v1)] + incX,
[v2]: v[['x', 'y', 'z'].indexOf(v2)] + incY,
}
: {
[v1]: v[v1] + incX,
[v2]: v[v2] + incY,
}
})
}, 16)
}, [w, h, onUpdate, set, stepV1, stepV2, v1, v2, yFactor])

const endOutOfBounds = useCallback(() => {
window.clearTimeout(timeout.current)
timeout.current = undefined
setIsOutOfBounds(false)
}, [])

useEffect(() => {
function setStepMultiplier(event: KeyboardEvent) {
stepMultiplier.current = multiplyStep(event)
}
window.addEventListener('keydown', setStepMultiplier)
window.addEventListener('keyup', setStepMultiplier)
return () => {
window.clearTimeout(timeout.current)
window.removeEventListener('keydown', setStepMultiplier)
window.removeEventListener('keyup', setStepMultiplier)
}
}, [])

const bind = useDrag(({ first, active, delta: [dx, dy], movement: [mx, my] }) => {
if (first) setShowJoystick(true)

const _x = clamp(mx, -w, w)
const _y = clamp(my, -h, h)

outOfBoundsX.current = Math.abs(mx) > Math.abs(_x) ? Math.sign(mx - _x) : 0
outOfBoundsY.current = Math.abs(my) > Math.abs(_y) ? Math.sign(_y - my) : 0

// @ts-expect-error
let newX = value[v1]
// @ts-expect-error
let newY = value[v2]

if (active) {
if (!outOfBoundsX.current) {
newX += dx * stepV1 * stepMultiplier.current
set({ x: _x })
}
if (!outOfBoundsY.current) {
newY -= yFactor * dy * stepV2 * stepMultiplier.current
set({ y: _y })
}
if (outOfBoundsX.current || outOfBoundsY.current) startOutOfBounds()
else endOutOfBounds()

onUpdate({ [v1]: newX, [v2]: newY })
} else {
setShowJoystick(false)
outOfBoundsX.current = 0
outOfBoundsY.current = 0
set({ x: 0, y: 0 })
endOutOfBounds()
}
})

return (
<JoystickTrigger ref={joystickeRef} {...bind()}>
{joystickShown && (
<Portal>
<JoystickPlayground ref={playgroundRef} isOutOfBounds={isOutOfBounds}>
{children ? children : <JoystickGrid />}
<span ref={spanRef} />
</JoystickPlayground>
</Portal>
)}
</JoystickTrigger>
)
}
62 changes: 62 additions & 0 deletions packages/leva/src/components/UI/Joystick3d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Joystick } from './Joystick'
import { useKeyPress } from '../../hooks/useKeyPress'
import { JoystickButtons, ButtonLabelContainer, PlaneLabel, KeyLabel } from './StyledJoystick3d'
import { Button } from '../Button'
import type { InternalVector2dSettings } from '../../plugins/Vector2d/vector2d-types'
import type { Vector3d } from '../../types'
import type { Vector3dProps } from '../../plugins/Vector3d/vector3d-types'
import { JoyCube } from './JoyCube'

type Joystick3dProps = { value: Vector3d } & Pick<Vector3dProps, 'onUpdate' | 'settings'>

// Detect OS to show appropriate modifier key label
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform)
const metaKeyLabel = isMac ? '⌘' : 'win'

const joystick3dKeyBindings = [
{ key: 'Control', keyLabel: '^', plane: 'xz', label: 'XZ' },
{ key: '', keyLabel: '', plane: 'xy', label: 'XY' },
{ key: 'Meta', keyLabel: metaKeyLabel, plane: 'zy', label: 'ZY' },
]

export function Joystick3d({ value, settings, onUpdate }: Joystick3dProps) {
const [plane, setPlane] = useState('xy')
const keyPress0 = useKeyPress(joystick3dKeyBindings[0].key)
const keyPress2 = useKeyPress(joystick3dKeyBindings[2].key)

useEffect(() => {
if (keyPress0) setPlane(joystick3dKeyBindings[0].plane)
else if (keyPress2) setPlane(joystick3dKeyBindings[2].plane)
else setPlane(joystick3dKeyBindings[1].plane)
}, [keyPress0, keyPress2])

const settings2d = useMemo(() => {
const { keys, ...rest } = settings
return { keys: plane, ...rest } as unknown as InternalVector2dSettings
}, [settings, plane])

return (
<>
<Joystick value={value} settings={settings2d} onUpdate={onUpdate}>
<JoyCube isTop={keyPress0} isRight={keyPress2} />
<JoystickButtons>
{joystick3dKeyBindings.map((kb) => (
<Button
key={kb.label}
label={
<ButtonLabelContainer>
{kb.keyLabel && <KeyLabel>{kb.keyLabel}</KeyLabel>}
<PlaneLabel>{kb.label}</PlaneLabel>
</ButtonLabelContainer>
}
onClick={() => ''}
settings={{ disabled: plane !== kb.plane }}
/>
))}
</JoystickButtons>
</Joystick>
</>
)
}
81 changes: 81 additions & 0 deletions packages/leva/src/components/UI/StyledJoystick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { styled } from '../../styles'

export const JoystickTrigger = styled('div', {
$flexCenter: '',
position: 'relative',
backgroundColor: '$elevation3',
borderRadius: '$sm',
cursor: 'pointer',
height: '$rowHeight',
width: '$rowHeight',
touchAction: 'none',
$draggable: '',
$hover: '',

'&:active': { cursor: 'none' },

'&::after': {
content: '""',
backgroundColor: '$accent2',
height: 4,
width: 4,
borderRadius: 2,
},
})

export const JoystickPlayground = styled('div', {
$flexCenter: '',
width: '$joystickWidth',
height: '$joystickHeight',
borderRadius: '$sm',
boxShadow: '$level2',
position: 'fixed',
zIndex: 10000,
$draggable: '',
transform: 'translate(-50%, -50%)',

perspective: '100px',

variants: {
isOutOfBounds: {
true: { backgroundColor: '$elevation1' },
false: { backgroundColor: '$elevation3' },
},
},

'> span': {
position: 'relative',
zindex: 100,
width: 10,
height: 10,
backgroundColor: '$accent2',
borderRadius: '50%',
},
})

export const JoystickGrid = styled('div', {
position: 'absolute',
$flexCenter: '',
borderStyle: 'solid',
borderWidth: 1,
borderColor: '$highlight1',
width: '80%',
height: '80%',

'&::after,&::before': {
content: '""',
position: 'absolute',
zindex: 10,
backgroundColor: '$highlight1',
},

'&::before': {
width: '100%',
height: 1,
},

'&::after': {
height: '100%',
width: 1,
},
})
Loading