diff --git a/apps/storybook/src/SvgElement.stories.tsx b/apps/storybook/src/SvgElement.stories.tsx index c0553d1bf..9500d878b 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'; @@ -31,10 +30,8 @@ export const Default = { abscissaConfig={{ visDomain: [0, 10], showGrid: true }} ordinateConfig={{ visDomain: [0, 10], showGrid: true }} > - - + - ) => { }, [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. 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; +}