Konva continue to track mouse outside stage

Viewed 602

I am trying to implement a joystick that can be used to control something like a robot in React.JS using the React-Konva library. Thus far I have managed to get something that sort of works by drawing a smaller circle inside a larger one and letting the smaller circle track the mouse position relative to the stage while the mouse is down. The problem with it is that once the mouse leaves the stage, I stop getting the onMouseMove events and the circle is stuck at its last position until the mouse returns to the stage. Ideally I would like to be able to have the circle keep tracking the direction of the mouse even when it moves outside the stage, but obviously limit how far the circle can actually move from the origin to remain within the stage.

Here is the code I have so far

import React, { useState, useContext } from "react";
import { Stage, Layer, Circle } from "react-konva";

export default function Joystick(props) {
  const { size } = props;

  const [x, setX] = useState(0);
  const [y, setY] = useState(0);
  const [down, setDown] = useState(false);

  const joyX = down ? x : size / 2;
  const joyY = down ? y : size / 2;

  return (
    <Stage
      width={size}
      height={size}
      onMouseMove={(ev) => {
        setX(ev.evt.layerX);
        setY(ev.evt.layerY);
      }}
      onMouseDown={(ev) => setDown(true)}
      onMouseUp={(ev) => setDown(false)}
    >
      <Layer>
        <Circle x={size / 2} y={size / 2} radius={size / 2} fill="black" />
        <Circle x={joyX} y={joyY} radius={size / 4} fill="white" />
      </Layer>
    </Stage>
  );
}

So what I'd like to know is what is the simplest and cleanest way that I can extend this to keep tracking the mouse even when it goes beyond the stage?

1 Answers

Based on advice from @VanquishedWombat to get inspiration from fire the div mousemove while on document I came up with the following code

function offset(el) {
  var rect = el.getBoundingClientRect(),
    scrollLeft = window.pageXOffset || document.documentElement.scrollLeft,
    scrollTop = window.pageYOffset || document.documentElement.scrollTop;
  return {
    top: rect.top + scrollTop,
    left: rect.left + scrollLeft,
  };
}

export default class Joystick extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      down: 0,
      x: 0,
      y: 0,
      offset: { top: 0, left: 0 },
    };

    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.handleMouseUp = this.handleMouseUp.bind(this);
  }

  updatePosition(ev, o) {
    const { size } = this.props;
    const offset = o || this.state.offset;

    let x = ev.clientX - offset.left;
    let y = ev.clientY - offset.top;

    let right = (x / size - 0.5) * 2;
    let up = (y / size - 0.5) * -2;

    const mag = Math.sqrt(right * right + up * up);
    const newMag = Math.min(mag, 1);

    right = (right / mag) * newMag;
    up = (up / mag) * newMag;

    x = (1 + right) * (size / 2);
    y = (1 - up) * (size / 2);

    this.setState({ x, y });
  }

  handleMouseMove(ev) {
    this.updatePosition(ev);
  }

  handleMouseUp(ev) {
    document.removeEventListener("mousemove", this.handleMouseMove);
    document.removeEventListener("mouseup", this.handleMouseUp);
    this.setState({ down: false });
  }

  render() {
    const { x, y, down } = this.state;
    const { size } = this.props;

    const joyX = down ? x : size / 2;
    const joyY = down ? y : size / 2;

    return (
      <div
        onMouseDown={(ev) => {
          const o = offset(ev.currentTarget);
          this.setState({ offset: o, down: true });
          this.updatePosition(ev, o);

          document.addEventListener("mousemove", this.handleMouseMove);
          document.addEventListener("mouseup", this.handleMouseUp);
        }}
        style={{ width: size, height: size }}
      >
        <Stage width={size} height={size}>
          <Layer
            clipFunc={(ctx) =>
              ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
            }
          >
            <Circle x={size / 2} y={size / 2} radius={size / 2} fill="black" />
            <Circle x={joyX} y={joyY} radius={size / 4} fill="white" />
          </Layer>
        </Stage>
      </div>
    );
  }
}

This code is a little bit nasty because it has to calculate the position of the cursor relative to the stage, but I have tried to keep it as simple as possible and it does seem to work quite well. The stage needs to be wrapped in a div of the same size to be able to use the getBoundingClientRect function which allows for the relative mouse position to be calculated. I also had to change my React component from a functional to a class component, because I need constant callback function references so that they can be properly unregistered after the mouse has been released.

I believe this will still fail if the position of the wrapping div were to change (from scrolling or whatever) while the mouse is down because it only calculates the offset at the initial mousedown event. This is not a problem in my application but be warned if this could affect yours.

Related