Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify and document usage of useInteraction and useModifierKeyPressed #1477

Merged
merged 2 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
31 changes: 16 additions & 15 deletions packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,30 +124,20 @@ export {
useInteraction,
useModifierKeyPressed,
} from './interactions/hooks';
export { MouseButton } from './interactions/models';
export { getModifierKeyArray } from './interactions/utils';
export { default as Box } from './interactions/box';

// Constants
export { COLOR_SCALE_TYPES, AXIS_SCALE_TYPES } from '@h5web/shared';

// Models
export { INTERPOLATORS } from './vis/heatmap/interpolators';

// Enums
axelboc marked this conversation as resolved.
Show resolved Hide resolved
export { ScaleType } from '@h5web/shared';
export { CurveType, GlyphType } from './vis/line/models';
export { ImageType } from './vis/rgb/models';
export { Notation } from './vis/matrix/models';
export { MouseButton } from './interactions/models';

export type {
ModifierKey,
Rect,
Selection,
CanvasEvent,
CanvasEventCallbacks,
InteractionInfo,
InteractionEntry,
CommonInteractionProps,
} from './interactions/models';

// Models
export type {
Domain,
VisibleDomains,
Expand Down Expand Up @@ -176,6 +166,17 @@ export type {
export type { D3Interpolator, ColorMap } from './vis/heatmap/models';
export type { ScatterAxisParams } from './vis/scatter/models';

export type {
ModifierKey,
Rect,
Selection,
CanvasEvent,
CanvasEventCallbacks,
InteractionInfo,
InteractionConfig,
CommonInteractionProps,
} from './interactions/models';

// Mock data and utilities
export {
mockMetadata,
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;
axelboc marked this conversation as resolved.
Show resolved Hide resolved
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),
axelboc marked this conversation as resolved.
Show resolved Hide resolved
);

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,
loichuder marked this conversation as resolved.
Show resolved Hide resolved
);

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);
axelboc marked this conversation as resolved.
Show resolved Hide resolved

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
Loading