Conditional mouse-event pass-through between sibling elements that overlap
Asked Answered
L

3

9

Is there a standard Javascript technique or library for managing conditional mouse-event pass-through between overlapping HTML elements that are not related?

For example, I have a partially transparent WebGL canvas (managed by Three.js) in front of a bunch of HTML elements (managed by the Thee.js CSS3 renderer, but that shouldn't be relevant). Those HTML elements have registered mouseover and mouseout events. I would like 3D objects floating in front of those elements to block mouse-events.

I already know how to use a ray-caster to determine whether the mouse is over a 3D object. What I don't know is how to allow the mouse-event to 'pass through' the canvas to the underlying HTML elements when a 3D object is not inbetween.

enter image description here

I've read about solutions where you traverse the DOM tree until you find the element that's underneath the mouse. But that seems overly complicated and slow. What I'd like to do, if possible, is pretend that the canvas is not there for a moment so that the event can pass through naturally.

In the interest of not reinventing the wheel, it would be great if there was already a library for this.

Lectern answered 6/6, 2014 at 13:2 Comment(5)
Are you looking for pointer-events?Dendrochronology
@DavidThomas: No. Putting that property on the canvas would give unconditional pass-through. I'm looking for a way to conditionally pass the event through using Javascript.Lectern
Okay, can you post a representative demo that reproduces your problem?Dendrochronology
Here's one I adapted from a Three.js example: jsfiddle.net/nxLBW . When you move the mouse over a floating cube, it flickers with different colors. Hovering over the green DIV in the background should print something to the console only when there's not a cube in front of it.Lectern
You won't be able to construct a real "conditional pass-through" while #background and canvas element are in different propagation pathes. You can handle all events in document and may simulate that behavior by conditional delegation to another function with the Event object in an argument or create a new Event and dispatch it to another target outside the path. Event.target holds the element the event was targeted to.Faerie
S
7

Since you don't want to use the pointer-events as David Thomas suggested, then the mouse will always target the elements with the highest z-index or, having the same z-index, the last sibling (when relatively stacked on top of others).

Having said this, the only way I can think is to:

  1. Hide the canvas
  2. Read element underneath
  3. Immediately show back the canvas

This is so fast that produces no noticeable flickering.

If there was an element underneath the mouse, then trigger that element mouse event.

Demo here

$("canvas").mousemove(function (event) {
    if (document.elementFromPoint) {
        var $canvas = $(this);

        // hide canvas visibility
        // don't do display:none as we want to maintain canvas layout
        $canvas.css('visibility', 'hidden');

        // get the element underneath, if any
        var $under = $(document.elementFromPoint(event.clientX, event.clientY));

        // show again the canvas
        $canvas.css('visibility', 'visible');
        if ($under.hasClass('underneath')) { 
           $under.triggerHandler('mouseover');
        } else {
            $("#result").text("mouse is over the canvas");
        }
    }
})
Sinclare answered 15/6, 2014 at 20:28 Comment(2)
Hi Jose. Thanks for this interesting answer, and your elegant and small example! In fact, instead of briefly switching to visibility: hidden, I may as well briefly switch to pointer-events: none. (I only objected to David's original pointer-events suggestion because it was, by itself, not the answer to my problem.) The 'quick switch' is original. I'll hold off on accepting an answer for now. But you can have the bounty. Cheers!Lectern
@Lectern Indeed, switching pointer-events between none and auto seems to be an even more elegant solution. jsfiddle.net/tQyst/10Sinclare
T
1

I found a pretty straightforward solution for you. And since you provided a jsfiddle example in one of your comments I figured I just tweak that code a little and let you see it.

It is very simple what I am doing. I check if the mouse is overlapping the div and the function "changeTheDivBox" is called when your Three.js raycaster does not hit any boxes.

var divBox = document.getElementById("background");
divBox.addEventListener("mouseover", mouseOver, false);
divBox.addEventListener('mouseout', mouseOut, false);
var mouseOverBox = false; // Are you currently hovering over the "background" div?
function mouseOver () {
    mouseOverBox = true;
}
function mouseOut () {
    mouseOverBox = false;
}
function changeTheDivBox () { // This function is called when your raycaster does not hit any boxes
    if ( mouseOverBox ) {
        divBox.style.backgroundColor = 'blue';
    }
    else {
        divBox.style.backgroundColor = 'green';
    }
}

Basically:

Is the mouse overlapping the <div> ?
    Yes.
    Is the mouse (raycaster) hitting any boxes?
    No.
        Then Change the <div>'s color!

One more thing I did was use the css property pointer-events. This lets you click on divs "further back" by making any chosen elements not register with the mouse events. I addedpointer-events: none; to the body to disable all mouse events and I then added pointer-events: auto; to the div element to re-enable them on it. So the mouse now only detects events on that div. Pretty neat.

I left the rest of the code as it is. I personally would prefer to to use a loop to constantly check if the overlap/raycast is still valid rather than rely on mouseIn / mouseOutSo but it is your example, so play around with it :)

UPDATED JSFIDDLE

Just as a personal preference. I would suggest that you avoid putting functions in the HTML. They don't always work as expected. (The this object keeps referring to the window) And they can make your code more confusing. I personally prefer to keep my JavaScript inside the tags. And eventListeners are more powerful than inline function calls anyways :)

Tahitian answered 15/6, 2014 at 21:38 Comment(2)
Thanks! But the idea of remembering whether the mouse is overlapping the div seems to be only a partial solution. If I transfer the mouse from the div onto a floating box, and leave the div area, the mouseOverBox will remain true. But the approach did spark some ideas!Lectern
I can't see the issue. I deliberately made the example simple. If you use such a floating box. Then change the code to react accordingly to whatever boxes you might need.Tahitian
F
0

You set in your code the z-index:-1; of #background and display:absolute;. That means: It is behind the body z-layer and mouse events will be targeted to the body or anything else in a layer above.

You could set pointer-events:none; of body or even all elements, but that could be a bad thing with other codes included into the site later. Prefer positioning other covering elements absolute with a higher z-index, those are: #container and #container>canvas.

See how the path is, a DOM event propagates: http://www.w3.org/TR/DOM-Level-3-Events/

In capturing phase the event is first propagated to the view, then the, html and body and after that it will get to the target displayed under the mouse cursor. Than in the bubbeling phase propagation takes the reversed way back to the view.

The rendered scene objects are not in this DOM event path. You have already raytraced a hit in the document event listener. Do this before the propagation reaches the #background element and safe somewhere if the event is known to be handled. You have tried preventDefault(). Unfortunately the mousemove event isn't cancelable, so this has no effect. Later called event listeners which check preventedDefault will get false. The meaning of preventDefault() is to prevent a default operation that is performed after propagation by the user agent, e.g. mark the word under the mouse pointer after double click. If the default operation should be performed then you cannot use preventDefault() to tell other listener that the event is already handled.

You could set a custom property of Event to true. It works at least in firefox, however, host objects are not strictly specified by W3C. This could lead to some error in other browsers or in the future. You could use stopPropagation() if no other listener should be called. Under circumstances this can lead to undesirable behavior or bugs in conjunction with some JavaScript frameworks.

Another approach is to set a variable in a scope of accessible to all of your event listeners, e.g. the global scope or better an encapsulating anonymous function. This could hold the latest handled event and that will not change until the propagation has completed (next event will not be fired before).

You can also handle the events targeted to #background in the listener of document if the raytracer doesn't hit a scene object. Event.target holds the topmost DOM element hit by a mouse event.

Your modified code with demonstration of the different approaches:

<!DOCTYPE html>
<html>
  <head>
    <title>events passthrough</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style type="text/css">
body {
  font-family: Monospace;
  margin: 0px;
  overflow: hidden;
}

div#background {
  position: absolute;
  top: 40px;
  left: 40px;
  width: 100px;
  height: 100px;
  background-color: pink;
  /*z-index: -1;            /* this is behind the body */
}


