Skip to content

Instantly share code, notes, and snippets.

@hashrock
Last active January 31, 2024 13:22
Show Gist options
  • Save hashrock/0e8f10d9a233127c5e33b09ca6883ff4 to your computer and use it in GitHub Desktop.
Save hashrock/0e8f10d9a233127c5e33b09ca6883ff4 to your computer and use it in GitHub Desktop.
SVG Drag and Drop with React Hooks
import React from "react";
import ReactDOM from "react-dom";
const Circle = () => {
const [position, setPosition] = React.useState({
x: 100,
y: 100,
active: false,
offset: { }
});
const handlePointerDown = e => {
const el = e.target;
const bbox = e.target.getBoundingClientRect();
const x = e.clientX - bbox.left;
const y = e.clientY - bbox.top;
el.setPointerCapture(e.pointerId);
setPosition({
...position,
active: true,
offset: {
x,
y
}
});
};
const handlePointerMove = e => {
const bbox = e.target.getBoundingClientRect();
const x = e.clientX - bbox.left;
const y = e.clientY - bbox.top;
if (position.active) {
setPosition({
...position,
x: position.x - (position.offset.x - x),
y: position.y - (position.offset.y - y)
});
}
};
const handlePointerUp = e => {
setPosition({
...position,
active: false
});
};
return (
<circle
cx={position.x}
cy={position.y}
r={50}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
fill={position.active ? "blue" : "black"}
/>
);
};
function App() {
return (
<svg viewBox="0 0 400 400" width="400" height="400">
<Circle />
</svg>
);
}
const Application = () => {
return (
<div>
<h1>Drag Me</h1>
<App />
</div>
);
};
ReactDOM.render(<Application />, document.getElementById("app"));
@Minious
Copy link

Minious commented Jan 31, 2024

I found a quite convoluted fix. The issue came from the reordering of elements. Using a useEffect to trigger setPointerCapture after the redraw solve the problem but is quite ugly imo. Let me know if you know any better workaround.

"use client";

import { useEffect, useState } from "react";

interface DragElement {
  x: number;
  y: number;
  width: number;
  height: number;
  active: boolean;
  xOffset: number;
  yOffset: number;
  key: string;
  htmlElement?: SVGElement;
  pointerId?: number;
}

const windowViewportToSVGViewport = (
  elBBox: DOMRect,
  svgViewbox: DOMRect,
  svgBBox: DOMRect
): DOMRect => {
  const ratio = {
    x: svgViewbox.width / svgBBox.width,
    y: svgViewbox.height / svgBBox.height,
  };
  const x = svgViewbox.x + (elBBox.x - svgBBox.x) * ratio.x;
  const y = svgViewbox.y + (elBBox.y - svgBBox.y) * ratio.y;
  const width = elBBox.width * ratio.x;
  const height = elBBox.height * ratio.y;

  return DOMRect.fromRect({ x, y, width, height });
};

