@threlte/extras
Version:
Utilities, abstractions and plugins for your Threlte apps
299 lines (298 loc) • 13.1 kB
JavaScript
import { fromStore } from 'svelte/store';
import { useInteractivity } from './context.js';
// Hover identity must match the dedup key used in `getHits`, otherwise the ID
// changes mid-hover (e.g. the hit's face index changes as the ray sweeps a
// plain mesh) and the object flickers between pointerout/pointerenter every
// frame.
function createIntersectionId(intersection) {
const target = intersection.eventObject ?? intersection.object;
if (intersection.instanceId !== undefined) {
return `${target.uuid}|${intersection.instanceId}`;
}
if (intersection.object.isPoints) {
return `${target.uuid}|${intersection.index}`;
}
return target.uuid;
}
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) => {
if (context.hovered.size === 0) {
return;
}
const hitIds = new Set();
for (const intersection of intersections) {
hitIds.add(createIntersectionId(intersection));
}
for (const [id, hoveredObj] of context.hovered) {
// 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 (!hitIds.has(id)) {
const { eventObject } = hoveredObj;
context.hovered.delete(id);
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 rawHits = context.raycaster.intersectObjects(context.interactiveObjects, true);
const seen = new Set();
// Deduplicate hits by object. When recursive=true, intersectObjects searches
// each registered object's full subtree, so a child that is itself registered
// appears once per registered ancestor — causing duplicate events. The key is
// context-sensitive so that legitimate multi-hit objects are preserved:
// InstancedMesh — each instance is a distinct target, key by instanceId
// Points — each point is a distinct target, key by point index
// Mesh / other — uuid only; multiple face hits are the same surface
const hits = rawHits.filter((hit) => {
const key = hit.instanceId !== undefined
? `${hit.object.uuid}|${hit.instanceId}`
: hit.object.isPoints
? `${hit.object.uuid}|${hit.index}`
: hit.object.uuid;
if (seen.has(key))
return false;
seen.add(key);
return 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 handlePointerLeaveOrCancel = () => {
context.pointerOverTarget.set(false);
cancelPointer([]);
};
const handlePointerEnter = () => {
context.pointerOverTarget.set(true);
};
const handleEvent = (event) => {
const name = event.type;
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 and timestamp on pointer-down
if (name === 'pointerdown') {
context.initialClick = [event.offsetX, event.offsetY];
context.initialClickTime = performance.now();
context.initialHits = hits.map((hit) => hit.eventObject);
}
const isClick = isClickEvent &&
delta <= context.clickDistanceThreshold &&
performance.now() - context.initialClickTime <= context.clickTimeThreshold;
// Fire pointermissed for objects that were not under the pointer at pointerdown.
// Must come before the dispatch loop so user-land cleanup runs first.
if (isClick) {
pointerMissed(event, context.interactiveObjects.filter((object) => !context.initialHits.includes(object)));
}
// Update hover state before dispatch so that pointerout/pointerleave fire
// before pointerover/pointerenter on newly hit objects. This ordering is
// important for useCursor and similar hooks that set state in both handlers.
if (isPointerMove)
cancelPointer(hits);
let stopped = false;
let stoppedAt = -1;
// loop through all hits and dispatch events
dispatchEvents: for (const hit of hits) {
const events = handlers.get(hit.eventObject);
if (!events)
continue;
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]);
}
},
stopImmediatePropagation() {
event.stopImmediatePropagation();
},
camera: context.raycaster.camera,
delta,
nativeEvent: event,
pointer: context.pointer.current,
ray: context.raycaster.ray
};
if (isPointerMove) {
// Move event ...
if (events.onpointerover ||
events.onpointerenter ||
events.onpointerout ||
events.onpointerleave) {
const id = createIntersectionId(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);
// If the pointermove handler called stopPropagation, update the hovered
// entry so subsequent moves continue to block farther objects.
if (intersectionEvent.stopped) {
const id = createIntersectionId(intersectionEvent);
const hoveredItem = context.hovered.get(id);
if (hoveredItem) {
hoveredItem.stopped = true;
}
}
}
else {
// All other events
if (events[`on${name}`]) {
if (!isClickEvent || (isClick && context.initialHits.includes(hit.eventObject))) {
events[`on${name}`]?.(intersectionEvent);
}
}
}
if (stopped) {
stoppedAt = hits.indexOf(hit);
break dispatchEvents;
}
}
// When propagation was stopped, run cancelPointer again with only the hits
// up to the stopped object. The pre-loop cancelPointer passed all hits, so
// farther objects were still considered "hovered". This second pass removes
// them and fires pointerout/pointerleave.
if (isPointerMove && stopped) {
cancelPointer(hits.slice(0, stoppedAt + 1));
}
};
let moveRAF = 0;
let queuedMoveEvent = null;
let lastMoveX = -Infinity;
let lastMoveY = -Infinity;
const MIN_MOVE_DELTA = 0.25; // pixels; ignore tiny jitter
// Process the first pointermove in a frame immediately for responsive hover
// updates, then coalesce any additional moves within the same frame into one
// deferred rAF call. This avoids the one-frame lag that causes cursor flicker
// when moving rapidly between interactive objects.
const handlePointerMove = (event) => {
// ignore sub-pixel jitter to cut redundant raycasts
if (Math.abs(event.offsetX - lastMoveX) < MIN_MOVE_DELTA &&
Math.abs(event.offsetY - lastMoveY) < MIN_MOVE_DELTA) {
return;
}
lastMoveX = event.offsetX;
lastMoveY = event.offsetY;
if (!moveRAF) {
// First move this frame — process immediately
handleEvent(event);
// Schedule a rAF to catch any coalesced moves that arrive before the next frame
moveRAF = requestAnimationFrame(() => {
moveRAF = 0;
if (queuedMoveEvent) {
handleEvent(queuedMoveEvent);
queuedMoveEvent = null;
}
});
}
else {
// Additional moves this frame — queue for the rAF callback
queuedMoveEvent = event;
}
};
const disconnect = (target) => {
for (const [eventName] of DOM_EVENTS) {
if (eventName === 'pointerleave' || eventName === 'pointercancel') {
target.removeEventListener(eventName, handlePointerLeaveOrCancel);
}
else if (eventName === 'pointermove') {
target.removeEventListener(eventName, handlePointerMove);
}
else if (eventName === 'pointerenter') {
target.removeEventListener(eventName, handlePointerEnter);
}
else {
target.removeEventListener(eventName, handleEvent);
}
}
};
const connect = (target) => {
for (const [eventName, defaultPassive] of DOM_EVENTS) {
const passive = context.eventOptions?.[eventName]?.passive ?? defaultPassive;
if (eventName === 'pointerleave' || eventName === 'pointercancel') {
target.addEventListener(eventName, handlePointerLeaveOrCancel, { passive });
}
else if (eventName === 'pointermove') {
target.addEventListener(eventName, handlePointerMove, { passive });
}
else if (eventName === 'pointerenter') {
target.addEventListener(eventName, handlePointerEnter, { passive });
}
else {
target.addEventListener(eventName, handleEvent, { passive });
}
}
};
const target = fromStore(context.target);
$effect.pre(() => {
const { current } = target;
if (!current)
return;
connect(current);
return () => {
disconnect(current);
};
});
};