#container>canvas
{ position: absolute;
  z-index: 100;
}

/* Explicitly disable mouse events on covering element. */
/* If z-index of background object is below zero then also disable body */

/* body, */
#container, #container canvas
{ pointer-events:none;
}

/* but let enabled all other elements */
*
{ pointer-events:auto;
}
    </style>
  </head>

  <body>
    <script type="text/javascript" src="https://mrdoob.github.io/three.js/build/three.min.js"></script>
    <script type="text/javascript" src="https://mrdoob.github.io/three.js/examples/js/libs/stats.min.js"></script>

    <div id="background"></div>
    <div id="container"></div>

    <script type="text/javascript">

var lastHandledEvent;

var container, stats;
var camera, scene, projector, renderer;
var particleMaterial;

var objects = [];

// don't run DOM relevant scripts before construction of DOM is guaranteed
document.addEventListener("DOMContentLoaded", function()
{
  init();
  animate();
}, false);

function init() {
    container = document.getElementById( 'container' );
    camera = new THREE.PerspectiveCamera( 70, window.innerWidth / window.innerHeight, 1, 10000 );
    camera.position.set( 10, 300, 500 );
    scene = new THREE.Scene();
    var geometry = new THREE.BoxGeometry( 100, 100, 100 );
    for ( var i = 0; i < 10; i ++ ) {
        var object = new THREE.Mesh( geometry, new THREE.MeshBasicMaterial( { color: Math.random() * 0xffffff, opacity: 0.5 } ) );
        object.position.x = Math.random() * 800 - 400;
        object.position.y = Math.random() * 800 - 400;
        object.position.z = Math.random() * 800 - 400;
        object.scale.x = Math.random() * 2 + 1;
        object.scale.y = Math.random() * 2 + 1;
        object.scale.z = Math.random() * 2 + 1;
        object.rotation.x = Math.random() * 2 * Math.PI;
        object.rotation.y = Math.random() * 2 * Math.PI;
        object.rotation.z = Math.random() * 2 * Math.PI;
        scene.add( object );
        objects.push( object );
    }

    var PI2 = Math.PI * 2;
    particleMaterial = new THREE.SpriteCanvasMaterial( {
        color: 0x000000,
        program: function ( context ) {
            context.beginPath();
            context.arc( 0, 0, 0.5, 0, PI2, true );
            context.fill();
        }
    } );
    projector = new THREE.Projector();
    renderer = new THREE.WebGLRenderer({ alpha: true });
    renderer.setSize( window.innerWidth, window.innerHeight );
    // renderer.domElement.style.position = 'absolute';  // is done in CSS
    // renderer.domElement.style.zIndex = 100;           // is done in CSS
    container.appendChild( renderer.domElement );

    // register document mousemove in capturing phase
    // if you want to use another handlers subsequently
    document.addEventListener('mousemove', onDocumentMouseMove, true );
    document.querySelector('#background').addEventListener('mousemove', onMouseMove, false);

    window.addEventListener( 'resize', onWindowResize, false );
}

