Skip to content

Commit da2e93e

Browse files
feat(widgets): New ContextMenuWidget (#9616)
Co-authored-by: Chris Gervang <[email protected]>
1 parent 5777f94 commit da2e93e

17 files changed

+297
-87
lines changed

modules/react/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ export {FullscreenWidget} from './widgets/fullscreen-widget';
1111
export {ZoomWidget} from './widgets/zoom-widget';
1212
export {GeolocateWidget as _GeolocateWidget} from './widgets/geolocate-widget';
1313
export {InfoWidget as _InfoWidget} from './widgets/info-widget';
14+
export {ContextMenuWidget as _ContextMenuWidget} from './widgets/context-menu-widget';
1415
export {LoadingWidget as _LoadingWidget} from './widgets/loading-widget';
1516
export {ResetViewWidget as _ResetViewWidget} from './widgets/reset-view-widget';
1617
export {ScaleWidget as _ScaleWidget} from './widgets/scale-widget';
1718
export {ScreenshotWidget as _ScreenshotWidget} from './widgets/screenshot-widget';
1819
export {SplitterWidget as _SplitterWidget} from './widgets/splitter-widget';
1920
export {ThemeWidget as _ThemeWidget} from './widgets/theme-widget';
2021
export {useWidget} from './utils/use-widget';
22+
export type {ContextMenuWidgetProps} from '@deck.gl/widgets';
2123

2224
// Types
2325
export type {DeckGLContextValue} from './utils/deckgl-context';
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {_ContextMenuWidget} from '@deck.gl/widgets';
2+
import type {ContextMenuWidgetProps} from '@deck.gl/widgets';
3+
import {useWidget} from '../utils/use-widget';
4+
5+
/**
6+
* React wrapper for the ContextMenuWidget.
7+
*/
8+
export const ContextMenuWidget = (props: ContextMenuWidgetProps) => {
9+
useWidget(_ContextMenuWidget, props);
10+
return null;
11+
};
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/* global document */
2+
import {Widget, WidgetProps} from '@deck.gl/core';
3+
import type {Deck, PickingInfo} from '@deck.gl/core';
4+
import {render} from 'preact';
5+
import {SimpleMenu} from './lib/components/simple-menu';
6+
7+
/** The standard, modern way is to use event.button === 2, where button is the standardized property (0 = left, 1 = middle, 2 = right). */
8+
const MOUSE_BUTTON_RIGHT = 2;
9+
/** A name for the legacy MouseEvent.which value that corresponds to the right-mouse button. In older browsers, the check is: if (event.which === 3) */
10+
const MOUSE_WHICH_RIGHT = 3;
11+
12+
export type ContextWidgetMenuItem = {
13+
label: string;
14+
key: string;
15+
};
16+
17+
export type ContextMenuWidgetProps = WidgetProps & {
18+
/** View to attach to and interact with. Required when using multiple views. */
19+
viewId?: string | null;
20+
/** Controls visibility of the context menu */
21+
visible?: boolean;
22+
/** Screen position at which to place the menu */
23+
position: {x: number; y: number};
24+
/** Items to render */
25+
menuItems: ContextWidgetMenuItem[];
26+
/** Provide menu items for the menu given the picked object */
27+
getMenuItems: (info: PickingInfo, widget: ContextMenuWidget) => ContextWidgetMenuItem[] | null;
28+
/** Callback with the selected item */
29+
onMenuItemSelected?: (key: string, pickInfo: PickingInfo | null) => void;
30+
};
31+
32+
export class ContextMenuWidget extends Widget<ContextMenuWidgetProps> {
33+
static defaultProps: Required<ContextMenuWidgetProps> = {
34+
...Widget.defaultProps,
35+
viewId: null,
36+
visible: false,
37+
position: {x: 0, y: 0},
38+
getMenuItems: undefined!,
39+
menuItems: [],
40+
// eslint-disable-next-line no-console
41+
onMenuItemSelected: (key, pickInfo) => console.log('Context menu item selected:', key, pickInfo)
42+
};
43+
44+
className = 'deck-widget-context-menu';
45+
placement = 'fill' as const;
46+
47+
pickInfo: PickingInfo | null = null;
48+
49+
constructor(props: ContextMenuWidgetProps) {
50+
super(props, ContextMenuWidget.defaultProps);
51+
this.pickInfo = null;
52+
this.setProps(props);
53+
}
54+
55+
onAdd({deck}: {deck: Deck<any>}): HTMLDivElement {
56+
const element = document.createElement('div');
57+
element.classList.add('deck-widget', 'deck-widget-context-menu');
58+
const style = {
59+
margin: '0px',
60+
top: '0px',
61+
left: '0px',
62+
position: 'absolute',
63+
pointerEvents: 'auto'
64+
};
65+
Object.entries(style).forEach(([key, value]) => element.style.setProperty(key, value));
66+
67+
deck.getCanvas()?.addEventListener('click', () => this.hide());
68+
deck.getCanvas()?.addEventListener('contextmenu', event => this.handleContextMenu(event));
69+
return element;
70+
}
71+
72+
onRenderHTML(rootElement: HTMLElement): void {
73+
const {visible, position, menuItems} = this.props;
74+
75+
const ui =
76+
visible && menuItems.length ? (
77+
<SimpleMenu
78+
menuItems={menuItems}
79+
onItemSelected={key => this.props.onMenuItemSelected(key, this.pickInfo)}
80+
position={position}
81+
style={{pointerEvents: 'auto'}}
82+
/>
83+
) : null;
84+
render(ui, rootElement);
85+
}
86+
87+
handleContextMenu(srcEvent: MouseEvent): boolean {
88+
if (
89+
srcEvent &&
90+
(srcEvent.button === MOUSE_BUTTON_RIGHT || srcEvent.which === MOUSE_WHICH_RIGHT)
91+
) {
92+
this.pickInfo =
93+
this.deck?.pickObject({
94+
x: srcEvent.clientX,
95+
y: srcEvent.clientY
96+
}) || null;
97+
const menuItems = (this.pickInfo && this.props.getMenuItems?.(this.pickInfo, this)) || [];
98+
const visible = menuItems.length > 0;
99+
this.setProps({
100+
visible,
101+
position: {x: srcEvent.clientX, y: srcEvent.clientY},
102+
menuItems
103+
});
104+
this.updateHTML();
105+
srcEvent.preventDefault();
106+
return visible;
107+
}
108+
109+
return false;
110+
}
111+
112+
hide(): void {
113+
this.setProps({visible: false});
114+
}
115+
}

modules/widgets/src/fullscreen-widget.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
/* global document */
66
import {Widget, type WidgetProps, type WidgetPlacement} from '@deck.gl/core';
77
import {render} from 'preact';
8-
import {IconButton} from './lib/components';
8+
import {IconButton} from './lib/components/icon-button';
99

1010
/* eslint-enable max-len */
1111

modules/widgets/src/index.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export {LoadingWidget as _LoadingWidget} from './loading-widget';
1818
export {FpsWidget as _FpsWidget} from './fps-widget';
1919
export {ThemeWidget as _ThemeWidget} from './theme-widget';
2020
export {InfoWidget as _InfoWidget} from './info-widget';
21+
export {ContextMenuWidget as _ContextMenuWidget} from './context-menu-widget';
2122
export {SplitterWidget as _SplitterWidget} from './splitter-widget';
2223
export {TimelineWidget as _TimelineWidget} from './timeline-widget';
2324
export {ViewSelectorWidget as _ViewSelectorWidget} from './view-selector-widget';
@@ -33,16 +34,20 @@ export type {FpsWidgetProps} from './fps-widget';
3334
export type {ScaleWidgetProps} from './scale-widget';
3435
export type {ThemeWidgetProps} from './theme-widget';
3536
export type {InfoWidgetProps} from './info-widget';
37+
export type {ContextMenuWidgetProps} from './context-menu-widget';
3638
export type {SplitterWidgetProps} from './splitter-widget';
3739
export type {TimelineWidgetProps} from './timeline-widget';
3840
export type {ViewSelectorWidgetProps} from './view-selector-widget';
3941

40-
export {IconButton, ButtonGroup, GroupedIconButton} from './lib/components';
41-
4242
export {LightTheme, DarkTheme, LightGlassTheme, DarkGlassTheme} from './themes';
4343
export type {DeckWidgetTheme} from './themes';
4444

4545
// Experimental exports
46-
47-
import * as _components from './lib/components';
48-
export {_components};
46+
export {ButtonGroup as _ButtonGroup, type ButtonGroupProps} from './lib/components/button-group';
47+
export {IconButton as _IconButton, type IconButtonProps} from './lib/components/icon-button';
48+
export {
49+
GroupedIconButton as _GroupedIconButton,
50+
type GroupedIconButtonProps
51+
} from './lib/components/grouped-icon-button';
52+
export {SimpleMenu as _SimpleMenu, type SimpleMenuProps} from './lib/components/simple-menu';
53+
export {IconMenu as _IconMenu, type IconMenuProps} from './lib/components/icon-menu';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// deck.gl
2+
// SPDX-License-Identifier: MIT
3+
// Copyright (c) vis.gl contributors
4+
5+
import type {ComponentChildren} from 'preact';
6+
7+
export type ButtonGroupProps = {
8+
children: ComponentChildren;
9+
orientation;
10+
};
11+
12+
/** Renders a group of buttons with Widget CSS */
13+
export const ButtonGroup = (props: ButtonGroupProps) => {
14+
const {children, orientation} = props;
15+
return <div className={`deck-widget-button-group ${orientation}`}>{children}</div>;
16+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// deck.gl
2+
// SPDX-License-Identifier: MIT
3+
// Copyright (c) vis.gl contributors
4+
5+
export type GroupedIconButtonProps = {
6+
className?: string;
7+
label: string;
8+
onClick: () => void;
9+
};
10+
11+
/** Renders an icon button as part of a ButtonGroup */
12+
export const GroupedIconButton = props => {
13+
const {className, label, onClick} = props;
14+
return (
15+
<button
16+
className={`deck-widget-icon-button ${className || ''}`}
17+
type="button"
18+
onClick={onClick}
19+
title={label}
20+
>
21+
<div className="deck-widget-icon" />
22+
</button>
23+
);
24+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// deck.gl
2+
// SPDX-License-Identifier: MIT
3+
// Copyright (c) vis.gl contributors
4+
5+
import type {ComponentChildren} from 'preact';
6+
7+
export type IconButtonProps = {
8+
className: string;
9+
label: string;
10+
onClick: (event?) => unknown;
11+
/** Optional icon or element to render inside the button */
12+
children?: ComponentChildren;
13+
};
14+
15+
/** Renders a button component with widget CSS */
16+
export const IconButton = (props: IconButtonProps) => {
17+
const {className, label, onClick, children} = props;
18+
return (
19+
<div className="deck-widget-button">
20+
<button
21+
className={`deck-widget-icon-button ${className}`}
22+
type="button"
23+
onClick={onClick}
24+
title={label}
25+
>
26+
{children ? children : <div className="deck-widget-icon" />}
27+
</button>
28+
</div>
29+
);
30+
};

modules/widgets/src/lib/components.tsx renamed to modules/widgets/src/lib/components/icon-menu.tsx

Lines changed: 7 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,6 @@
1-
// deck.gl
2-
// SPDX-License-Identifier: MIT
3-
// Copyright (c) vis.gl contributors
4-
5-
import type {ComponentChildren, JSX} from 'preact';
1+
import type {JSX} from 'preact';
62
import {useState, useRef, useEffect} from 'preact/hooks';
7-
8-
export type IconButtonProps = {
9-
className: string;
10-
label: string;
11-
onClick: (event?) => unknown;
12-
/** Optional icon or element to render inside the button */
13-
children?: ComponentChildren;
14-
};
15-
16-
/** Renders a button component with widget CSS */
17-
export const IconButton = (props: IconButtonProps) => {
18-
const {className, label, onClick, children} = props;
19-
return (
20-
<div className="deck-widget-button">
21-
<button
22-
className={`deck-widget-icon-button ${className}`}
23-
type="button"
24-
onClick={onClick}
25-
title={label}
26-
>
27-
{children ? children : <div className="deck-widget-icon" />}
28-
</button>
29-
</div>
30-
);
31-
};
32-
33-
export type ButtonGroupProps = {
34-
children;
35-
orientation;
36-
};
37-
38-
/** Renders a group of buttons with Widget CSS */
39-
export const ButtonGroup = (props: ButtonGroupProps) => {
40-
const {children, orientation} = props;
41-
return <div className={`deck-widget-button-group ${orientation}`}>{children}</div>;
42-
};
43-
44-
export type GroupedIconButtonProps = {
45-
className;
46-
label;
47-
onClick;
48-
};
49-
50-
/** Renders an icon button as part of a ButtonGroup */
51-
export const GroupedIconButton = props => {
52-
const {className, label, onClick} = props;
53-
return (
54-
<button
55-
className={`deck-widget-icon-button ${className}`}
56-
type="button"
57-
onClick={onClick}
58-
title={label}
59-
>
60-
<div className="deck-widget-icon" />
61-
</button>
62-
);
63-
};
3+
import {IconButton} from './icon-button';
644

655
const MENU_STYLE: JSX.CSSProperties = {
666
position: 'absolute',
@@ -81,17 +21,17 @@ const MENU_ITEM_STYLE: JSX.CSSProperties = {
8121
pointerEvents: 'auto'
8222
};
8323

84-
export type MenuProps<KeyType = string> = {
24+
export type IconMenuProps<KeyType = string> = {
8525
className: string;
8626
icon?: () => JSX.Element;
8727
label?: string;
8828
menuItems: {value: KeyType; icon: () => JSX.Element; label: string}[];
8929
initialItem: KeyType;
90-
onSelect: (item: KeyType) => void;
30+
onItemSelected: (item: KeyType) => void;
9131
};
9232

93-
/** A component that renders the popup menu for view mode selection. */
94-
export function Menu<KeyType extends string>(props: MenuProps<KeyType>) {
33+
/** A component that renders an icon popup menu */
34+
export function IconMenu<KeyType extends string>(props: IconMenuProps<KeyType>) {
9535
const [menuOpen, setMenuOpen] = useState(false);
9636
const containerRef = useRef<HTMLDivElement>(null);
9737

@@ -114,7 +54,7 @@ export function Menu<KeyType extends string>(props: MenuProps<KeyType>) {
11454
const handleSelectItem = (item: KeyType) => {
11555
setSelectedItem(item);
11656
setMenuOpen(false);
117-
props.onSelect(item);
57+
props.onItemSelected(item);
11858
};
11959

12060
const handleButtonClick = () => setMenuOpen(!menuOpen);

0 commit comments

Comments
 (0)