Skip to content

Commit 2cb64a0

Browse files
committed
Support right mouse button and preventing default context menu
1 parent 928618d commit 2cb64a0

9 files changed

Lines changed: 185 additions & 9 deletions

File tree

apps/storybook/.storybook/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const preview: Preview = {
1212
order: [
1313
'Getting started',
1414
'Utilities',
15-
'Context',
15+
'Contexts',
1616
'Customization',
1717
'Visualizations',
1818
['LineVis'],
Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
## Context
1+
## VisCanvasContext
22

33
Children of `VisCanvas` have access to `VisCanvasContext`, which provides helpful utilities, notably to convert points between coordinate spaces (data, world, HTML).
44
It also exposes the size and ratio of the canvas and of the visualization, as well as the axis configs passed to `VisCanvas`.
@@ -40,3 +40,19 @@ const { visSize, dataToWorld, worldToData } = useVisCanvasContext();
4040
| `r3fRoot` | React Three Fiber container rendered inside `canvasArea` that wraps the `canvas` element |
4141

4242
> These tables are not exhaustive. Please consider any undocumented property as experimental or meant for internal use only.
43+
44+
## InteractionsContext
45+
46+
Children of `VisCanvas` also have access to `InteractionsContext`, which provides low-level utilities for coordinating mouse interactivity on the canvas.
47+
However, thanks to the `useInteraction` hook, which allows registering new interactions, there's little need to access `InteractionsContext` directly.
48+
49+
The only utility you may need is `getInteractions`, which allows accessing all registered interactions that are currently enabled. If you pass mouse button(s)
50+
and/or modifier key(s) to it, it can filter the interactions and return only those that are compatible. Note that `getInteractions` only works
51+
when called inside an event handler, not during render.
52+
53+
```ts
54+
getInteractions: (
55+
button?: InteractionConfig['button'],
56+
modifierKey?: InteractionConfig['modifierKey'],
57+
) => Interaction[];
58+
```

apps/storybook/src/Pan.stories.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { MouseButton, Pan, ResetZoomButton, VisCanvas, Zoom } from '@h5web/lib';
1+
import {
2+
MouseButton,
3+
Pan,
4+
PreventDefaultContextMenu,
5+
ResetZoomButton,
6+
VisCanvas,
7+
Zoom,
8+
} from '@h5web/lib';
29
import { type Meta, type StoryObj } from '@storybook/react';
310

411
import FillHeight from './decorators/FillHeight';
@@ -19,9 +26,13 @@ const meta = {
1926
button: {
2027
control: {
2128
type: 'inline-check',
22-
labels: { [MouseButton.Left]: 'Left', [MouseButton.Middle]: 'Middle' },
29+
labels: {
30+
[MouseButton.Left]: 'Left',
31+
[MouseButton.Middle]: 'Middle',
32+
[MouseButton.Right]: 'Right',
33+
},
2334
},
24-
options: [MouseButton.Left, MouseButton.Middle],
35+
options: [MouseButton.Left, MouseButton.Middle, MouseButton.Right],
2536
},
2637
modifierKey: {
2738
control: { type: 'inline-check' },
@@ -43,6 +54,7 @@ export const Default = {
4354
<Pan {...args} />
4455
<Zoom />
4556
<ResetZoomButton />
57+
<PreventDefaultContextMenu />
4658
</VisCanvas>
4759
);
4860
},
@@ -69,6 +81,13 @@ export const MiddleButton = {
6981
},
7082
} satisfies Story;
7183

84+
export const RightButton = {
85+
...Default,
86+
args: {
87+
button: [MouseButton.Right],
88+
},
89+
} satisfies Story;
90+
7291
export const TwoButtons = {
7392
...Default,
7493
args: {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {
2+
FloatingControl,
3+
MouseButton,
4+
Pan,
5+
PreventDefaultContextMenu,
6+
VisCanvas,
7+
Zoom,
8+
} from '@h5web/lib';
9+
import { useToggle } from '@react-hookz/web';
10+
import { type Meta, type StoryObj } from '@storybook/react';
11+
12+
import FillHeight from './decorators/FillHeight';
13+
14+
const meta = {
15+
title: 'Building Blocks/Interactions/PreventDefaultContextMenu',
16+
component: PreventDefaultContextMenu,
17+
decorators: [FillHeight],
18+
parameters: { layout: 'fullscreen' },
19+
args: {
20+
when: undefined,
21+
},
22+
argTypes: {
23+
when: {
24+
control: { type: 'inline-check' },
25+
options: ['when-needed', 'always', 'never'],
26+
},
27+
},
28+
} satisfies Meta<typeof PreventDefaultContextMenu>;
29+
30+
export default meta;
31+
type Story = StoryObj<typeof meta>;
32+
33+
export const WhenNeeded = {
34+
render: (args) => {
35+
const [withRightBtn, toggleRightBtn] = useToggle(false);
36+
37+
return (
38+
<VisCanvas
39+
title={`Panning with ${withRightBtn ? 'right' : 'left'} mouse button`}
40+
abscissaConfig={{ visDomain: [-10, 0], showGrid: true }}
41+
ordinateConfig={{ visDomain: [50, 100], showGrid: true }}
42+
>
43+
<PreventDefaultContextMenu {...args} />
44+
45+
<Pan button={withRightBtn ? MouseButton.Right : MouseButton.Left} />
46+
<Zoom />
47+
48+
<FloatingControl>
49+
<button type="button" onClick={() => toggleRightBtn()}>
50+
Pan with {withRightBtn ? 'left' : 'right'} button
51+
</button>
52+
</FloatingControl>
53+
</VisCanvas>
54+
);
55+
},
56+
} satisfies Story;
57+
58+
export const Always = {
59+
...WhenNeeded,
60+
args: {
61+
when: 'always',
62+
},
63+
} satisfies Story;
64+
65+
export const Never = {
66+
...WhenNeeded,
67+
args: {
68+
when: 'never',
69+
},
70+
} satisfies Story;

packages/lib/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export { default as SelectToZoom } from './interactions/SelectToZoom';
7272
export { default as AxialSelectToZoom } from './interactions/AxialSelectToZoom';
7373
export { default as SelectionTool } from './interactions/SelectionTool';
7474
export { default as AxialSelectionTool } from './interactions/AxialSelectionTool';
75+
export { default as PreventDefaultContextMenu } from './interactions/PreventDefaultContextMenu';
7576
export type { PanProps } from './interactions/Pan';
7677
export type { ZoomProps } from './interactions/Zoom';
7778
export type { XAxisZoomProps } from './interactions/XAxisZoom';
@@ -81,6 +82,7 @@ export type { SelectToZoomProps } from './interactions/SelectToZoom';
8182
export type { AxialSelectionToolProps } from './interactions/AxialSelectionTool';
8283
export type { AxialSelectToZoomProps } from './interactions/AxialSelectToZoom';
8384
export type { DefaultInteractionsConfig } from './interactions/DefaultInteractions';
85+
export type { PreventDefaultContextMenuProps } from './interactions/PreventDefaultContextMenu';
8486

8587
// SVG
8688
export { default as SvgElement } from './interactions/svg/SvgElement';
@@ -92,9 +94,11 @@ export type { SvgLineProps } from './interactions/svg/SvgLine';
9294
export type { SvgRectProps } from './interactions/svg/SvgRect';
9395
export type { SvgCircleProps } from './interactions/svg/SvgCircle';
9496

95-
// Context
97+
// Contexts
9698
export { useVisCanvasContext } from './vis/shared/VisCanvasProvider';
99+
export { useInteractionsContext } from './interactions/InteractionsProvider';
97100
export type { VisCanvasContextValue } from './vis/shared/VisCanvasProvider';
101+
export type { InteractionsContextValue } from './interactions/InteractionsProvider';
98102

99103
// Utilities
100104
export { toTypedNdArray } from '@h5web/shared/vis-utils';

packages/lib/src/interactions/DefaultInteractions.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Zoom, { type ZoomProps } from '../interactions/Zoom';
88
import AxialSelectToZoom, {
99
type AxialSelectToZoomProps,
1010
} from './AxialSelectToZoom';
11+
import PreventDefaultContextMenu from './PreventDefaultContextMenu';
1112

1213
export interface DefaultInteractionsConfig {
1314
pan?: PanProps | false;
@@ -51,6 +52,8 @@ function DefaultInteractions(props: DefaultInteractionsConfig) {
5152
{...interactions.ySelectToZoom}
5253
/>
5354
)}
55+
56+
<PreventDefaultContextMenu />
5457
</>
5558
);
5659
}

packages/lib/src/interactions/InteractionsProvider.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { castArray } from '@h5web/shared/vis-utils';
12
import {
23
createContext,
34
type ReactNode,
@@ -12,6 +13,10 @@ import { type InteractionConfig } from './models';
1213
export interface InteractionsContextValue {
1314
registerInteraction: (id: string, config: InteractionConfig) => void;
1415
unregisterInteraction: (id: string) => void;
16+
getInteractions: (
17+
button?: InteractionConfig['button'],
18+
modifierKey?: InteractionConfig['modifierKey'],
19+
) => Interaction[];
1520
shouldInteract: (id: string, event: MouseEvent) => boolean;
1621
}
1722

@@ -27,7 +32,7 @@ function InteractionsProvider(props: { children: ReactNode }) {
2732
const [interactionMap] = useState(new Map<string, Interaction>());
2833

2934
const registerInteraction = useCallback(
30-
(id: string, config: InteractionConfig) => {
35+
(id: string, config: InteractionConfig): void => {
3136
if (interactionMap.has(id)) {
3237
console.warn(`An interaction with ID "${id}" is already registered.`); // eslint-disable-line no-console
3338
} else {
@@ -38,14 +43,41 @@ function InteractionsProvider(props: { children: ReactNode }) {
3843
);
3944

4045
const unregisterInteraction = useCallback(
41-
(id: string) => {
46+
(id: string): void => {
4247
interactionMap.delete(id);
4348
},
4449
[interactionMap],
4550
);
4651

52+
const getInteractions = useCallback(
53+
(
54+
button?: InteractionConfig['button'],
55+
modifierKey?: InteractionConfig['modifierKey'],
56+
): Interaction[] => {
57+
const interactions = [...interactionMap.values()].filter(
58+
(inter) => inter.isEnabled,
59+
);
60+
61+
if (button === undefined) {
62+
return interactions;
63+
}
64+
65+
const buttons = button === 'Wheel' ? button : castArray(button);
66+
const modifierKeys = modifierKey ? castArray(modifierKey) : [];
67+
68+
return interactions.filter(
69+
(inter) =>
70+
(buttons === 'Wheel'
71+
? inter.isWheel
72+
: buttons.some((b) => inter.buttons.includes(b))) &&
73+
modifierKeys.every((k) => inter.modifierKeys.includes(k)),
74+
);
75+
},
76+
[interactionMap],
77+
);
78+
4779
const shouldInteract = useCallback(
48-
(interactionId: string, event: MouseEvent | WheelEvent) => {
80+
(interactionId: string, event: MouseEvent | WheelEvent): boolean => {
4981
const registeredInteractions = [...interactionMap.values()];
5082
if (!interactionMap.has(interactionId)) {
5183
throw new Error(`Interaction ${interactionId} is not registered`);
@@ -78,6 +110,7 @@ function InteractionsProvider(props: { children: ReactNode }) {
78110
value={{
79111
registerInteraction,
80112
unregisterInteraction,
113+
getInteractions,
81114
shouldInteract,
82115
}}
83116
>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useEventListener } from '@react-hookz/web';
2+
import { useThree } from '@react-three/fiber';
3+
4+
import { useInteractionsContext } from './InteractionsProvider';
5+
import { MouseButton } from './models';
6+
7+
interface Props {
8+
when?: 'as-needed' | 'always' | 'never';
9+
}
10+
11+
function PreventDefaultContextMenu(props: Props) {
12+
const { when = 'as-needed' } = props;
13+
const { getInteractions } = useInteractionsContext();
14+
15+
const { domElement } = useThree((state) => state.gl);
16+
17+
useEventListener(domElement, 'contextmenu', (evt: PointerEvent) => {
18+
if (
19+
when === 'always' ||
20+
(when === 'as-needed' && getInteractions(MouseButton.Right).length > 0)
21+
) {
22+
evt.preventDefault();
23+
}
24+
});
25+
26+
return null;
27+
}
28+
29+
export { type Props as PreventDefaultContextMenuProps };
30+
export default PreventDefaultContextMenu;

packages/lib/src/interactions/models.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type ModifierKey = 'Alt' | 'Control' | 'Shift';
55
export enum MouseButton {
66
'Left' = 0,
77
'Middle' = 1,
8+
'Right' = 2,
89
}
910

1011
export type Rect = [start: Vector3, end: Vector3];

0 commit comments

Comments
 (0)