skip to Main Content

We have the following code sample:

function DraggableText({ x, y, text }) {
    const [position, setPosition] = React.useState({ x: x, y: y });
    const [isDragging, setIsDragging] = React.useState(false);
    const [mouseOffset, setMouseOffset] = React.useState({ x: 0, y: 0 });
  
    const handleMouseDown = (event) => {
      event.preventDefault();
      setIsDragging(true);
      setMouseOffset({
        x: event.clientX - position.x,
        y: event.clientY - position.y,
      });
    };
  
    const handleMouseUp = (event) => {
      setIsDragging(false);
    };
  
    const handleMouseMove = (event) => {
      if (isDragging) {
        setPosition({
          x: event.clientX - mouseOffset.x,
          y: event.clientY - mouseOffset.y,
        });
      }
    };
  
    return (
      <text
        x={position.x}
        y={position.y}
        onMouseDown={handleMouseDown}
        onMouseUp={handleMouseUp}
        onMouseMove={handleMouseMove}
        style={{ cursor: 'move' }}
      >
        {text}
      </text>
    );
  }
  
  
 function D3BarChart({ }) {
   
    // And Finally, Return!
    return (
        <svg
            className='cbb-box-shadowed'
            width='100%'
            viewBox={`0 0 700 450`}
            preserveAspectRatio='xMaxYMax'
            style={{ background: '#F2F2F2' }}
        >
            <DraggableText x={100} y={100} text="Drag me!" />
        </svg>
    );
}

// render both components

