Skip to content

Commit

Permalink
Simplify and document usage of useInteraction and `useModifierKeyPr…
Browse files Browse the repository at this point in the history
…essed`
  • Loading branch information
axelboc committed Aug 21, 2023
1 parent 2fcf775 commit a658f47
Show file tree
Hide file tree
Showing 11 changed files with 127 additions and 70 deletions.
48 changes: 48 additions & 0 deletions apps/storybook/src/Utilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PointerEvent>) => {
if (shouldInteract(evt.sourceEvent)) {
/* ... */
}
},
[shouldInteract]);

useCanvasEvents({ onPointerDown }};
```
#### useModifierKeyPressed
Keeps track of the pressed state of one or more modifier keys.
The hook removes the need for a mouse event to be fired to know the state of the given modifier keys, which allows reacting to the user releasing
a key at any time, even when the mouse is immobile.
```ts
useModifierKeyPressed(modifierKey?: ModifierKey | ModifierKey[]): boolean

const isModifierKeyPressed = useModifierKeyPressed('Shift');

const onPointerMove = useCallback((evt: CanvasEvent<PointerEvent>) => {
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.
Expand Down
42 changes: 11 additions & 31 deletions packages/lib/src/interactions/InteractionsProvider.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -22,14 +19,14 @@ export function useInteractionsContext() {
function InteractionsProvider(props: { children: ReactNode }) {
const { children } = props;

const [interactionMap] = useState(new Map<string, MapEntry>());
const [interactionMap] = useState(new Map<string, Interaction>());

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],
Expand All @@ -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) {
Expand All @@ -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],
);
Expand Down
10 changes: 2 additions & 8 deletions packages/lib/src/interactions/Pan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Vector3>(); // `useRef` to avoid re-renders
const isModifierKeyPressed = useModifierKeyPressed(modifierKeys);
const isModifierKeyPressed = useModifierKeyPressed(modifierKey);

const onPointerDown = useCallback(
(evt: CanvasEvent<PointerEvent>) => {
Expand Down
6 changes: 2 additions & 4 deletions packages/lib/src/interactions/SelectionTool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import type {
Selection,
} from './models';
import { MouseButton } from './models';
import { getModifierKeyArray } from './utils';

interface Props extends CommonInteractionProps {
id?: string;
Expand Down Expand Up @@ -80,12 +79,11 @@ function SelectionTool(props: Props) {
const startEvtRef = useRef<CanvasEvent<PointerEvent>>();
const hasSuccessfullyEndedRef = useRef<boolean>(false);

const modifierKeys = getModifierKeyArray(modifierKey);
const isModifierKeyPressed = useModifierKeyPressed(modifierKeys);
const isModifierKeyPressed = useModifierKeyPressed(modifierKey);

const shouldInteract = useInteraction(id, {
button: MouseButton.Left,
modifierKeys,
modifierKey,
disabled,
});

Expand Down
5 changes: 2 additions & 3 deletions packages/lib/src/interactions/XAxisZoom.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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) => ({
Expand Down
5 changes: 2 additions & 3 deletions packages/lib/src/interactions/YAxisZoom.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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) => ({
Expand Down
5 changes: 2 additions & 3 deletions packages/lib/src/interactions/Zoom.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
17 changes: 12 additions & 5 deletions packages/lib/src/interactions/hooks.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -9,7 +10,7 @@ import { useInteractionsContext } from './InteractionsProvider';
import type {
CanvasEvent,
CanvasEventCallbacks,
InteractionEntry,
InteractionConfig,
ModifierKey,
Selection,
} from './models';
Expand Down Expand Up @@ -181,22 +182,28 @@ 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),
[id, shouldInteract],
);
}

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<string, boolean>());
Expand Down
44 changes: 44 additions & 0 deletions packages/lib/src/interactions/interaction.ts
Original file line number Diff line number Diff line change
@@ -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))
);
}
}
6 changes: 2 additions & 4 deletions packages/lib/src/interactions/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 0 additions & 9 deletions packages/lib/src/interactions/utils.ts

This file was deleted.

0 comments on commit a658f47

Please sign in to comment.