diff --git a/apps/storybook/src/SvgElement.stories.module.css b/apps/storybook/src/SvgElement.stories.module.css
index 97849b259..4d122e17f 100644
--- a/apps/storybook/src/SvgElement.stories.module.css
+++ b/apps/storybook/src/SvgElement.stories.module.css
@@ -1,12 +1,18 @@
-.rect {
+.dragCircle {
fill: teal;
- fill-opacity: 0.2;
- stroke: teal;
+ fill-opacity: 0.5;
+ stroke: transparent;
stroke-width: 2;
pointer-events: auto;
+ cursor: grab;
}
-.rect:hover {
- fill: darkgoldenrod;
- stroke: darkorange;
+.dragCircle:hover,
+.dragCircle[data-dragging] {
+ fill: blueviolet;
+}
+
+.dragCircle[data-dragging] {
+ stroke: darkmagenta;
+ cursor: grabbing;
}
diff --git a/apps/storybook/src/SvgElement.stories.tsx b/apps/storybook/src/SvgElement.stories.tsx
index c0553d1bf..4c058e9df 100644
--- a/apps/storybook/src/SvgElement.stories.tsx
+++ b/apps/storybook/src/SvgElement.stories.tsx
@@ -1,15 +1,16 @@
import {
DataToHtml,
- Pan,
+ DefaultInteractions,
ResetZoomButton,
SvgCircle,
SvgElement,
SvgLine,
SvgRect,
+ useDrag,
VisCanvas,
- Zoom,
} from '@h5web/lib';
import type { Meta, StoryObj } from '@storybook/react';
+import { useState } from 'react';
import { Vector3 } from 'three';
import FillHeight from './decorators/FillHeight';
@@ -18,7 +19,19 @@ import styles from './SvgElement.stories.module.css';
const meta = {
title: 'Building Blocks/SvgElement',
component: SvgElement,
- decorators: [FillHeight],
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ FillHeight,
+ ],
parameters: { layout: 'fullscreen' },
} satisfies Meta;
@@ -27,45 +40,72 @@ type Story = StoryObj;
export const Default = {
render: (args) => (
-
-
-
-
+ {(pt1, pt2, pt3, pt4, pt5, pt6) => (
+
+
+
+
+
+
+ )}
+
+ ),
+} satisfies Story;
-
- {(pt1, pt2, pt3, pt4, pt5, pt6) => (
+export const Draggable = {
+ render: (args) => {
+ const [center, setCenter] = useState(() => new Vector3(2, 6));
+
+ const { delta, isDragging, startDrag } = useDrag({
+ onDragEnd: (d) => setCenter((c) => c.clone().add(d)),
+ });
+
+ return (
+
+ {(htmlCenter) => (
-
- {
+ evt.stopPropagation();
+ startDrag(evt.nativeEvent);
+ }}
/>
-
-
)}
-
- ),
+ );
+ },
} satisfies Story;
diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts
index 2b0c2c76e..51928f7ba 100644
--- a/packages/lib/src/index.ts
+++ b/packages/lib/src/index.ts
@@ -123,6 +123,7 @@ export {
useCanvasEvents,
useInteraction,
useModifierKeyPressed,
+ useDrag,
} from './interactions/hooks';
export { default as Box } from './interactions/box';
diff --git a/packages/lib/src/interactions/hooks.ts b/packages/lib/src/interactions/hooks.ts
index 5a854ea3a..394219549 100644
--- a/packages/lib/src/interactions/hooks.ts
+++ b/packages/lib/src/interactions/hooks.ts
@@ -1,7 +1,7 @@
-import { useEventListener, useToggle } from '@react-hookz/web';
+import { useEventListener, useSyncedRef, useToggle } from '@react-hookz/web';
import { useThree } from '@react-three/fiber';
import { castArray } from 'lodash';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { Vector3 } from 'three';
import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider';
@@ -248,3 +248,71 @@ function useCanvasArea(): HTMLDivElement {
state.gl.domElement.parentElement?.parentElement as HTMLDivElement,
);
}
+
+export function useDrag(opts: { onDragEnd: (delta: Vector3) => void }) {
+ const { onDragEnd } = opts;
+
+ const camera = useThree((state) => state.camera);
+ const { htmlToData } = useVisCanvasContext();
+
+ const htmlStartRef = useRef();
+ const onDragEndRef = useSyncedRef(onDragEnd);
+
+ const [delta, setDelta] = useState();
+
+ const startDrag = useCallback((evt: PointerEvent) => {
+ const { offsetX, offsetY, target, pointerId } = evt;
+
+ if (target instanceof Element) {
+ target.setPointerCapture(pointerId);
+ }
+
+ htmlStartRef.current = new Vector3(offsetX, offsetY);
+ setDelta(new Vector3());
+ }, []);
+
+ const handlePointerMove = useCallback(
+ (canvasEvt: CanvasEvent) => {
+ if (!htmlStartRef.current) {
+ return;
+ }
+
+ const dataStart = htmlToData(camera, htmlStartRef.current);
+ setDelta(canvasEvt.dataPt.sub(dataStart));
+ },
+ [camera, htmlToData],
+ );
+
+ const handlePointerUp = useCallback(
+ (canvasEvt: CanvasEvent) => {
+ if (!htmlStartRef.current) {
+ return;
+ }
+
+ const { dataPt, sourceEvent } = canvasEvt;
+ const { target, pointerId } = sourceEvent;
+
+ if (target instanceof Element) {
+ target.releasePointerCapture(pointerId);
+ }
+
+ const dataStart = htmlToData(camera, htmlStartRef.current);
+ htmlStartRef.current = undefined;
+ setDelta(undefined);
+
+ onDragEndRef.current?.(dataPt.sub(dataStart));
+ },
+ [camera, htmlToData, onDragEndRef],
+ );
+
+ useCanvasEvents({
+ onPointerMove: handlePointerMove,
+ onPointerUp: handlePointerUp,
+ });
+
+ return {
+ delta: delta || new Vector3(),
+ isDragging: !!delta,
+ startDrag,
+ };
+}