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

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

// @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={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