function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize( window.innerWidth, window.innerHeight );
}

var background = document.getElementById('background');

function onDocumentMouseMove( event ) 
{
    var vector = new THREE.Vector3( ( event.clientX / window.innerWidth ) * 2 - 1, - ( event.clientY / window.innerHeight ) * 2 + 1, 0.5 );
    projector.unprojectVector( vector, camera );
    var raycaster = new THREE.Raycaster( camera.position, vector.sub( camera.position ).normalize() );
    var intersects = raycaster.intersectObjects( objects );
    if ( intersects.length > 0 ) 
    {
        intersects[ 0 ].object.material.color.setHex( Math.random() * 0xffffff );
        //event.preventDefault();   // mousemove is not cancelable

        // extending event objects works at least in Firefox, not sure if crossbrowser combatible
        // always be careful when extending DOM
        event.handled = true;       // add custom property
        lastHandledEvent = event;   // another way: store in a variable accessible by all handlers
                                    //              to remember this one was the last handled event
        console.log('event marked as handled in document listener');
        //event.stopPropagation();  // or could stop propagation, however,
                                    // often not a good praxis in conjuction with frameworks
    }
    else if(event.target === background)
      console.log('background could be handled in document listener');
}

function onMouseMove( event )
{ 
  // if(event.defaultPrevented   // mousemove not cancable, always false
  if(event.handled)              // TODO: check crossbrowser compatibility
                                 //       of custom properties on event objects.
                                 //       In doubt use a var outside the functions.
    console.log('other listener: event.handled is: '+event.handled);

  if(lastHandledEvent === event) // the safer way: use variable accessible
  {
    console.log('NOT handling event in other listener');
    return;
  }
  console.log(event.target, '...or handled in other listener');
  event.target.style.backgroundColor = '#'+('00000' + (Math.random() * 0xffffff).toString(16)).slice(-6);
}

function animate() {
    requestAnimationFrame( animate );
    render();
}

var radius = 600;
var theta = 0;

function render() {
    theta += 0.1;
    camera.position.x = radius * Math.sin( THREE.Math.degToRad( theta ) );
    camera.position.y = radius * Math.sin( THREE.Math.degToRad( theta ) );
    camera.position.z = radius * Math.cos( THREE.Math.degToRad( theta ) );
    camera.lookAt( scene.position );
    renderer.render( scene, camera );
}
    </script>    
  </body>
</html>
Faerie answered 22/6, 2014 at 5:30 Comment(1)
Thanks! Though you should know that almost all code in the fiddle I provided comes from the original Three.js example, and I only added minimal extra code to illustrate my problem.Lectern

© 2022 - 2024 — McMap. All rights reserved.