export default function TestDrag({}: {}) {
  const [elements, setElements] = useState<DragElement[]>([
    {
      x: 0,
      y: 0,
      width: 100,
      height: 200,
      active: false,
      xOffset: 0,
      yOffset: 0,
      key: "0",
    },
    {
      x: -100,
      y: -100,
      width: 100,
      height: 100,
      active: false,
      xOffset: 0,
      yOffset: 0,
      key: "1",
    },
    {
      x: -50,
      y: 100,
      width: 150,
      height: 200,
      active: false,
      xOffset: 0,
      yOffset: 0,
      key: "2",
    },
    {
      x: 200,
      y: 250,
      width: 50,
      height: 50,
      active: false,
      xOffset: 0,
      yOffset: 0,
      key: "3",
    },
  ]);

  useEffect(() => {
    elements.forEach((element) => {
      if (
        element.active &&
        element.htmlElement !== undefined &&
        element.pointerId !== undefined
      )
        element.htmlElement.setPointerCapture(element.pointerId);
    });
  }, [elements]);

  function handlePointerDown(
    index1: number,
    e: React.PointerEvent<SVGElement>
  ) {
    let newElements = elements.map(function (item, index2): DragElement {
      if (index1 === index2) {
        const el = e.currentTarget as SVGElement;
        const elBBox = el.getBoundingClientRect();
        const svgViewbox = (e.target as SVGElement).ownerSVGElement?.viewBox
          .baseVal;
        const svgBBox = (
          e.target as SVGElement
        ).ownerSVGElement?.getBoundingClientRect();

        if (!svgViewbox || !svgBBox) return item;

        const { x, y, width, height } = windowViewportToSVGViewport(
          elBBox,
          svgViewbox,
          svgBBox
        );
        const cursorPosition = { x: e.clientX, y: e.clientY };
        const { x: cursorSvgPositionX, y: cursorSvgPositionY } =
          windowViewportToSVGViewport(
            DOMRect.fromRect(cursorPosition),
            svgViewbox,
            svgBBox
          );

        return {
          ...item,
          xOffset: cursorSvgPositionX - x,
          yOffset: cursorSvgPositionY - y,
          active: true,
          htmlElement: el,
          pointerId: e.pointerId,
        };
      }
      return item;
    });

    // Move the element to the top of the array
    const el = newElements.splice(index1, 1)[0];
    newElements.push(el);

    setElements(newElements);
  }

  function handlePointerMove(
    index1: number,
    e: React.PointerEvent<SVGElement>
  ) {
    let newElements = elements.map(function (item, index2): DragElement {
      if (index1 === index2 && item.active === true) {
        const svgViewbox = (e.target as SVGElement).ownerSVGElement?.viewBox
          .baseVal;
        const svgBBox = (
          e.target as SVGElement
        ).ownerSVGElement?.getBoundingClientRect();

        if (!svgViewbox || !svgBBox) return item;

        const cursorPosition = { x: e.clientX, y: e.clientY };
        const { x: cursorSvgPositionX, y: cursorSvgPositionY } =
          windowViewportToSVGViewport(
            DOMRect.fromRect(cursorPosition),
            svgViewbox,
            svgBBox
          );

        return {
          ...item,
          x: cursorSvgPositionX - item.xOffset,
          y: cursorSvgPositionY - item.yOffset,
        };
      }
      return item;
    });
    setElements(newElements);
  }

  function handlePointerUp(index1: number, e: React.PointerEvent<SVGElement>) {
    let newElements = elements.map(function (item, index2): DragElement {
      if (index1 === index2) {
        return { ...item, active: false };
      }
      return item;
    });

    setElements(newElements);
  }

  const rectElements = elements.map(function (item, index) {
    return (
      <rect
        key={item.key}
        x={item.x}
        y={item.y}
        z={-index}
        fill="yellow"
        stroke="blue"
        width={item.width}
        height={item.height}
        onPointerDown={(evt) => handlePointerDown(index, evt)}
        onPointerUp={(evt) => handlePointerUp(index, evt)}
        onPointerMove={(evt) => handlePointerMove(index, evt)}
      />
    );
  });

  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width={800}
      height={800}
      viewBox="-100 -100 400 400"
      style={{
        backgroundColor: "#ff0000",
        position: "absolute",
        left: 50,
        top: 50,
      }}
    >
      {rectElements}
    </svg>
  );
}

@hashrock
Copy link
Author

hashrock commented Jan 31, 2024

@Minious
That's right, when you rearrange elements during a drag operation, you lose the reference to the DOM.
The rearrangement should be done when the dragging is finished.

If it were me, I would probably just render the object coming to the forefront twice.

  return (
    <svg
      width={800}
      height={800}
      viewBox="-100 -100 400 400"
      style={{
        backgroundColor: "#ff0000",
        position: "absolute",
        left: 50,
        top: 50,
      }}
    >
      {rectElements}
      {elements.filter((item) => item.active === true).map((item) => {
        return (
          <rect
            key="active"
            x={item.x}
            y={item.y}
            fill="yellow"
            stroke="white"
            width={item.width}
            height={item.height}
            style={{ pointerEvents: "none" }}
          />
        );
      }
      )}
    </svg>
  );

I often use techniques to create a UI layer that is just for appearance and disconnected from the actual contents. By using pointer-events: none, it is possible to make events transparent.

image

https://dev.to/hashrock/writing-spreadsheet-with-svg-and-vuejs--23ed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment