Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement useDrag hook #1478

Merged
merged 1 commit into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}