diff --git a/apps/storybook/src/SvgElement.stories.tsx b/apps/storybook/src/SvgElement.stories.tsx
index c0553d1bf..eae96b1db 100644
--- a/apps/storybook/src/SvgElement.stories.tsx
+++ b/apps/storybook/src/SvgElement.stories.tsx
@@ -1,13 +1,12 @@
import {
DataToHtml,
- Pan,
+ DefaultInteractions,
ResetZoomButton,
SvgCircle,
SvgElement,
SvgLine,
SvgRect,
VisCanvas,
- Zoom,
} from '@h5web/lib';
import type { Meta, StoryObj } from '@storybook/react';
import { Vector3 } from 'three';
@@ -18,7 +17,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 +38,36 @@ type Story = StoryObj;
export const Default = {
render: (args) => (
-
-
-
-
-
-
- {(pt1, pt2, pt3, pt4, pt5, pt6) => (
-
-
-
-
-
-
- )}
-
-
+ {(pt1, pt2, pt3, pt4, pt5, pt6) => (
+
+
+
+
+
+
+ )}
+
),
} satisfies Story;
diff --git a/apps/storybook/src/Utilities.mdx b/apps/storybook/src/Utilities.mdx
index b216407d2..3979b6435 100644
--- a/apps/storybook/src/Utilities.mdx
+++ b/apps/storybook/src/Utilities.mdx
@@ -291,6 +291,28 @@ const onPointerMove = useCallback((evt: CanvasEvent) => {
}, [isModifierKeyPressed]);
```
+#### useDrag
+
+Manages a low-level drag interaction. The returned object contains:
+
+- the `delta` vector of the current drag interaction (with a fallback of `(0, 0, 0)`);
+- `isDragging`, indicating whether a drag is in progress; and
+- a `startDrag` function that must be called when the user starts interacting with the draggable element (i.e. on `pointerdown`).
+
+```ts
+useDrag(opts: UseDragOpts): UseDragState
+```
+
+The hook is typically coupled with a state, as demonstrated below. For a concrete implementation example,
+see the [_SvgElement/Draggable_](https://h5web-docs.panosc.eu/?path=/story/building-blocks-svgelement--draggable) story.
+
+```ts
+const [position, setPosition] = useState(() => new Vector3(0, 0));
+const { delta, isDragging, startDrag } = useDrag({
+ onDragEnd: (d) => setPosition((c) => c.clone().add(d)),
+});
+```
+
### Mock data
The library exposes a utility function to retrieve a mock entity's metadata and a mock dataset's value as ndarray for testing purposes.
diff --git a/apps/storybook/src/useDrag.stories.module.css b/apps/storybook/src/useDrag.stories.module.css
new file mode 100644
index 000000000..4d122e17f
--- /dev/null
+++ b/apps/storybook/src/useDrag.stories.module.css
@@ -0,0 +1,18 @@
+.dragCircle {
+ fill: teal;
+ fill-opacity: 0.5;
+ stroke: transparent;
+ stroke-width: 2;
+ pointer-events: auto;
+ cursor: grab;
+}
+
+.dragCircle:hover,
+.dragCircle[data-dragging] {
+ fill: blueviolet;
+}
+
+.dragCircle[data-dragging] {
+ stroke: darkmagenta;
+ cursor: grabbing;
+}
diff --git a/apps/storybook/src/useDrag.stories.tsx b/apps/storybook/src/useDrag.stories.tsx
new file mode 100644
index 000000000..e81af5891
--- /dev/null
+++ b/apps/storybook/src/useDrag.stories.tsx
@@ -0,0 +1,65 @@
+import {
+ DataToHtml,
+ DefaultInteractions,
+ ResetZoomButton,
+ SvgElement,
+ useDrag,
+ VisCanvas,
+} from '@h5web/lib';
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState } from 'react';
+import { Vector3 } from 'three';
+
+import FillHeight from './decorators/FillHeight';
+import styles from './useDrag.stories.module.css';
+
+const meta = {
+ title: 'Experimental/useDrag',
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ FillHeight,
+ ],
+ parameters: { layout: 'fullscreen' },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default = {
+ render: () => {
+ 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 8b99a53bd..55460e5a7 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';
@@ -13,6 +13,8 @@ import type {
InteractionConfig,
ModifierKey,
Selection,
+ UseDragOpts,
+ UseDragState,
} from './models';
const ZOOM_FACTOR = 0.95;
@@ -244,3 +246,71 @@ export function useModifierKeyPressed(
return allPressed;
}
+
+export function useDrag(opts: UseDragOpts): UseDragState {
+ 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,
+ };
+}
diff --git a/packages/lib/src/interactions/models.ts b/packages/lib/src/interactions/models.ts
index 379424b06..529a7868f 100644
--- a/packages/lib/src/interactions/models.ts
+++ b/packages/lib/src/interactions/models.ts
@@ -42,3 +42,13 @@ export interface CommonInteractionProps {
modifierKey?: ModifierKey | ModifierKey[];
disabled?: boolean;
}
+
+export interface UseDragOpts {
+ onDragEnd: (delta: Vector3) => void;
+}
+
+export interface UseDragState {
+ delta: Vector3;
+ isDragging: boolean;
+ startDrag: (evt: PointerEvent) => void;
+}