diff --git a/apps/storybook/src/Annotation.stories.tsx b/apps/storybook/src/Annotation.stories.tsx index 021c64b64..deea1b950 100644 --- a/apps/storybook/src/Annotation.stories.tsx +++ b/apps/storybook/src/Annotation.stories.tsx @@ -2,13 +2,12 @@ import type { CanvasEvent } from '@h5web/lib'; import { Annotation, DefaultInteractions, - useCanvasEvents, + useCanvasEvent, VisCanvas, } from '@h5web/lib'; import { useRafState } from '@react-hookz/web'; import type { Meta, StoryObj } from '@storybook/react'; import type { ReactNode } from 'react'; -import { useCallback } from 'react'; import FillHeight from './decorators/FillHeight'; import { formatCoord } from './utils'; @@ -158,15 +157,10 @@ function PointerTracker(props: { const { children } = props; const [coords, setCoords] = useRafState<[number, number]>(); - const onPointerMove = useCallback( - (evt: CanvasEvent) => { - const { x, y } = evt.dataPt; - setCoords([x, y]); - }, - [setCoords], - ); - - useCanvasEvents({ onPointerMove }); + useCanvasEvent('pointermove', (evt: CanvasEvent) => { + const { x, y } = evt.dataPt; + setCoords([x, y]); + }); // eslint-disable-next-line react/jsx-no-useless-fragment return <>{coords ? children(...coords) : null}; diff --git a/apps/storybook/src/Utilities.mdx b/apps/storybook/src/Utilities.mdx index 3a00bca4f..80dceba32 100644 --- a/apps/storybook/src/Utilities.mdx +++ b/apps/storybook/src/Utilities.mdx @@ -184,21 +184,27 @@ getAxisDomain([4, 2, 0], ScaleType.Linear, 0.5); // [6, -2]) - **`useAxisDomain(..args): Domain | undefined`** - Memoised version of `getAxisDomain`. - **`useValueToIndexScale(..args): ScaleThreshold`** - Memoised version of `getValueToIndexScale`. -#### useCanvasEvents +#### useCanvasEvent -Register event listeners on the `canvas` element. Useful to implement custom interactions. Must be called from a child component of `VisCanvas`. +Register an event listener on the `canvas` element. Only events that extend from the `MouseEvent` interface are allowed: `pointer`, `wheel`, `click`, `dblclick`, etc.) +The listener receives the coordinates of the mouse event in **HTML space** (`(offsetX, offsetY)`), Three.js **world space**, and **data space**, +which is convenient when implementing custom interactions. ```ts -useCanvasEvents(callbacks: CanvasEventCallbacks): void +useCanvasEvent( + mouseEventName: 'pointerdown' | 'pointerup' | 'pointermove' | 'wheel' | 'click' | ..., + listener: (evt: CanvasEvent) => void, + options: AddEventListenerOptions = {}, +): void -const handlePointerDown = useCallback((evt: CanvasEvent) => { +useCanvasEvent('pointerdown', (evt: CanvasEvent) => { const { htmlPt, worldPt, dataPt, sourceEvent } = evt; // ... -}, []); - -useCanvasEvents({ onPointerDown: handlePointerDown }); // also supported: `onPointerMove`, `onPointerUp` and `onWheel` +}); ``` +> Note that the hook must be called from a child component of `VisCanvas`. + #### useCameraState Compute and update a state on every React Three Fiber frame. Useful to re-render React components when the user pans/zooms. @@ -262,14 +268,11 @@ const shouldInteract = useInteraction('MyInteraction', { modifierKey: 'Control', }) -const onPointerDown = useCallback((evt: CanvasEvent) => { +useCanvasEvent('pointerdown', (evt: CanvasEvent) => { if (shouldInteract(evt.sourceEvent)) { /* ... */ } -}, -[shouldInteract]); - -useCanvasEvents({ onPointerDown }}; +}); ``` #### useModifierKeyPressed diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 51928f7ba..ad01d44f3 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -120,7 +120,7 @@ export { useVisDomain, useSafeDomain } from './vis/heatmap/hooks'; export { scaleGamma } from './vis/scaleGamma'; export { - useCanvasEvents, + useCanvasEvent, useInteraction, useModifierKeyPressed, useDrag, @@ -172,7 +172,6 @@ export type { Rect, Selection, CanvasEvent, - CanvasEventCallbacks, InteractionInfo, InteractionConfig, CommonInteractionProps, diff --git a/packages/lib/src/interactions/Pan.tsx b/packages/lib/src/interactions/Pan.tsx index b66ab040f..9fd4312aa 100644 --- a/packages/lib/src/interactions/Pan.tsx +++ b/packages/lib/src/interactions/Pan.tsx @@ -1,9 +1,9 @@ import { useThree } from '@react-three/fiber'; -import { useCallback, useRef } from 'react'; +import { useRef } from 'react'; import type { Vector3 } from 'three'; import { - useCanvasEvents, + useCanvasEvent, useInteraction, useModifierKeyPressed, useMoveCameraTo, @@ -32,41 +32,37 @@ function Pan(props: Props) { const startOffsetPosition = useRef(); // `useRef` to avoid re-renders const isModifierKeyPressed = useModifierKeyPressed(modifierKey); - const onPointerDown = useCallback( - (evt: CanvasEvent) => { - const { worldPt, sourceEvent } = evt; - const { target, pointerId } = sourceEvent; + function handlePointerDown(evt: CanvasEvent) { + const { worldPt, sourceEvent } = evt; + const { target, pointerId } = sourceEvent; + + if (shouldInteract(sourceEvent)) { + (target as Element).setPointerCapture(pointerId); // https://stackoverflow.com/q/28900077/758806 + startOffsetPosition.current = worldPt.clone(); + } + } + + function handlePointerMove(evt: CanvasEvent) { + if (!startOffsetPosition.current || !isModifierKeyPressed) { + return; + } - if (shouldInteract(sourceEvent)) { - (target as Element).setPointerCapture(pointerId); // https://stackoverflow.com/q/28900077/758806 - startOffsetPosition.current = worldPt.clone(); - } - }, - [shouldInteract], - ); + const { worldPt } = evt; + const delta = startOffsetPosition.current.clone().sub(worldPt); + moveCameraTo(camera.position.clone().add(delta)); + } - const onPointerUp = useCallback((evt: CanvasEvent) => { + function handlePointerUp(evt: CanvasEvent) { const { sourceEvent } = evt; const { target, pointerId } = sourceEvent; (target as Element).releasePointerCapture(pointerId); // https://stackoverflow.com/q/28900077/758806 startOffsetPosition.current = undefined; - }, []); - - const onPointerMove = useCallback( - (evt: CanvasEvent) => { - if (!startOffsetPosition.current || !isModifierKeyPressed) { - return; - } - - const { worldPt } = evt; - const delta = startOffsetPosition.current.clone().sub(worldPt); - moveCameraTo(camera.position.clone().add(delta)); - }, - [camera, isModifierKeyPressed, moveCameraTo], - ); + } - useCanvasEvents({ onPointerDown, onPointerMove, onPointerUp }); + useCanvasEvent('pointerdown', handlePointerDown); + useCanvasEvent('pointermove', handlePointerMove); + useCanvasEvent('pointerup', handlePointerUp); return null; } diff --git a/packages/lib/src/interactions/SelectionTool.tsx b/packages/lib/src/interactions/SelectionTool.tsx index bd62ee466..d0ec77e8d 100644 --- a/packages/lib/src/interactions/SelectionTool.tsx +++ b/packages/lib/src/interactions/SelectionTool.tsx @@ -8,16 +8,12 @@ import { } from '@react-hookz/web'; import { useThree } from '@react-three/fiber'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import type { Camera } from 'three'; import type { VisCanvasContextValue } from '../vis/shared/VisCanvasProvider'; import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider'; -import { - useCanvasEvents, - useInteraction, - useModifierKeyPressed, -} from './hooks'; +import { useCanvasEvent, useInteraction, useModifierKeyPressed } from './hooks'; import type { CanvasEvent, CommonInteractionProps, @@ -87,55 +83,48 @@ function SelectionTool(props: Props) { disabled, }); - const onPointerDown = useCallback( - (evt: CanvasEvent) => { - const { sourceEvent } = evt; - if (!shouldInteract(sourceEvent)) { - return; - } + function handlePointerDown(evt: CanvasEvent) { + const { sourceEvent } = evt; + if (!shouldInteract(sourceEvent)) { + return; + } - const { target, pointerId } = sourceEvent; - (target as Element).setPointerCapture(pointerId); + const { target, pointerId } = sourceEvent; + (target as Element).setPointerCapture(pointerId); - startEvtRef.current = evt; - }, - [shouldInteract], - ); + startEvtRef.current = evt; + } - const onPointerMove = useCallback( - (evt: CanvasEvent) => { - if (!startEvtRef.current) { - return; - } + function handlePointerMove(evt: CanvasEvent) { + if (!startEvtRef.current) { + return; + } - const { htmlPt: htmlStart } = startEvtRef.current; - const html: Rect = [htmlStart, canvasBox.clampPoint(evt.htmlPt)]; - const world = html.map((pt) => htmlToWorld(camera, pt)) as Rect; - const data = world.map(worldToData) as Rect; + const { htmlPt: htmlStart } = startEvtRef.current; + const html: Rect = [htmlStart, canvasBox.clampPoint(evt.htmlPt)]; + const world = html.map((pt) => htmlToWorld(camera, pt)) as Rect; + const data = world.map(worldToData) as Rect; - setRawSelection({ html, world, data }); - }, - [camera, canvasBox, htmlToWorld, worldToData, setRawSelection], - ); + setRawSelection({ html, world, data }); + } - const onPointerUp = useCallback( - (evt: CanvasEvent) => { - if (!startEvtRef.current) { - return; - } + function handlePointerUp(evt: CanvasEvent) { + if (!startEvtRef.current) { + return; + } - const { sourceEvent } = evt; - const { target, pointerId } = sourceEvent; - (target as Element).releasePointerCapture(pointerId); + const { sourceEvent } = evt; + const { target, pointerId } = sourceEvent; + (target as Element).releasePointerCapture(pointerId); - startEvtRef.current = undefined; - hasSuccessfullyEndedRef.current = shouldInteract(sourceEvent); - setRawSelection(undefined); - }, - [setRawSelection, shouldInteract], - ); + startEvtRef.current = undefined; + hasSuccessfullyEndedRef.current = shouldInteract(sourceEvent); + setRawSelection(undefined); + } - useCanvasEvents({ onPointerDown, onPointerMove, onPointerUp }); + useCanvasEvent('pointerdown', handlePointerDown); + useCanvasEvent('pointermove', handlePointerMove); + useCanvasEvent('pointerup', handlePointerUp); function cancelSelection() { startEvtRef.current = undefined; diff --git a/packages/lib/src/interactions/XAxisZoom.tsx b/packages/lib/src/interactions/XAxisZoom.tsx index d2e187339..74c096123 100644 --- a/packages/lib/src/interactions/XAxisZoom.tsx +++ b/packages/lib/src/interactions/XAxisZoom.tsx @@ -1,5 +1,10 @@ import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider'; -import { useCanvasEvents, useInteraction, useZoomOnWheel } from './hooks'; +import { + useCanvasEvent, + useInteraction, + useWheelCapture, + useZoomOnWheel, +} from './hooks'; import type { CommonInteractionProps } from './models'; type Props = CommonInteractionProps; @@ -19,7 +24,8 @@ function XAxisZoom(props: Props) { y: false, }); - useCanvasEvents({ onWheel: useZoomOnWheel(isZoomAllowed) }); + useWheelCapture(); + useCanvasEvent('wheel', useZoomOnWheel(isZoomAllowed)); return null; } diff --git a/packages/lib/src/interactions/YAxisZoom.tsx b/packages/lib/src/interactions/YAxisZoom.tsx index dc2cab8f2..e579395cc 100644 --- a/packages/lib/src/interactions/YAxisZoom.tsx +++ b/packages/lib/src/interactions/YAxisZoom.tsx @@ -1,5 +1,10 @@ import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider'; -import { useCanvasEvents, useInteraction, useZoomOnWheel } from './hooks'; +import { + useCanvasEvent, + useInteraction, + useWheelCapture, + useZoomOnWheel, +} from './hooks'; import type { CommonInteractionProps } from './models'; type Props = CommonInteractionProps; @@ -19,7 +24,8 @@ function YAxisZoom(props: Props) { y: shouldInteract(sourceEvent), }); - useCanvasEvents({ onWheel: useZoomOnWheel(isZoomAllowed) }); + useWheelCapture(); + useCanvasEvent('wheel', useZoomOnWheel(isZoomAllowed)); return null; } diff --git a/packages/lib/src/interactions/Zoom.tsx b/packages/lib/src/interactions/Zoom.tsx index d1731752c..f4a64aa4c 100644 --- a/packages/lib/src/interactions/Zoom.tsx +++ b/packages/lib/src/interactions/Zoom.tsx @@ -1,4 +1,9 @@ -import { useCanvasEvents, useInteraction, useZoomOnWheel } from './hooks'; +import { + useCanvasEvent, + useInteraction, + useWheelCapture, + useZoomOnWheel, +} from './hooks'; import type { CommonInteractionProps } from './models'; type Props = CommonInteractionProps; @@ -17,7 +22,8 @@ function Zoom(props: Props) { return { x: shouldZoom, y: shouldZoom }; }; - useCanvasEvents({ onWheel: useZoomOnWheel(isZoomAllowed) }); + useWheelCapture(); + useCanvasEvent('wheel', useZoomOnWheel(isZoomAllowed)); return null; } diff --git a/packages/lib/src/interactions/hooks.ts b/packages/lib/src/interactions/hooks.ts index 55460e5a7..cd00f9f9a 100644 --- a/packages/lib/src/interactions/hooks.ts +++ b/packages/lib/src/interactions/hooks.ts @@ -9,9 +9,9 @@ import Box from './box'; import { useInteractionsContext } from './InteractionsProvider'; import type { CanvasEvent, - CanvasEventCallbacks, InteractionConfig, ModifierKey, + MouseEventName, Selection, UseDragOpts, UseDragState, @@ -67,16 +67,15 @@ export function useZoomOnSelection() { ); } -function useWheelCapture() { +export function useWheelCapture() { const { canvasArea } = useVisCanvasContext(); - const onWheel = useCallback((evt: WheelEvent) => { - evt.preventDefault(); - }, []); - - // Handler must be registed as non-passive for `preventDefault` to have an effect - // (React's `onWheel` prop registers handlers as passive) - useEventListener(canvasArea, 'wheel', onWheel, { passive: false }); + useEventListener( + canvasArea, + 'wheel', + (evt: WheelEvent) => evt.preventDefault(), + { passive: false }, // for `preventDefault` to have an effect (React's `onWheel` prop registers handlers as passive) + ); } export function useZoomOnWheel( @@ -85,106 +84,63 @@ export function useZoomOnWheel( const camera = useThree((state) => state.camera); const moveCameraTo = useMoveCameraTo(); - const onWheel = useCallback( - (evt: CanvasEvent) => { - const { sourceEvent, worldPt } = evt; - const { x: zoomX, y: zoomY } = isZoomAllowed(sourceEvent); + return function handleWheel(evt: CanvasEvent) { + const { sourceEvent, worldPt } = evt; + const { x: zoomX, y: zoomY } = isZoomAllowed(sourceEvent); - if (!zoomX && !zoomY) { - return; - } - - const zoomVector = new Vector3( - zoomX ? ZOOM_FACTOR : 1, - zoomY ? ZOOM_FACTOR : 1, - 1, - ); + if (!zoomX && !zoomY) { + return; + } - // sourceEvent.deltaY < 0 => Wheel down => decrease scale to reduce FOV - if (sourceEvent.deltaY < 0) { - camera.scale.multiply(zoomVector).min(ONE_VECTOR); - } else { - // Use `divide` instead of `multiply` by 1 / zoomVector to avoid rounding issues (https://github.com/silx-kit/h5web/issues/1088) - camera.scale.divide(zoomVector).min(ONE_VECTOR); - } - - // Scale change in position according to zoom - const delta = camera.position.clone().sub(worldPt); - if (sourceEvent.deltaY < 0) { - delta.multiply(zoomVector); - } else { - delta.divide(zoomVector); - } - - moveCameraTo(worldPt.clone().add(delta)); - }, - [camera, isZoomAllowed, moveCameraTo], - ); + const zoomVector = new Vector3( + zoomX ? ZOOM_FACTOR : 1, + zoomY ? ZOOM_FACTOR : 1, + 1, + ); + + // sourceEvent.deltaY < 0 => Wheel down => decrease scale to reduce FOV + if (sourceEvent.deltaY < 0) { + camera.scale.multiply(zoomVector).min(ONE_VECTOR); + } else { + // Use `divide` instead of `multiply` by 1 / zoomVector to avoid rounding issues (https://github.com/silx-kit/h5web/issues/1088) + camera.scale.divide(zoomVector).min(ONE_VECTOR); + } - useWheelCapture(); + // Scale change in position according to zoom + const delta = camera.position.clone().sub(worldPt); + if (sourceEvent.deltaY < 0) { + delta.multiply(zoomVector); + } else { + delta.divide(zoomVector); + } - return onWheel; + moveCameraTo(worldPt.clone().add(delta)); + }; } -export function useCanvasEvents(callbacks: CanvasEventCallbacks): void { - const { onPointerDown, onPointerMove, onPointerUp, onWheel } = callbacks; - +export function useCanvasEvent< + T extends MouseEventName, + U extends GlobalEventHandlersEventMap[T], +>( + mouseEventName: T, + listener: (evt: CanvasEvent) => void, + options: AddEventListenerOptions = {}, +): void { + const listenerRef = useSyncedRef(listener); // no need to memoise listener with `useCallback` const camera = useThree((state) => state.camera); const { htmlToWorld, worldToData, canvasArea } = useVisCanvasContext(); - const processEvent = useCallback( - (sourceEvent: T): CanvasEvent => { - const { offsetX, offsetY } = sourceEvent; - - const htmlPt = new Vector3(offsetX, offsetY); - const worldPt = htmlToWorld(camera, htmlPt); - const dataPt = worldToData(worldPt); - - return { htmlPt, worldPt, dataPt, sourceEvent }; - }, - [camera, htmlToWorld, worldToData], - ); + function handleEvent(sourceEvent: U): void { + const { offsetX, offsetY } = sourceEvent; - const handlePointerDown = useCallback( - (sourceEvent: PointerEvent) => { - if (onPointerDown) { - onPointerDown(processEvent(sourceEvent)); - } - }, - [processEvent, onPointerDown], - ); + const htmlPt = new Vector3(offsetX, offsetY); + const worldPt = htmlToWorld(camera, htmlPt); + const dataPt = worldToData(worldPt); - const handlePointerMove = useCallback( - (sourceEvent: PointerEvent) => { - if (onPointerMove) { - onPointerMove(processEvent(sourceEvent)); - } - }, - [processEvent, onPointerMove], - ); - - const handlePointerUp = useCallback( - (sourceEvent: PointerEvent) => { - if (onPointerUp) { - onPointerUp(processEvent(sourceEvent)); - } - }, - [processEvent, onPointerUp], - ); - - const handleWheel = useCallback( - (sourceEvent: WheelEvent) => { - if (onWheel) { - onWheel(processEvent(sourceEvent)); - } - }, - [processEvent, onWheel], - ); + listenerRef.current({ htmlPt, worldPt, dataPt, sourceEvent }); + } - useEventListener(canvasArea, 'pointerdown', handlePointerDown); - useEventListener(canvasArea, 'pointermove', handlePointerMove); - useEventListener(canvasArea, 'pointerup', handlePointerUp); - useEventListener(canvasArea, 'wheel', handleWheel); + useEventListener(canvasArea, mouseEventName, handleEvent, options); } export function useInteraction( @@ -269,44 +225,36 @@ export function useDrag(opts: UseDragOpts): UseDragState { setDelta(new Vector3()); }, []); - const handlePointerMove = useCallback( - (canvasEvt: CanvasEvent) => { - if (!htmlStartRef.current) { - return; - } + function handlePointerMove(canvasEvt: CanvasEvent) { + if (!htmlStartRef.current) { + return; + } - const dataStart = htmlToData(camera, htmlStartRef.current); - setDelta(canvasEvt.dataPt.sub(dataStart)); - }, - [camera, htmlToData], - ); + const dataStart = htmlToData(camera, htmlStartRef.current); + setDelta(canvasEvt.dataPt.sub(dataStart)); + } - const handlePointerUp = useCallback( - (canvasEvt: CanvasEvent) => { - if (!htmlStartRef.current) { - return; - } + function handlePointerUp(canvasEvt: CanvasEvent) { + if (!htmlStartRef.current) { + return; + } - const { dataPt, sourceEvent } = canvasEvt; - const { target, pointerId } = sourceEvent; + const { dataPt, sourceEvent } = canvasEvt; + const { target, pointerId } = sourceEvent; - if (target instanceof Element) { - target.releasePointerCapture(pointerId); - } + if (target instanceof Element) { + target.releasePointerCapture(pointerId); + } - const dataStart = htmlToData(camera, htmlStartRef.current); - htmlStartRef.current = undefined; - setDelta(undefined); + const dataStart = htmlToData(camera, htmlStartRef.current); + htmlStartRef.current = undefined; + setDelta(undefined); - onDragEndRef.current?.(dataPt.sub(dataStart)); - }, - [camera, htmlToData, onDragEndRef], - ); + onDragEndRef.current?.(dataPt.sub(dataStart)); + } - useCanvasEvents({ - onPointerMove: handlePointerMove, - onPointerUp: handlePointerUp, - }); + useCanvasEvent('pointermove', handlePointerMove); + useCanvasEvent('pointerup', handlePointerUp); return { delta: delta || new Vector3(), diff --git a/packages/lib/src/interactions/models.ts b/packages/lib/src/interactions/models.ts index 529a7868f..0d9798d06 100644 --- a/packages/lib/src/interactions/models.ts +++ b/packages/lib/src/interactions/models.ts @@ -22,13 +22,6 @@ export interface CanvasEvent { sourceEvent: T; } -export interface CanvasEventCallbacks { - onPointerDown?: (evt: CanvasEvent) => void; - onPointerMove?: (evt: CanvasEvent) => void; - onPointerUp?: (evt: CanvasEvent) => void; - onWheel?: (evt: CanvasEvent) => void; -} - export interface InteractionInfo { shortcut: string; description: string; @@ -52,3 +45,9 @@ export interface UseDragState { isDragging: boolean; startDrag: (evt: PointerEvent) => void; } + +export type MouseEventName = { + [Key in keyof GlobalEventHandlersEventMap]: GlobalEventHandlersEventMap[Key] extends MouseEvent + ? Key + : never; +}[keyof GlobalEventHandlersEventMap];