ReactDOM.render(
  (<div>
    <div width='60%'>
      <D3BarChart />
    </div>
  </div>),
  document.querySelector('#root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.14.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.14.0/umd/react-dom.production.min.js"></script>


<div id="root">React component will be rendered here.</div>

When we click to drag the text, the mouse moves about 5% faster than the text in each direction. The result is a buggy experience using the text-drag.

The issue appears to be related to the viewBox in the <svg>. We are looking for a solution that keeps the viewBox and the width: 100% as is on the SVG, as well as the <div width='60%'> that the D3BarChart is returned inside of, as these are key parts to how the graphs are created and displayed on our web application.

Perhaps there is a way to offset the viewBox value in the <DraggableText />?

3

Answers


  1. Try this code.

    function DraggableText() {
      const [position, setPosition] = useState({
        x: 50,
        y: 50
      });
    
      const handleDragStart = (event) => {
        event.preventDefault();
        const startX = event.clientX - position.x;
        const startY = event.clientY - position.y;
    
        const handleMouseMove = (event) => {
          setPosition({
            x: event.clientX - startX,
            y: event.clientY - startY,
          });
        };
    
        const handleMouseUp = () => {
          document.removeEventListener("mousemove", handleMouseMove);
          document.removeEventListener("mouseup", handleMouseUp);
        };
    
        document.addEventListener("mousemove", handleMouseMove);
        document.addEventListener("mouseup", handleMouseUp);
      };
    
      return ( <
        svg >
        <
        text x = {
          position.x
        }
        y = {
          position.y
        }
        onMouseDown = {
          handleDragStart
        }
        style = {
          {
            cursor: "move"
          }
        } >
        Drag me!
        <
        /text> <
        /svg>
      );
    }
    
    export default DraggableText;
    Login or Signup to reply.
  2. Then main problem with your code, as you already said, is that the svg width and the svg viewBox width are different.

    In your case the svg width is 100%, so it’s dinamically calculated, but its viewBox width is 700px. If you set both to the same value you would avoid this issue, otherwise you have to do some math.

    For sake of simplicity, let’s say you have this markup: <svg width="300px" viewBox="0 0 600 …. Your svg would occupy 300px on the screen. But since your svg viewBox width is 600px, the svg would "think" to be twice as big when it comes to calculate the position of its internal elements.
    This means that if you want to position an element into the svg at (100px, 100px) according to the screen geometry, you will need to multiply those values by 2 and set your svg element position to (200px, 200px).
    This scaling factor (2 in this example) can be calculated programmatically reading the SVG.viewBox attribute and getting the actual current image size on screen with Element.getBoundingClientRect(), like this:

    scale = svg.viewBox.baseVal.width / svg.getBoundingClientRect().width
    

    In your code you should scale the mouse position to match the svg geometry, like so:

    function DraggableText({ x, y, text }) {
        const [position, setPosition] = React.useState({ x: x, y: y });
        const [isDragging, setIsDragging] = React.useState(false);
        const [mouseOffset, setMouseOffset] = React.useState({ x: 0, y: 0 });
        const [scale, setScale] = React.useState(1);
      
        const handleMouseDown = (event) => {
          event.preventDefault();
          setIsDragging(true);
          const svg = event.target.parentElement;
          const vbox = svg.viewBox.baseVal;
          const rect = svg.getBoundingClientRect();
          const in_scale = vbox.width / rect.width;
          setScale(in_scale);
          setMouseOffset({
            x: (event.clientX * in_scale) - position.x,
            y: (event.clientY * in_scale) - position.y,
          });
        };
      
        const handleMouseUp = (event) => {
          setIsDragging(false);
        };
      
        const handleMouseMove = (event) => {
          if (isDragging) {
            setPosition({
              x: scale * event.clientX - mouseOffset.x,
              y: scale * event.clientY - mouseOffset.y,
            });
          }
        };
      
        return (
          <text
            x={position.x}
            y={position.y}
            onMouseDown={handleMouseDown}
            onMouseUp={handleMouseUp}
            onMouseMove={handleMouseMove}
            style={{ cursor: 'move' }}
          >
            {text}
          </text>
        );
      }
      
      
     function D3BarChart({ }) {
       
        // And Finally, Return!
        return (
            <svg
                className='cbb-box-shadowed'
                width='100%'
                viewBox={`0 0 700 450`}
                preserveAspectRatio='xMaxYMax'
                style={{ background: '#F2F2F2' }}
            >
                <DraggableText x={100} y={100} text="Drag me!" />
            </svg>
        );
    }
    
    // render both components
    
    ReactDOM.render(
      (<div>
        <div width='60%'>
          <D3BarChart />
        </div>
      </div>),
      document.querySelector('#root'));
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.14.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.14.0/umd/react-dom.production.min.js"></script>
    
    
    <div id="root">React component will be rendered here.</div>

    Apart from this, as others pointed out, you would get a better result using the mouse move event of the svg instead of the text element, since if you drag it around fast enough, your mouse would be able to leave it, and the event will not fire anymore.

    Login or Signup to reply.
  3. Basically the changes I can see compared to my own component (drag handles on a panel) are

    • add mousemove and mouseup to window (or container), not to the element
    • scaling the event coordinates to the parent
    • using useCallback() for handlers, to rebuild only on dependency changes

    Giving mousemove and mouseup wider scope allows the text to "catch up" with the mouse if you move it quicker than React can process the events.

    Otherwise the mouse can get dissociated from the text and mousemove is no longer processed.

    function DraggableText({ x, y, text }) {
      const [position, setPosition] = React.useState({ x, y })
      const [isDragging, setIsDragging] = React.useState(false)
      const [mouseOffset, setMouseOffset] = React.useState({ x: 0, y: 0 })
    
      const textRef = React.useRef()
      const [scale, setScale] = React.useState({ x: 1, y: 1 })
    
      const calcScale = React.useCallback(() => {
        if (!textRef.current) return
        const parent = textRef.current.parentElement 
        const viewbox = parent.viewBox.baseVal;
        const rect = parent.getBoundingClientRect();
        const scale = {
          x: viewbox.width / Math.round(rect.width), 
          y: viewbox.height / Math.round(rect.height) 
        }
        return scale
      }, [])
    
      const handleMouseDown = React.useCallback(event => {
        event.preventDefault()
        setIsDragging(true)
    
        const scale = calcScale()
        setScale(scale)
    
        const newPosition = {
          x: (event.clientX * scale.x) - position.x,
          y: (event.clientY * scale.y) - position.y,
        }
        setMouseOffset(newPosition)
      }, [position, calcScale, scale])
    
      const handleMouseUp = React.useCallback(() => {
        if (!isDragging) return
        setIsDragging(false)
      }, [isDragging])
    
      const handleMouseMove = React.useCallback(event => {
        if (!isDragging) return
        setPosition({
          x: scale.x * event.clientX - mouseOffset.x,
          y: scale.y * event.clientY - mouseOffset.y,
        })
      }, [isDragging, mouseOffset, scale])
    
      // External listeners
      React.useEffect(() => {
        console.log('isDragging', isDragging)
        if (!isDragging) return
        window.addEventListener('mousemove', handleMouseMove)
        window.addEventListener('mouseup', handleMouseUp)
        return () => {
          window.removeEventListener('mousemove', handleMouseMove)
          window.removeEventListener('mouseup', handleMouseUp)
        };
      }, [isDragging, handleMouseMove, handleMouseUp])
    
      return (
        <text
        ref={textRef}
          x={position.x}
          y={position.y}
          onMouseDown={handleMouseDown}
          // onMouseUp={handleMouseUp}
          // onMouseMove={handleMouseMove}
          style={{ cursor: 'move' }}
        >
          {text}
        </text>
      )
    } 
     
     function D3BarChart({ }) {
       
        // And Finally, Return!
        return (
            <svg
                className='cbb-box-shadowed'
                width='100%'
                viewBox={`0 0 700 450`}
                preserveAspectRatio='xMaxYMax'
                style={{ background: '#F2F2F2' }}
            >
                <DraggableText x={100} y={100} text="Drag me!" />
            </svg>
        );
    }
    
    // render both components
    
    ReactDOM.render(
      (<div>
        <div width='60%'>
          <D3BarChart />
        </div>
      </div>),
      document.querySelector('#root'));
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.14.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.14.0/umd/react-dom.production.min.js"></script>
    
    
    <div id="root">React component will be rendered here.</div>

    I also set position on mousedown which does away with mouseOffset state, but haven’t made that change here in case there’s something else going on that I didn’t notice.

    It would be a small optimization anyway.


    Update

    I notice when the snippet is opened in full-page mode, it’s no longer tracking properly.

    This is due to scale being set on component mount, but if you perform this sequence

    • drag and mouse up

    • resize the window (in snippet, select "full page")

    • drag again

    then scale is wrong for the 2nd drag.

    The code is now updated to handle window resize.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search