Skip to content
Open
Show file tree
Hide file tree
Changes from 18 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>
)
}
155 changes: 155 additions & 0 deletions packages/leva/src/components/UI/Joystick.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
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
}

const AXIS_KEYS = ['x', 'y', 'z'] as const

/**
* Type-safe helper to extract axis values from Vector2d/Vector3d
* @param value - Array or object representation of vector
* @param axis - Axis key ('x', 'y', or 'z')
* @returns The numeric value for the given axis, or 0 if not found
*/
function getAxisValue(value: Vector2d | Vector3d, axis: string): number {
if (Array.isArray(value)) {
const index = AXIS_KEYS.indexOf(axis as 'x' | 'y' | 'z')
return index !== -1 ? value[index] : 0
}
return value[axis as keyof typeof value] ?? 0
}

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 joystickRef = useRef<HTMLDivElement>(null)
const playgroundRef = useRef<HTMLDivElement>(null)

useLayoutEffect(() => {
if (joystickShown) {
const { top, left, width, height } = joystickRef.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[AXIS_KEYS.indexOf(v1 as 'x' | 'y' | 'z')] + incX,
[v2]: v[AXIS_KEYS.indexOf(v2 as 'x' | 'y' | 'z')] + incY,
}
: {
[v1]: v[v1] + incX,
[v2]: v[v2] + incY,
}
})
}, 16)
}, [w, h, onUpdate, set, stepV1, stepV2, v1, v2, yFactor])

const endOutOfBounds = useCallback(() => {
window.clearInterval(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.clearInterval(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

let newX = getAxisValue(value, v1)
let newY = getAxisValue(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={joystickRef} {...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