import * as React from 'react' import * as THREE from 'three' import { SelectionBox } from 'three-stdlib' import { useThree } from '@react-three/fiber' import { shallow } from 'zustand/shallow' const context = /* @__PURE__ */ React.createContext([]) type Props = JSX.IntrinsicElements['group'] & { /** Allow multi select, default: false */ multiple?: boolean /** Allow box select, default: false */ box?: boolean /** Custom CSS border: default: '1px solid #55aaff' */ border?: string /** Curom CSS color, default: 'rgba(75, 160, 255, 0.1)' */ backgroundColor?: string /** Callback for selection changes */ onChange?: (selected: THREE.Object3D[]) => void /** Callback for selection changes once the pointer is up */ onChangePointerUp?: (selected: THREE.Object3D[]) => void /** Optional filter for filtering the selection */ filter?: (selected: THREE.Object3D[]) => THREE.Object3D[] } export function Select({ box, multiple, children, onChange, onChangePointerUp, border = '1px solid #55aaff', backgroundColor = 'rgba(75, 160, 255, 0.1)', filter: customFilter = (item) => item, ...props }: Props) { const [downed, down] = React.useState(false) const { setEvents, camera, raycaster, gl, controls, size, get } = useThree() const [hovered, hover] = React.useState(false) const [active, dispatch] = React.useReducer( (state, { object, shift }: { object?: THREE.Object3D | THREE.Object3D[]; shift?: boolean }): THREE.Object3D[] => { if (object === undefined) return [] else if (Array.isArray(object)) return object else if (!shift) return state[0] === object ? [] : [object] else if (state.includes(object)) return state.filter((o) => o !== object) else return [object, ...state] }, [] ) React.useEffect(() => { if (downed) onChange?.(active) else onChangePointerUp?.(active) }, [active, downed]) const onClick = React.useCallback((e) => { e.stopPropagation() dispatch({ object: customFilter([e.object])[0], shift: multiple && e.shiftKey }) }, []) const onPointerMissed = React.useCallback((e) => !hovered && dispatch({}), [hovered]) const ref = React.useRef(null!) React.useEffect(() => { if (!box || !multiple) return const selBox = new SelectionBox(camera, ref.current as unknown as THREE.Scene) const element = document.createElement('div') element.style.pointerEvents = 'none' element.style.border = border element.style.backgroundColor = backgroundColor element.style.position = 'fixed' const startPoint = new THREE.Vector2() const pointTopLeft = new THREE.Vector2() const pointBottomRight = new THREE.Vector2() const oldRaycasterEnabled = get().events.enabled const oldControlsEnabled = (controls as any)?.enabled let isDown = false function prepareRay(event, vec) { const { offsetX, offsetY } = event const { width, height } = size vec.set((offsetX / width) * 2 - 1, -(offsetY / height) * 2 + 1) } function onSelectStart(event) { if (controls) (controls as any).enabled = false setEvents({ enabled: false }) down((isDown = true)) gl.domElement.parentElement?.appendChild(element) element.style.left = `${event.clientX}px` element.style.top = `${event.clientY}px` element.style.width = '0px' element.style.height = '0px' startPoint.x = event.clientX startPoint.y = event.clientY } function onSelectMove(event) { pointBottomRight.x = Math.max(startPoint.x, event.clientX) pointBottomRight.y = Math.max(startPoint.y, event.clientY) pointTopLeft.x = Math.min(startPoint.x, event.clientX) pointTopLeft.y = Math.min(startPoint.y, event.clientY) element.style.left = `${pointTopLeft.x}px` element.style.top = `${pointTopLeft.y}px` element.style.width = `${pointBottomRight.x - pointTopLeft.x}px` element.style.height = `${pointBottomRight.y - pointTopLeft.y}px` } function onSelectOver() { if (isDown) { if (controls) (controls as any).enabled = oldControlsEnabled setEvents({ enabled: oldRaycasterEnabled }) down((isDown = false)) element.parentElement?.removeChild(element) } } function pointerDown(event) { if (event.shiftKey) { onSelectStart(event) prepareRay(event, selBox.startPoint) } } let previous: THREE.Mesh[] = [] function pointerMove(event) { if (isDown) { onSelectMove(event) prepareRay(event, selBox.endPoint) const allSelected = selBox .select() .sort((o) => (o as any).uuid) .filter((o) => o.isMesh) if (!shallow(allSelected, previous)) { previous = allSelected dispatch({ object: customFilter(allSelected) }) } } } function pointerUp(event) { if (isDown) onSelectOver() } document.addEventListener('pointerdown', pointerDown, { passive: true }) document.addEventListener('pointermove', pointerMove, { passive: true, capture: true }) document.addEventListener('pointerup', pointerUp, { passive: true }) return () => { document.removeEventListener('pointerdown', pointerDown) document.removeEventListener('pointermove', pointerMove, true) document.removeEventListener('pointerup', pointerUp) } }, [size.width, size.height, raycaster, camera, controls, gl]) return ( hover(true)} onPointerOut={() => hover(false)} onPointerMissed={onPointerMissed} {...props} > {children} ) } // The return type is explicitly declared here because otherwise TypeScript will emit `THREE.Object3D[]`. // The meaning of the generic parameter for `Object3D` was changed in r156, so it should not be included so that it // works with all versions of @types/three. export function useSelect(): THREE.Object3D[] { return React.useContext(context) }