2

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?

Gerharddc
  • 3,921
  • 8
  • 45
  • 83

1 Answers1

2

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.

Gerharddc
  • 3,921
  • 8
  • 45
  • 83
  • Nice work. Out of interest, will there be any issue if the canvas is scaled ? It might not be your use case and it might not be an issue - I just always consider scaling as a potential problem as a canvas stage can be scaled in isolation from the surrounding document. – Vanquished Wombat Jul 30 '20 at 09:09
  • Note to others - if you use this code and find it does not work when transplanted, look out for any cancelbubble-style code you might have that is stopping the events travelling up the tree to the document level. – Vanquished Wombat Jul 30 '20 at 09:11
  • 1
    @VanquishedWombat scaling will probably lead to some pain with this as is yes because the screen coordinates are assumed to be at the same scale as coordinates in the canvas. It would most likely need to be modified somewhat to support scaling. – Gerharddc Jul 30 '20 at 09:37