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, + }; +}