diff --git a/apps/storybook/src/Utilities.mdx b/apps/storybook/src/Utilities.mdx index 175bd8bfa..25daa8d90 100644 --- a/apps/storybook/src/Utilities.mdx +++ b/apps/storybook/src/Utilities.mdx @@ -243,6 +243,54 @@ const pt = useCameraState( ); ``` +#### useInteraction + +Register an interaction. You must provide a unique ID that is not used by other interactions inside the current `VisCanvas` (pan, zoom, etc.) + +The hook returns a function, conventionally named `shouldInteract`, that allows testing if a given mouse event (`PointerEvent` or `WheelEvent`) +is allowed to start or continue the interaction. It checks whether the event was triggered with the same mouse button and modifier key(s) +with which the interaction was registered and ensures that there is no interaction that is better suited to handle this event. + +```ts +useInteraction( + id: string, + config: InteractionConfig, +): (event: MouseEvent) => boolean + +const shouldInteract = useInteraction('MyInteraction', { + button: MouseButton.Left, + modifierKey: 'Control', +}) + +const onPointerDown = useCallback((evt: CanvasEvent) => { + if (shouldInteract(evt.sourceEvent)) { + /* ... */ + } +}, +[shouldInteract]); + +useCanvasEvents({ onPointerDown }}; +``` + +#### useModifierKeyPressed + +Keeps track of the pressed state of one or more modifier keys. + +The hook does not require a mouse event to be fired to know the state of the modifier keys, which allows reacting to the user releasing +a key at any time, even when the mouse is immobile. + +```ts +useModifierKeyPressed(modifierKeys: ModifierKey[]): boolean + +const isModifierKeyPressed = useModifierKeyPressed(['Shift']); + +const onPointerMove = useCallback((evt: CanvasEvent) => { + if (isModifierKeyPressed) { + return; + } +}, [isModifierKeyPressed]); +``` + ### 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/packages/lib/src/interactions/InteractionsProvider.tsx b/packages/lib/src/interactions/InteractionsProvider.tsx index 7301cb2ce..4bd12ea1e 100644 --- a/packages/lib/src/interactions/InteractionsProvider.tsx +++ b/packages/lib/src/interactions/InteractionsProvider.tsx @@ -1,18 +1,15 @@ import type { ReactNode } from 'react'; import { createContext, useCallback, useContext, useState } from 'react'; -import type { InteractionEntry, ModifierKey, MouseButton } from './models'; +import { Interaction } from './interaction'; +import type { InteractionConfig } from './models'; export interface InteractionsContextValue { - registerInteraction: (id: string, value: InteractionEntry) => void; + registerInteraction: (id: string, config: InteractionConfig) => void; unregisterInteraction: (id: string) => void; shouldInteract: (id: string, event: MouseEvent) => boolean; } -interface MapEntry extends InteractionEntry { - id: string; -} - const InteractionsContext = createContext({} as InteractionsContextValue); export function useInteractionsContext() { @@ -22,14 +19,14 @@ export function useInteractionsContext() { function InteractionsProvider(props: { children: ReactNode }) { const { children } = props; - const [interactionMap] = useState(new Map()); + const [interactionMap] = useState(new Map()); const registerInteraction = useCallback( - (id: string, value: InteractionEntry) => { + (id: string, config: InteractionConfig) => { if (interactionMap.has(id)) { console.warn(`An interaction with ID "${id}" is already registered.`); // eslint-disable-line no-console } else { - interactionMap.set(id, { id, ...value }); + interactionMap.set(id, new Interaction(id, config)); } }, [interactionMap], @@ -45,28 +42,12 @@ function InteractionsProvider(props: { children: ReactNode }) { const shouldInteract = useCallback( (interactionId: string, event: MouseEvent | WheelEvent) => { const registeredInteractions = [...interactionMap.values()]; - - function isButtonPressed(button: MouseButton | MouseButton[] | 'Wheel') { - if (event instanceof WheelEvent) { - return button === 'Wheel'; - } - - return Array.isArray(button) - ? button.includes(event.button) - : event.button === button; - } - - function areKeysPressed(keys: ModifierKey[]) { - return keys.every((k) => event.getModifierState(k)); - } - if (!interactionMap.has(interactionId)) { throw new Error(`Interaction ${interactionId} is not registered`); } const matchingInteractions = registeredInteractions.filter( - ({ modifierKeys: keys, button, disabled }) => - !disabled && isButtonPressed(button) && areKeysPressed(keys), + (interaction) => interaction.matches(event), ); if (matchingInteractions.length === 0) { @@ -77,13 +58,12 @@ function InteractionsProvider(props: { children: ReactNode }) { return matchingInteractions[0].id === interactionId; } - // If conflicting interactions, the one with the most modifier keys take precedence - matchingInteractions.sort( - (a, b) => b.modifierKeys.length - a.modifierKeys.length, + // If conflicting interactions, the one with the most modifier keys takes precedence + const maxKeysInteraction = matchingInteractions.reduce((acc, next) => + next.modifierKeys.length > acc.modifierKeys.length ? next : acc, ); - const [maxKeyInteraction] = matchingInteractions; - return maxKeyInteraction.id === interactionId; + return maxKeysInteraction.id === interactionId; }, [interactionMap], ); diff --git a/packages/lib/src/interactions/Pan.tsx b/packages/lib/src/interactions/Pan.tsx index 03aeb0229..b66ab040f 100644 --- a/packages/lib/src/interactions/Pan.tsx +++ b/packages/lib/src/interactions/Pan.tsx @@ -10,7 +10,6 @@ import { } from './hooks'; import type { CanvasEvent, CommonInteractionProps } from './models'; import { MouseButton } from './models'; -import { getModifierKeyArray } from './utils'; interface Props extends CommonInteractionProps { id?: string; @@ -25,18 +24,13 @@ function Pan(props: Props) { disabled, } = props; - const modifierKeys = getModifierKeyArray(modifierKey); - const shouldInteract = useInteraction(id, { - button, - modifierKeys, - disabled, - }); + const shouldInteract = useInteraction(id, { button, modifierKey, disabled }); const camera = useThree((state) => state.camera); const moveCameraTo = useMoveCameraTo(); const startOffsetPosition = useRef(); // `useRef` to avoid re-renders - const isModifierKeyPressed = useModifierKeyPressed(modifierKeys); + const isModifierKeyPressed = useModifierKeyPressed(modifierKey); const onPointerDown = useCallback( (evt: CanvasEvent) => { diff --git a/packages/lib/src/interactions/SelectionTool.tsx b/packages/lib/src/interactions/SelectionTool.tsx index cfe6ff757..bd62ee466 100644 --- a/packages/lib/src/interactions/SelectionTool.tsx +++ b/packages/lib/src/interactions/SelectionTool.tsx @@ -25,7 +25,6 @@ import type { Selection, } from './models'; import { MouseButton } from './models'; -import { getModifierKeyArray } from './utils'; interface Props extends CommonInteractionProps { id?: string; @@ -80,12 +79,11 @@ function SelectionTool(props: Props) { const startEvtRef = useRef>(); const hasSuccessfullyEndedRef = useRef(false); - const modifierKeys = getModifierKeyArray(modifierKey); - const isModifierKeyPressed = useModifierKeyPressed(modifierKeys); + const isModifierKeyPressed = useModifierKeyPressed(modifierKey); const shouldInteract = useInteraction(id, { button: MouseButton.Left, - modifierKeys, + modifierKey, disabled, }); diff --git a/packages/lib/src/interactions/XAxisZoom.tsx b/packages/lib/src/interactions/XAxisZoom.tsx index ea0901468..d2e187339 100644 --- a/packages/lib/src/interactions/XAxisZoom.tsx +++ b/packages/lib/src/interactions/XAxisZoom.tsx @@ -1,7 +1,6 @@ import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider'; import { useCanvasEvents, useInteraction, useZoomOnWheel } from './hooks'; import type { CommonInteractionProps } from './models'; -import { getModifierKeyArray } from './utils'; type Props = CommonInteractionProps; @@ -10,9 +9,9 @@ function XAxisZoom(props: Props) { const { visRatio } = useVisCanvasContext(); const shouldInteract = useInteraction('XAxisZoom', { - modifierKeys: getModifierKeyArray(modifierKey), - disabled: visRatio !== undefined || disabled, button: 'Wheel', + modifierKey, + disabled: visRatio !== undefined || disabled, }); const isZoomAllowed = (sourceEvent: WheelEvent) => ({ diff --git a/packages/lib/src/interactions/YAxisZoom.tsx b/packages/lib/src/interactions/YAxisZoom.tsx index 58185b66a..dc2cab8f2 100644 --- a/packages/lib/src/interactions/YAxisZoom.tsx +++ b/packages/lib/src/interactions/YAxisZoom.tsx @@ -1,7 +1,6 @@ import { useVisCanvasContext } from '../vis/shared/VisCanvasProvider'; import { useCanvasEvents, useInteraction, useZoomOnWheel } from './hooks'; import type { CommonInteractionProps } from './models'; -import { getModifierKeyArray } from './utils'; type Props = CommonInteractionProps; @@ -10,9 +9,9 @@ function YAxisZoom(props: Props) { const { visRatio } = useVisCanvasContext(); const shouldInteract = useInteraction('YAxisZoom', { - modifierKeys: getModifierKeyArray(modifierKey), - disabled: visRatio !== undefined || disabled, button: 'Wheel', + modifierKey, + disabled: visRatio !== undefined || disabled, }); const isZoomAllowed = (sourceEvent: WheelEvent) => ({ diff --git a/packages/lib/src/interactions/Zoom.tsx b/packages/lib/src/interactions/Zoom.tsx index 2efc00baa..d1731752c 100644 --- a/packages/lib/src/interactions/Zoom.tsx +++ b/packages/lib/src/interactions/Zoom.tsx @@ -1,15 +1,14 @@ import { useCanvasEvents, useInteraction, useZoomOnWheel } from './hooks'; import type { CommonInteractionProps } from './models'; -import { getModifierKeyArray } from './utils'; type Props = CommonInteractionProps; function Zoom(props: Props) { const { modifierKey, disabled } = props; const shouldInteract = useInteraction('Zoom', { - modifierKeys: getModifierKeyArray(modifierKey), - disabled, button: 'Wheel', + modifierKey, + disabled, }); const isZoomAllowed = (sourceEvent: WheelEvent) => { diff --git a/packages/lib/src/interactions/hooks.ts b/packages/lib/src/interactions/hooks.ts index 7a0399311..7a286f653 100644 --- a/packages/lib/src/interactions/hooks.ts +++ b/packages/lib/src/interactions/hooks.ts @@ -1,5 +1,6 @@ import { useEventListener, useToggle } from '@react-hookz/web'; import { useThree } from '@react-three/fiber'; +import { castArray } from 'lodash'; import { useCallback, useEffect, useState } from 'react'; import { Vector3 } from 'three'; @@ -9,7 +10,7 @@ import { useInteractionsContext } from './InteractionsProvider'; import type { CanvasEvent, CanvasEventCallbacks, - InteractionEntry, + InteractionConfig, ModifierKey, Selection, } from './models'; @@ -181,14 +182,17 @@ export function useCanvasEvents(callbacks: CanvasEventCallbacks): void { useEventListener(domElement, 'wheel', handleWheel); } -export function useInteraction(id: string, value: InteractionEntry) { +export function useInteraction( + id: string, + config: InteractionConfig, +): (event: MouseEvent) => boolean { const { shouldInteract, registerInteraction, unregisterInteraction } = useInteractionsContext(); useEffect(() => { - registerInteraction(id, value); + registerInteraction(id, config); return () => unregisterInteraction(id); - }, [id, registerInteraction, unregisterInteraction, value]); + }, [id, registerInteraction, unregisterInteraction, config]); return useCallback( (event: MouseEvent) => shouldInteract(id, event), @@ -196,7 +200,10 @@ export function useInteraction(id: string, value: InteractionEntry) { ); } -export function useModifierKeyPressed(modifierKeys: ModifierKey[]): boolean { +export function useModifierKeyPressed( + modifierKey: ModifierKey | ModifierKey[] = [], +): boolean { + const modifierKeys = castArray(modifierKey); const { domElement } = useThree((state) => state.gl); const [pressedKeys] = useState(new Map()); diff --git a/packages/lib/src/interactions/interaction.ts b/packages/lib/src/interactions/interaction.ts new file mode 100644 index 000000000..02311d06f --- /dev/null +++ b/packages/lib/src/interactions/interaction.ts @@ -0,0 +1,44 @@ +import { castArray } from 'lodash'; +import type { ModifierKey } from 'react'; + +import type { InteractionConfig } from './models'; +import { MouseButton } from './models'; + +export class Interaction { + public readonly buttons: MouseButton[]; + public readonly modifierKeys: ModifierKey[]; + public readonly isWheel: boolean; + public readonly isEnabled: boolean; + + public constructor( + public readonly id: string, + config: InteractionConfig, + ) { + const { + button = MouseButton.Left, + modifierKey = [], + disabled = false, + } = config; + + if (button === 'Wheel') { + this.buttons = []; + this.isWheel = true; + } else { + this.buttons = castArray(button); + this.isWheel = false; + } + + this.modifierKeys = castArray(modifierKey); + this.isEnabled = !disabled; + } + + public matches(event: MouseEvent): boolean { + return ( + this.isEnabled && + (event instanceof WheelEvent + ? this.isWheel + : this.buttons.includes(event.button)) && + this.modifierKeys.every((key) => event.getModifierState(key)) + ); + } +} diff --git a/packages/lib/src/interactions/models.ts b/packages/lib/src/interactions/models.ts index 5612600a2..379424b06 100644 --- a/packages/lib/src/interactions/models.ts +++ b/packages/lib/src/interactions/models.ts @@ -34,10 +34,8 @@ export interface InteractionInfo { description: string; } -export interface InteractionEntry { - button: MouseButton | MouseButton[] | 'Wheel'; - modifierKeys: ModifierKey[]; - disabled?: boolean; +export interface InteractionConfig extends CommonInteractionProps { + button?: MouseButton | MouseButton[] | 'Wheel'; } export interface CommonInteractionProps { diff --git a/packages/lib/src/interactions/utils.ts b/packages/lib/src/interactions/utils.ts deleted file mode 100644 index b28dd44bd..000000000 --- a/packages/lib/src/interactions/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { castArray } from 'lodash'; - -import type { ModifierKey } from './models'; - -export function getModifierKeyArray( - keys: ModifierKey | ModifierKey[] | undefined = [], -): ModifierKey[] { - return castArray(keys); -}