UNPKG

@threlte/extras

Version:

Utilities, abstractions and plugins for your Threlte apps

197 lines (196 loc) 8.55 kB
import { watch } from '@threlte/core'; import { useInteractivity } from './context'; function getIntersectionId(event) { return `${(event.eventObject || event.object).uuid}/${event.index}${event.instanceId}`; } const DOM_EVENTS = [ ['click', false], ['contextmenu', false], ['dblclick', false], ['wheel', false], ['pointerdown', true], ['pointerup', true], ['pointerleave', true], ['pointerenter', true], ['pointermove', true], ['pointercancel', true] ]; export const setupInteractivity = (context) => { const { handlers } = useInteractivity(); const calculateDistance = (event) => { const dx = event.offsetX - context.initialClick[0]; const dy = event.offsetY - context.initialClick[1]; return Math.round(Math.hypot(dx, dy)); }; const cancelPointer = (intersections) => { for (const hoveredObj of context.hovered.values()) { // When no objects were hit or the the hovered object wasn't found underneath the cursor // we call pointerout and delete the object from the hovered elements map if (intersections.length === 0 || !intersections.some((hit) => { return (hit.object === hoveredObj.object && hit.index === hoveredObj.index && hit.instanceId === hoveredObj.instanceId); })) { const { eventObject } = hoveredObj; context.hovered.delete(getIntersectionId(hoveredObj)); const events = handlers.get(eventObject); if (events) { // Clear out intersects, they are outdated by now const data = { ...hoveredObj, intersections }; events.onpointerout?.(data); events.onpointerleave?.(data); } } } }; const getHits = () => { if (!context.enabled.current) return []; const intersections = []; const hits = context.raycaster.intersectObjects(context.interactiveObjects, true); const filtered = context.filter === undefined ? hits : context.filter(hits, context); // Bubble up the events, find the event source (eventObject) for (const hit of filtered) { let eventObject = hit.object; // Bubble event up while (eventObject) { if (handlers.has(eventObject)) intersections.push({ ...hit, eventObject }); eventObject = eventObject.parent; } } return intersections; }; const pointerMissed = (event, objects) => { for (const object of objects) { handlers.get(object)?.onpointermissed?.(event); } }; const getEventHandler = (name) => { // Deal with cancelation if (name === 'pointerleave' || name === 'pointercancel') { return () => { context.pointerOverTarget.set(false); cancelPointer([]); }; } if (name === 'pointerenter') { return () => { context.pointerOverTarget.set(true); }; } return (event) => { const isPointerMove = name === 'pointermove'; const isClickEvent = name === 'click' || name === 'contextmenu' || name === 'dblclick'; /** * Will set up the raycaster. The default implementation will use the * mouse position on the renderers domElement. */ context.compute(event, context); const hits = getHits(); const delta = isClickEvent ? calculateDistance(event) : 0; // Save initial coordinates on pointer-down if (name === 'pointerdown') { context.initialClick = [event.offsetX, event.offsetY]; context.initialHits = hits.map((hit) => hit.eventObject); } // If a click yields no results, pass it back to the user as a miss // Missed events have to come first in order to establish user-land side-effect clean up if (isClickEvent && hits.length === 0) { if (delta <= 2) { pointerMissed(event, context.interactiveObjects); } } // Take care of unhover if (isPointerMove) cancelPointer(hits); let stopped = false; // loop through all hits and dispatch events dispatchEvents: for (const hit of hits) { const intersectionEvent = { stopped, ...hit, intersections: hits, stopPropagation() { stopped = true; intersectionEvent.stopped = true; if (context.hovered.size > 0 && Array.from(context.hovered.values()).some((i) => i.eventObject === hit.eventObject)) { // Objects cannot flush out higher up objects that have already caught the event const higher = hits.slice(0, hits.indexOf(hit)); cancelPointer([...higher, hit]); } }, camera: context.raycaster.camera, delta, nativeEvent: event, pointer: context.pointer.current, ray: context.raycaster.ray }; const events = handlers.get(hit.eventObject); if (!events) return; if (isPointerMove) { // Move event ... if (events.onpointerover || events.onpointerenter || events.onpointerout || events.onpointerleave) { const id = getIntersectionId(intersectionEvent); const hoveredItem = context.hovered.get(id); if (!hoveredItem) { // If the object wasn't previously hovered, book it and call its handler context.hovered.set(id, intersectionEvent); events.onpointerover?.(intersectionEvent); events.onpointerenter?.(intersectionEvent); } else if (hoveredItem.stopped) { // If the object was previously hovered and stopped, we shouldn't allow other items to proceed intersectionEvent.stopPropagation(); } } // Call pointer move events.onpointermove?.(intersectionEvent); } else { // All other events if (events[`on${name}`]) { if (!isClickEvent || context.initialHits.includes(hit.eventObject)) { // Missed events have to come first pointerMissed(event, context.interactiveObjects.filter((object) => !context.initialHits.includes(object))); // Call the event events[`on${name}`]?.(intersectionEvent); } } else { // "Real" click event if (isClickEvent && context.initialHits.includes(hit.eventObject)) { pointerMissed(event, context.interactiveObjects.filter((object) => !context.initialHits.includes(object))); } } } if (stopped) break dispatchEvents; } }; }; const disconnect = (target) => { DOM_EVENTS.forEach(([eventName]) => { target.removeEventListener(eventName, getEventHandler(eventName)); }); }; const connect = (target) => { DOM_EVENTS.forEach(([eventName, passive]) => { target.addEventListener(eventName, getEventHandler(eventName), { passive }); }); }; watch(context.target, (target) => { if (target) connect(target); return () => { if (target) disconnect(target); }; }); };