aframe
Version:
A web framework for building virtual reality experiences.
556 lines (471 loc) • 18.9 kB
JavaScript
/* global MouseEvent, TouchEvent */
import * as THREE from 'three';
import { registerComponent } from '../core/component.js';
import * as utils from '../utils/index.js';
var EVENTS = {
CLICK: 'click',
FUSING: 'fusing',
MOUSEENTER: 'mouseenter',
MOUSEDOWN: 'mousedown',
MOUSELEAVE: 'mouseleave',
MOUSEUP: 'mouseup'
};
var STATES = {
FUSING: 'cursor-fusing',
HOVERING: 'cursor-hovering',
HOVERED: 'cursor-hovered'
};
var CANVAS_EVENTS = {
DOWN: ['mousedown', 'touchstart'],
UP: ['mouseup', 'touchend']
};
var WEBXR_EVENTS = {
DOWN: ['selectstart'],
UP: ['selectend']
};
var CANVAS_HOVER_CLASS = 'a-mouse-cursor-hover';
/**
* Cursor component. Applies the raycaster component specifically for starting the raycaster
* from the camera and pointing from camera's facing direction, and then only returning the
* closest intersection. Cursor can be fine-tuned by setting raycaster properties.
*
* @member {object} fuseTimeout - Timeout to trigger fuse-click.
* @member {Element} cursorDownEl - Entity that was last mousedowned during current click.
* @member {object} intersection - Attributes of the current intersection event, including
* 3D- and 2D-space coordinates. See: http://threejs.org/docs/api/core/Raycaster.html
* @member {Element} intersectedEl - Currently-intersected entity. Used to keep track to
* emit events when unintersecting.
*/
export var Component = registerComponent('cursor', {
dependencies: ['raycaster'],
schema: {
downEvents: {default: []},
fuse: {default: utils.device.isMobile()},
fuseTimeout: {default: 1500, min: 0},
mouseCursorStylesEnabled: {default: true},
upEvents: {default: []},
rayOrigin: {default: 'entity', oneOf: ['mouse', 'entity', 'xrselect']}
},
after: ['tracked-controls'],
multiple: true,
init: function () {
var self = this;
this.fuseTimeout = undefined;
this.cursorDownEl = null;
this.intersectedEl = null;
this.canvasBounds = document.body.getBoundingClientRect();
this.isCursorDown = false;
this.activeXRInput = null;
// Debounce.
this.updateCanvasBounds = utils.debounce(function updateCanvasBounds () {
self.canvasBounds = self.el.sceneEl.canvas.getBoundingClientRect();
}, 500);
this.eventDetail = {};
this.intersectedEventDetail = {cursorEl: this.el};
// Bind methods.
this.onCursorDown = this.onCursorDown.bind(this);
this.onCursorUp = this.onCursorUp.bind(this);
this.onIntersection = this.onIntersection.bind(this);
this.onIntersectionCleared = this.onIntersectionCleared.bind(this);
this.onMouseMove = this.onMouseMove.bind(this);
this.onEnterVR = this.onEnterVR.bind(this);
},
update: function (oldData) {
var rayOrigin = this.data.rayOrigin;
if (rayOrigin === oldData.rayOrigin) { return; }
if (rayOrigin === 'entity') { this.resetRaycaster(); }
this.updateMouseEventListeners();
// Update the WebXR event listeners if needed.
// This handles the cases a cursor is created or has its rayOrigin changed during an XR session.
// In the case the cursor is created before we have an active XR session, it does not add the WebXR event listeners here (addWebXREventListeners is a no-op without xrSession), upon onEnterVR they are added.
if (rayOrigin === 'xrselect' || rayOrigin === 'entity') {
this.addWebXREventListeners();
}
if (oldData.rayOrigin === 'xrselect' || oldData.rayOrigin === 'entity') {
this.removeWebXREventListeners();
}
},
tick: function () {
// Update on frame to allow someone to select and mousemove
var frame = this.el.sceneEl.frame;
var inputSource = this.activeXRInput;
if (this.data.rayOrigin === 'xrselect' && frame && inputSource) {
this.onMouseMove({
frame: frame,
inputSource: inputSource,
type: 'fakeselectevent'
});
}
},
play: function () {
this.addEventListeners();
},
pause: function () {
this.removeEventListeners();
},
remove: function () {
var el = this.el;
el.removeState(STATES.HOVERING);
el.removeState(STATES.FUSING);
clearTimeout(this.fuseTimeout);
if (this.intersectedEl) { this.intersectedEl.removeState(STATES.HOVERED); }
this.removeEventListeners();
},
addEventListeners: function () {
var canvas;
var data = this.data;
var el = this.el;
var self = this;
function addCanvasListeners () {
canvas = el.sceneEl.canvas;
if (data.downEvents.length || data.upEvents.length) { return; }
CANVAS_EVENTS.DOWN.forEach(function (downEvent) {
canvas.addEventListener(downEvent, self.onCursorDown, {passive: false});
});
CANVAS_EVENTS.UP.forEach(function (upEvent) {
canvas.addEventListener(upEvent, self.onCursorUp, {passive: false});
});
}
canvas = el.sceneEl.canvas;
if (canvas) {
addCanvasListeners();
} else {
el.sceneEl.addEventListener('render-target-loaded', addCanvasListeners);
}
data.downEvents.forEach(function (downEvent) {
el.addEventListener(downEvent, self.onCursorDown);
});
data.upEvents.forEach(function (upEvent) {
el.addEventListener(upEvent, self.onCursorUp);
});
el.addEventListener('raycaster-intersection', this.onIntersection);
el.addEventListener('raycaster-closest-entity-changed', this.onIntersection);
el.addEventListener('raycaster-intersection-cleared', this.onIntersectionCleared);
el.sceneEl.addEventListener('rendererresize', this.updateCanvasBounds);
el.sceneEl.addEventListener('enter-vr', this.onEnterVR);
window.addEventListener('resize', this.updateCanvasBounds);
window.addEventListener('scroll', this.updateCanvasBounds);
this.updateMouseEventListeners();
},
removeEventListeners: function () {
var canvas;
var data = this.data;
var el = this.el;
var self = this;
canvas = el.sceneEl.canvas;
if (canvas && !data.downEvents.length && !data.upEvents.length) {
CANVAS_EVENTS.DOWN.forEach(function (downEvent) {
canvas.removeEventListener(downEvent, self.onCursorDown);
});
CANVAS_EVENTS.UP.forEach(function (upEvent) {
canvas.removeEventListener(upEvent, self.onCursorUp);
});
}
data.downEvents.forEach(function (downEvent) {
el.removeEventListener(downEvent, self.onCursorDown);
});
data.upEvents.forEach(function (upEvent) {
el.removeEventListener(upEvent, self.onCursorUp);
});
el.removeEventListener('raycaster-intersection', this.onIntersection);
el.removeEventListener('raycaster-closest-entity-changed', this.onIntersection);
el.removeEventListener('raycaster-intersection-cleared', this.onIntersectionCleared);
canvas.removeEventListener('mousemove', this.onMouseMove);
canvas.removeEventListener('touchstart', this.onMouseMove);
canvas.removeEventListener('touchmove', this.onMouseMove);
el.sceneEl.removeEventListener('rendererresize', this.updateCanvasBounds);
el.sceneEl.removeEventListener('enter-vr', this.onEnterVR);
window.removeEventListener('resize', this.updateCanvasBounds);
window.removeEventListener('scroll', this.updateCanvasBounds);
this.removeWebXREventListeners();
},
updateMouseEventListeners: function () {
var canvas;
var el = this.el;
canvas = el.sceneEl.canvas;
canvas.removeEventListener('mousemove', this.onMouseMove);
canvas.removeEventListener('touchmove', this.onMouseMove);
el.setAttribute('raycaster', 'useWorldCoordinates', false);
if (this.data.rayOrigin !== 'mouse') { return; }
canvas.addEventListener('mousemove', this.onMouseMove);
canvas.addEventListener('touchmove', this.onMouseMove, {passive: false});
el.setAttribute('raycaster', 'useWorldCoordinates', true);
this.updateCanvasBounds();
},
resetRaycaster: function () {
this.el.setAttribute('raycaster', {
direction: new THREE.Vector3().set(0, 0, -1),
origin: new THREE.Vector3()
});
},
addWebXREventListeners: function () {
var self = this;
var xrSession = this.el.sceneEl.xrSession;
if (xrSession) {
WEBXR_EVENTS.DOWN.forEach(function (downEvent) {
xrSession.addEventListener(downEvent, self.onCursorDown);
});
WEBXR_EVENTS.UP.forEach(function (upEvent) {
xrSession.addEventListener(upEvent, self.onCursorUp);
});
}
},
removeWebXREventListeners: function () {
var self = this;
var xrSession = this.el.sceneEl.xrSession;
if (xrSession) {
WEBXR_EVENTS.DOWN.forEach(function (downEvent) {
xrSession.removeEventListener(downEvent, self.onCursorDown);
});
WEBXR_EVENTS.UP.forEach(function (upEvent) {
xrSession.removeEventListener(upEvent, self.onCursorUp);
});
}
},
onMouseMove: (function () {
var direction = new THREE.Vector3();
var mouse = new THREE.Vector2();
var origin = new THREE.Vector3();
var rayCasterConfig = {origin: origin, direction: direction};
return function (evt) {
var bounds = this.canvasBounds;
var camera = this.el.sceneEl.camera;
var cameraElParent;
var left;
var point;
var top;
var frame;
var inputSource;
var referenceSpace;
var pose;
var transform;
camera.parent.updateMatrixWorld();
// Calculate mouse position based on the canvas element
if (evt.type === 'touchmove' || evt.type === 'touchstart') {
// Track the first touch for simplicity.
point = evt.touches.item(0);
} else {
point = evt;
}
left = point.clientX - bounds.left;
top = point.clientY - bounds.top;
mouse.x = (left / bounds.width) * 2 - 1;
mouse.y = -(top / bounds.height) * 2 + 1;
if (this.data.rayOrigin === 'xrselect' && (evt.type === 'selectstart' || evt.type === 'fakeselectevent')) {
frame = evt.frame;
inputSource = evt.inputSource;
referenceSpace = this.el.sceneEl.renderer.xr.getReferenceSpace();
pose = frame.getPose(inputSource.targetRaySpace, referenceSpace);
if (pose) {
transform = pose.transform;
direction.set(0, 0, -1);
direction.applyQuaternion(transform.orientation);
origin.copy(transform.position);
// Transform XRPose into world space
cameraElParent = camera.el.object3D.parent;
cameraElParent.localToWorld(origin);
direction.transformDirection(cameraElParent.matrixWorld);
}
} else if (evt.type === 'fakeselectout') {
direction.set(0, 1, 0);
origin.set(0, 9999, 0);
} else if (camera && camera.isPerspectiveCamera) {
origin.setFromMatrixPosition(camera.matrixWorld);
direction.set(mouse.x, mouse.y, 0.5).unproject(camera).sub(origin).normalize();
} else if (camera && camera.isOrthographicCamera) {
origin.set(mouse.x, mouse.y, (camera.near + camera.far) / (camera.near - camera.far)).unproject(camera); // set origin in plane of camera
direction.set(0, 0, -1).transformDirection(camera.matrixWorld);
} else {
console.error('AFRAME.Raycaster: Unsupported camera type: ' + camera.type);
}
this.el.setAttribute('raycaster', rayCasterConfig);
if (evt.type === 'touchmove') { evt.preventDefault(); }
};
})(),
/**
* Trigger mousedown and keep track of the mousedowned entity.
*/
onCursorDown: function (evt) {
this.isCursorDown = true;
// Raycast again for touch.
if (this.data.rayOrigin === 'mouse' && evt.type === 'touchstart') {
this.onMouseMove(evt);
this.el.components.raycaster.checkIntersections();
evt.preventDefault();
}
if (this.data.rayOrigin === 'xrselect' && evt.type === 'selectstart') {
this.activeXRInput = evt.inputSource;
this.onMouseMove(evt);
this.el.components.raycaster.checkIntersections();
// if something was tapped on don't do ar-hit-test things
if (
this.el.components.raycaster.intersectedEls.length &&
this.el.sceneEl.components['ar-hit-test'] !== undefined &&
this.el.sceneEl.getAttribute('ar-hit-test').enabled
) {
// Cancel the ar-hit-test behaviours and disable the ar-hit-test
this.el.sceneEl.setAttribute('ar-hit-test', 'enabled', false);
this.reenableARHitTest = true;
}
}
this.twoWayEmit(EVENTS.MOUSEDOWN, evt);
this.cursorDownEl = this.intersectedEl;
},
/**
* Trigger mouseup if:
* - Not fusing (mobile has no mouse).
* - Currently intersecting an entity.
* - Currently-intersected entity is the same as the one when mousedown was triggered,
* in case user mousedowned one entity, dragged to another, and mouseupped.
*/
onCursorUp: function (evt) {
if (!this.isCursorDown) { return; }
if (this.data.rayOrigin === 'xrselect' && this.activeXRInput !== evt.inputSource) { return; }
this.isCursorDown = false;
var data = this.data;
this.twoWayEmit(EVENTS.MOUSEUP, evt);
if (this.reenableARHitTest === true) {
this.el.sceneEl.setAttribute('ar-hit-test', 'enabled', true);
this.reenableARHitTest = undefined;
}
// If intersected entity has changed since the cursorDown, still emit mouseUp on the
// previously cursorUp entity.
if (this.cursorDownEl && this.cursorDownEl !== this.intersectedEl) {
this.intersectedEventDetail.intersection = null;
this.cursorDownEl.emit(EVENTS.MOUSEUP, this.intersectedEventDetail);
}
if ((!data.fuse || data.rayOrigin === 'mouse' || data.rayOrigin === 'xrselect') &&
this.intersectedEl && this.cursorDownEl === this.intersectedEl) {
this.twoWayEmit(EVENTS.CLICK, evt);
}
// if the current xr input stops selecting then make the ray caster point somewhere else
if (data.rayOrigin === 'xrselect') {
this.onMouseMove({
type: 'fakeselectout'
});
}
this.activeXRInput = null;
this.cursorDownEl = null;
if (evt.type === 'touchend') { evt.preventDefault(); }
},
/**
* Handle intersection.
*/
onIntersection: function (evt) {
var currentIntersection;
var cursorEl = this.el;
var index;
var intersectedEl;
var intersection;
// Select closest object, excluding the cursor.
index = evt.detail.els[0] === cursorEl ? 1 : 0;
intersection = evt.detail.intersections[index];
intersectedEl = evt.detail.els[index];
// If cursor is the only intersected object, ignore the event.
if (!intersectedEl) { return; }
// Already intersecting this entity.
if (this.intersectedEl === intersectedEl) { return; }
// Ignore events further away than active intersection.
if (this.intersectedEl) {
currentIntersection = this.el.components.raycaster.getIntersection(this.intersectedEl);
if (currentIntersection && currentIntersection.distance <= intersection.distance) { return; }
}
// Unset current intersection.
this.clearCurrentIntersection(true);
this.setIntersection(intersectedEl, intersection);
},
/**
* Handle intersection cleared.
*/
onIntersectionCleared: function (evt) {
var clearedEls = evt.detail.clearedEls;
// Check if the current intersection has ended
if (clearedEls.indexOf(this.intersectedEl) === -1) { return; }
this.clearCurrentIntersection();
},
onEnterVR: function () {
var rayOrigin = this.data.rayOrigin;
this.clearCurrentIntersection(true);
if (rayOrigin === 'xrselect' || rayOrigin === 'entity') {
this.addWebXREventListeners();
}
},
setIntersection: function (intersectedEl, intersection) {
var cursorEl = this.el;
var data = this.data;
var self = this;
// Already intersecting.
if (this.intersectedEl === intersectedEl) { return; }
// Set new intersection.
this.intersectedEl = intersectedEl;
// Hovering.
cursorEl.addState(STATES.HOVERING);
intersectedEl.addState(STATES.HOVERED);
this.twoWayEmit(EVENTS.MOUSEENTER);
if (this.data.mouseCursorStylesEnabled && this.data.rayOrigin === 'mouse') {
this.el.sceneEl.canvas.classList.add(CANVAS_HOVER_CLASS);
}
// Begin fuse if necessary.
if (data.fuseTimeout === 0 || !data.fuse || data.rayOrigin === 'xrselect' || data.rayOrigin === 'mouse') { return; }
cursorEl.addState(STATES.FUSING);
this.twoWayEmit(EVENTS.FUSING);
this.fuseTimeout = setTimeout(function fuse () {
cursorEl.removeState(STATES.FUSING);
self.twoWayEmit(EVENTS.CLICK);
}, data.fuseTimeout);
},
clearCurrentIntersection: function (ignoreRemaining) {
var index;
var intersection;
var intersections;
var cursorEl = this.el;
// Nothing to be cleared.
if (!this.intersectedEl) { return; }
// No longer hovering (or fusing).
this.intersectedEl.removeState(STATES.HOVERED);
cursorEl.removeState(STATES.HOVERING);
cursorEl.removeState(STATES.FUSING);
this.twoWayEmit(EVENTS.MOUSELEAVE);
if (this.data.mouseCursorStylesEnabled && this.data.rayOrigin === 'mouse') {
this.el.sceneEl.canvas.classList.remove(CANVAS_HOVER_CLASS);
}
// Unset intersected entity (after emitting the event).
this.intersectedEl = null;
// Clear fuseTimeout.
clearTimeout(this.fuseTimeout);
// Set intersection to another raycast element if any.
if (ignoreRemaining === true) { return; }
intersections = this.el.components.raycaster.intersections;
if (intersections.length === 0) { return; }
// Exclude the cursor.
index = intersections[0].object.el === cursorEl ? 1 : 0;
intersection = intersections[index];
if (!intersection) { return; }
this.setIntersection(intersection.object.el, intersection);
},
/**
* Helper to emit on both the cursor and the intersected entity (if exists).
*/
twoWayEmit: function (evtName, originalEvent) {
var el = this.el;
var intersectedEl = this.intersectedEl;
var intersection;
function addOriginalEvent (detail, evt) {
if (originalEvent instanceof MouseEvent) {
detail.mouseEvent = originalEvent;
} else if (typeof TouchEvent !== 'undefined' &&
originalEvent instanceof TouchEvent) {
detail.touchEvent = originalEvent;
}
}
intersection = this.el.components.raycaster.getIntersection(intersectedEl);
this.eventDetail.intersectedEl = intersectedEl;
this.eventDetail.intersection = intersection;
addOriginalEvent(this.eventDetail, originalEvent);
el.emit(evtName, this.eventDetail);
if (!intersectedEl) { return; }
this.intersectedEventDetail.intersection = intersection;
addOriginalEvent(this.intersectedEventDetail, originalEvent);
intersectedEl.emit(evtName, this.intersectedEventDetail);
}
});