Handling scroll hijacking

I’m trying to find a better way to avoid scroll hijacking on 3D models embedded on a webpage. My idea is something similar to how Google Maps handles this:

  • if there’s a mouse, the mousewheel should only zoom if ctrl is pressed.
  • on touch devices, use a two-finger drag to rotate, and a pinch to zoom. Single finger drag performs normal page scrolling as usual.
  • a HTML overlay shows helpful messages (like “use two fingers to rotate” or “ctrl-scroll to zoom”) on mouse hover but when no interaction is being performed, with a timeout to show again after an interaction is made, and on single finger scroll on the canvas.

I got the mouse wheel part working by camera.inputs.remove(camera.inputs.attached.mousewheel); plus some code to handle the wheel directly, pasted reference for developers who run into this page in the future:

    this.container.addEventListener('wheel', (e) => {
      if (e.ctrlKey) {
        this.camera.radius += e.deltaY / 12.0; // magic constant gathered from babylonjs code
        e.preventDefault();
      }
    });

I can handle the “use ctrl-scroll” overlay message with an else there easily.

For mobile things seem a bit nastier. I can allow normal page scrolling with engine.doNotHandleTouchAction = true; and touch-action: auto. But then pinching also zooms the entire page, and I can’t find a way to disable single-finger behavior. Any suggestions, or will I need to write a new class similar to arcRotateCameraPointersInput?

BTW, this seems quite useful to me and perhaps could be an easy to make setting for the Viewer (or even the engine itself) with a helper function. Any page that embeds 3D content will suffer from scroll hijacking, and in mobile it can pretty much get you stuck on the page. I’ll be happy to provide a PR if you like the idea.

2 Likes

This feels like a modification of the Pointer Input for the specific camera.

Just as you removed the mousewheel support you can remove the pointer support.
And yes -

You will need to do that, as the pointer input has a very specific set of processes that prevent you from making it more dynamic.

TL;dr - our input system was meant to do exactly what you are suggesting - if you find that the default implementation doesn’t fit your needs, you are remove it and add your own :slight_smile:

We do however provide a lot of flags to each input, so if it is just about the way things are working you might be fine with just setting a flag. The best example is the touch input that allows you to configre what type of zoom you will trigger when pinching -

Babylon.js/arcRotateCameraPointersInput.ts at master · BabylonJS/Babylon.js (github.com)

3 Likes

This is the complete solution for those who run into this post in the future.

First, you can detect if there’s a mouse with CSS to show a message only for mobile or mouse users:

  /* smartphones, touchscreens */
  @media (hover: none) and (pointer: coarse) {
    .object-scroll-message-mouse {
      display: none;
    }
  }

You can do the same in JS with window.matchMedia('(hover: none) and (pointer: coarse)'); if you need it.

To make scroll only work with ctrl, this is the snippet:

    // ctrl+scroll
    camera.inputs.remove(camera.inputs.attached.mousewheel);
    container.addEventListener('wheel', (e) => {
      if (e.ctrlKey) {
        this.showZoomHelp = false; // you can control whether to show help
        camera.radius += e.deltaY / 12.0; // 12.0 is a magic constant, see BaseCameraMouseWheelInput
        e.preventDefault();
      } else {
        this.showZoomHelp = true;
      }
    });

Mobile is a little trickier, and I decided to get rid of the double-finger for rotation because I found out that I can block only the initial pan-y movement (which does scroll). If you do a horizontal scroll or a pinch when starting the motion you can rotate in any direction later, and it seemed good enough UX for my project. Here’s what you have to do:

    // allow scrolling by the browser, handle other events. 
    // This conflicts with engine.doNotHandleTouchAction, don't set that flag.
    container.setAttribute('touch-action', 'pan-y');
    container.style.touchAction = 'pan-y';

    // scroll hijacking on mobile. Keep track if there's a touch still happening
    const pointerIds = new Set();
    let touching = false;
    container.addEventListener('pointerdown', (e) => {
      touching = true;
      pointerIds.add(e.pointerId);
    });
    container.addEventListener('pointerleave', (e) => {
      pointerIds.delete(e.pointerId);
      if (pointerIds.size === 0) {
        touching = false;
      }
    });

This avoids a change to the input class, which would be necessary for a double-finger drag, but it’s a very easy copy-and-paste solution.

I don’t get a pan with a double-finger drag, which I thought would happen. I think ArcRotateCamera doesn’t allow panning, right? Which is good enough for me.

2 Likes

Hey @brunobg!

Thanks for sharing!

I am into customizing the camera inputs these days too and I had to create Google Earth like camera input for our project. Two finger pan, rotate, zoom + one finger pan + two finger tilt (both fingers moves up or down). I started to create an input module for the ArcRotateCamera using HammerJS but I am slowly getting rid of all HammerJS dependencies. I am already doing all the math and want to make it using just native events. Maybe you (or the others) will find something usefull…

Check out the demo (it may have still issues):
https://demos.babylonjs.xyz/hammerjs-example/#/

Repo:

:vulcan_salute:

2 Likes

Hey @roland, that looks great and very useful! Thanks :slight_smile:

2 Likes