diff --git a/.changeset/pink-cups-refuse.md b/.changeset/pink-cups-refuse.md new file mode 100644 index 00000000..5575744f --- /dev/null +++ b/.changeset/pink-cups-refuse.md @@ -0,0 +1,5 @@ +--- +"leva": patch +--- + +feat: adds joystick3d with modifier keys diff --git a/packages/leva/src/components/Button/Button.tsx b/packages/leva/src/components/Button/Button.tsx index 599fb3d0..d275e100 100644 --- a/packages/leva/src/components/Button/Button.tsx +++ b/packages/leva/src/components/Button/Button.tsx @@ -5,7 +5,7 @@ import { Row } from '../UI' import { StyledButton } from './StyledButton' type ButtonProps = { - label: string + label: string | JSX.Element } & Omit export function Button({ onClick, settings, label }: ButtonProps) { diff --git a/packages/leva/src/components/UI/JoyCube.tsx b/packages/leva/src/components/UI/JoyCube.tsx new file mode 100644 index 00000000..1cf2191d --- /dev/null +++ b/packages/leva/src/components/UI/JoyCube.tsx @@ -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 ( + + {showFront && ( + <> + + + + + + + + )} + {showMid && ( + <> + + + + + + + + )} + {showRear && ( + <> + + + + + + + + )} + + ) +} diff --git a/packages/leva/src/components/UI/Joystick.tsx b/packages/leva/src/components/UI/Joystick.tsx new file mode 100644 index 00000000..145151ec --- /dev/null +++ b/packages/leva/src/components/UI/Joystick.tsx @@ -0,0 +1,154 @@ +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 & { + children?: React.ReactNode +} + +const AXIS_KEYS = ['x', 'y', 'z'] as const +const AXIS_INDEX: Record = { x: 0, y: 1, z: 2 } + +/** + * 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() + 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() + + const joystickRef = useRef(null) + const playgroundRef = useRef(null) + + useLayoutEffect(() => { + if (joystickShown) { + const rect = joystickRef.current?.getBoundingClientRect() + const playground = playgroundRef.current + if (rect && playground) { + playground.style.left = rect.left + rect.width / 2 + 'px' + playground.style.top = rect.top + rect.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 { + [v1]: getAxisValue(v, v1) + incX, + [v2]: getAxisValue(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 ( + + {joystickShown && ( + + + {children || } + + + + )} + + ) +} diff --git a/packages/leva/src/components/UI/Joystick3d.tsx b/packages/leva/src/components/UI/Joystick3d.tsx new file mode 100644 index 00000000..34453f4c --- /dev/null +++ b/packages/leva/src/components/UI/Joystick3d.tsx @@ -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 + +// 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 ( + <> + + + + {joystick3dKeyBindings.map((kb) => ( +