Skip to content

Commit

Permalink
Implement useDrag hook
Browse files Browse the repository at this point in the history
  • Loading branch information
axelboc committed Aug 31, 2023
1 parent e53129e commit d6199d3
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 7 deletions.
7 changes: 2 additions & 5 deletions apps/storybook/src/SvgElement.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -31,10 +30,8 @@ export const Default = {
abscissaConfig={{ visDomain: [0, 10], showGrid: true }}
ordinateConfig={{ visDomain: [0, 10], showGrid: true }}
>
<Pan />
<Zoom />
<DefaultInteractions />
<ResetZoomButton />

<DataToHtml
points={[
new Vector3(2, 8),
Expand Down
22 changes: 22 additions & 0 deletions apps/storybook/src/Utilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,28 @@ const onPointerMove = useCallback((evt: CanvasEvent<PointerEvent>) => {
}, [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)`);
- an `isDragging` boolean, 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.
Expand Down
18 changes: 18 additions & 0 deletions apps/storybook/src/useDrag.stories.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
65 changes: 65 additions & 0 deletions apps/storybook/src/useDrag.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<VisCanvas
abscissaConfig={{ visDomain: [0, 10], showGrid: true }}
ordinateConfig={{ visDomain: [0, 10], showGrid: true }}
>
<DefaultInteractions />
<ResetZoomButton />
<Story />
</VisCanvas>
),
FillHeight,
],
parameters: { layout: 'fullscreen' },
} satisfies Meta;

export default meta;
type Story = StoryObj<typeof meta>;

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 (
<DataToHtml points={[center.clone().add(delta)]}>
{(htmlCenter) => (
<SvgElement>
<circle
className={styles.dragCircle}
cx={htmlCenter.x}
cy={htmlCenter.y}
r={40}
data-dragging={isDragging || undefined}
onPointerDown={(evt) => {
evt.stopPropagation();
startDrag(evt.nativeEvent);
}}
/>
</SvgElement>
)}
</DataToHtml>
);
},
} satisfies Story;
1 change: 1 addition & 0 deletions packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export {
useCanvasEvents,
useInteraction,
useModifierKeyPressed,
useDrag,
} from './interactions/hooks';
export { default as Box } from './interactions/box';

Expand Down
74 changes: 72 additions & 2 deletions packages/lib/src/interactions/hooks.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,6 +13,8 @@ import type {
InteractionConfig,
ModifierKey,
Selection,
UseDragOpts,
UseDragState,
} from './models';

const ZOOM_FACTOR = 0.95;
Expand Down Expand Up @@ -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<Vector3>();
const onDragEndRef = useSyncedRef(onDragEnd);

const [delta, setDelta] = useState<Vector3>();

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<PointerEvent>) => {
if (!htmlStartRef.current) {
return;
}

const dataStart = htmlToData(camera, htmlStartRef.current);
setDelta(canvasEvt.dataPt.sub(dataStart));
},
[camera, htmlToData],
);

const handlePointerUp = useCallback(
(canvasEvt: CanvasEvent<PointerEvent>) => {
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,
};
}
10 changes: 10 additions & 0 deletions packages/lib/src/interactions/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

0 comments on commit d6199d3

Please sign in to comment.