From 1015b307e14cc628120f8d8ec91d804c30b08391 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Sun, 4 May 2025 18:58:51 -0400 Subject: [PATCH 1/3] feat(widgets): Add custom ButtonGroupWidget --- modules/widgets/src/button-group-widget.tsx | 84 +++++++++++++ .../widgets/src/context-menu-widget copy.tsx | 115 ++++++++++++++++++ modules/widgets/src/index.ts | 1 + .../lib/components/grouped-icon-button.tsx | 12 +- modules/widgets/src/menu-widget.tsx | 101 +++++++++++++++ 5 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 modules/widgets/src/button-group-widget.tsx create mode 100644 modules/widgets/src/context-menu-widget copy.tsx create mode 100644 modules/widgets/src/menu-widget.tsx diff --git a/modules/widgets/src/button-group-widget.tsx b/modules/widgets/src/button-group-widget.tsx new file mode 100644 index 00000000000..f75456cc3e7 --- /dev/null +++ b/modules/widgets/src/button-group-widget.tsx @@ -0,0 +1,84 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {Widget} from '@deck.gl/core'; +import {Viewport, WidgetProps, WidgetPlacement} from '@deck.gl/core'; +import {render} from 'preact'; +import {ButtonGroup} from './lib/components/button-group'; +import {GroupedIconButton} from './lib/components/grouped-icon-button'; + +/** Defined one button in the menu */ +export type Button = { + id: string; + label: string; + icon?: () => JSX.Element; +} | { + id: string; + label: string; + className: string; +} + +export type ButtonGroupWidgetProps = WidgetProps & { + /** Widget positioning within the view. Default 'top-left'. */ + placement?: WidgetPlacement; + /** View to attach to and interact with. Required when using multiple views. */ + viewId?: string | null; + /** Button orientation. */ + orientation?: 'vertical' | 'horizontal'; + /** List of buttons to show */ + buttons: Button[]; + /** Tooltip message on zoom out button. */ + onButtonClick?: (id: string, widget: ButtonGroupWidget) => void; +}; + +/** + * A widget that lets the user add custom icon buttons to deck + * The buttons participate in widget positioning and theming, + * however the functionality is defined by the props.onButtonClick callback + */ +export class ButtonGroupWidget extends Widget { + static defaultProps: Required = { + ...Widget.defaultProps, + id: 'button-group', + placement: 'top-left', + orientation: 'vertical', + viewId: undefined!, + buttons: [], + // eslint-disable-next-line no-console + onButtonClick: (id, widget) => console.log(`Button ${id} clicked`, widget) + }; + + className = 'deck-widget-zoom'; + placement: WidgetPlacement = 'top-left'; + viewId?: string | null = null; + viewports: {[id: string]: Viewport} = {}; + + constructor(props: ButtonGroupWidgetProps) { + super(props, ButtonGroupWidget.defaultProps); + this.setProps(props); + } + + setProps(props: Partial) { + this.placement = props.placement ?? this.placement; + this.viewId = props.viewId ?? this.viewId; + super.setProps(props); + } + + onRenderHTML(rootElement: HTMLElement): void { + const ui = ( + + {this.props.buttons.map(button => ( + this.props.onButtonClick(button.id, this)} + /> + ))} + + ); + render(ui, rootElement); + } +} diff --git a/modules/widgets/src/context-menu-widget copy.tsx b/modules/widgets/src/context-menu-widget copy.tsx new file mode 100644 index 00000000000..c4a39d48dd7 --- /dev/null +++ b/modules/widgets/src/context-menu-widget copy.tsx @@ -0,0 +1,115 @@ +/* global document */ +import {Widget, WidgetProps} from '@deck.gl/core'; +import type {Deck, PickingInfo} from '@deck.gl/core'; +import {render} from 'preact'; +import {SimpleMenu} from './lib/components/simple-menu'; + +/** The standard, modern way is to use event.button === 2, where button is the standardized property (0 = left, 1 = middle, 2 = right). */ +const MOUSE_BUTTON_RIGHT = 2; +/** 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) */ +const MOUSE_WHICH_RIGHT = 3; + +export type ContextWidgetMenuItem = { + label: string; + key: string; +}; + +export type ContextMenuWidgetProps = WidgetProps & { + /** View to attach to and interact with. Required when using multiple views. */ + viewId?: string | null; + /** Controls visibility of the context menu */ + visible?: boolean; + /** Screen position at which to place the menu */ + position: {x: number; y: number}; + /** Items to render */ + menuItems: ContextWidgetMenuItem[]; + /** Provide menu items for the menu given the picked object */ + getMenuItems: (info: PickingInfo, widget: ContextMenuWidget) => ContextWidgetMenuItem[] | null; + /** Callback with the selected item */ + onMenuItemSelected?: (key: string, pickInfo: PickingInfo | null) => void; +}; + +export class ContextMenuWidget extends Widget { + static defaultProps: Required = { + ...Widget.defaultProps, + viewId: null, + visible: false, + position: {x: 0, y: 0}, + getMenuItems: undefined!, + menuItems: [], + // eslint-disable-next-line no-console + onMenuItemSelected: (key, pickInfo) => console.log('Context menu item selected:', key, pickInfo) + }; + + className = 'deck-widget-context-menu'; + placement = 'fill' as const; + + pickInfo: PickingInfo | null = null; + + constructor(props: ContextMenuWidgetProps) { + super(props, ContextMenuWidget.defaultProps); + this.pickInfo = null; + this.setProps(props); + } + + onAdd({deck}: {deck: Deck}): HTMLDivElement { + const element = document.createElement('div'); + element.classList.add('deck-widget', 'deck-widget-context-menu'); + const style = { + margin: '0px', + top: '0px', + left: '0px', + position: 'absolute', + pointerEvents: 'auto' + }; + Object.entries(style).forEach(([key, value]) => element.style.setProperty(key, value)); + + deck.getCanvas()?.addEventListener('click', () => this.hide()); + deck.getCanvas()?.addEventListener('contextmenu', event => this.handleContextMenu(event)); + return element; + } + + onRenderHTML(rootElement: HTMLElement): void { + const {visible, position, menuItems} = this.props; + + const ui = + visible && menuItems.length ? ( + this.props.onMenuItemSelected(key, this.pickInfo)} + position={position} + style={{pointerEvents: 'auto'}} + /> + ) : null; + render(ui, rootElement); + } + + handleContextMenu(srcEvent: MouseEvent): boolean { + if ( + srcEvent && + (srcEvent.button === MOUSE_BUTTON_RIGHT || srcEvent.which === MOUSE_WHICH_RIGHT) + ) { + this.pickInfo = + this.deck?.pickObject({ + x: srcEvent.clientX, + y: srcEvent.clientY + }) || null; + const menuItems = (this.pickInfo && this.props.getMenuItems?.(this.pickInfo, this)) || []; + const visible = menuItems.length > 0; + this.setProps({ + visible, + position: {x: srcEvent.clientX, y: srcEvent.clientY}, + menuItems + }); + this.updateHTML(); + srcEvent.preventDefault(); + return visible; + } + + return false; + } + + hide(): void { + this.setProps({visible: false}); + } +} diff --git a/modules/widgets/src/index.ts b/modules/widgets/src/index.ts index dd144ea8fd8..3646486aa45 100644 --- a/modules/widgets/src/index.ts +++ b/modules/widgets/src/index.ts @@ -22,6 +22,7 @@ export {ContextMenuWidget as _ContextMenuWidget} from './context-menu-widget'; export {SplitterWidget as _SplitterWidget} from './splitter-widget'; export {TimelineWidget as _TimelineWidget} from './timeline-widget'; export {ViewSelectorWidget as _ViewSelectorWidget} from './view-selector-widget'; +export {ButtonGroupWidget as _ButtonGroupWidget, type ButtonGroupWidgetProps} from './button-group-widget'; export type {FullscreenWidgetProps} from './fullscreen-widget'; export type {CompassWidgetProps} from './compass-widget'; diff --git a/modules/widgets/src/lib/components/grouped-icon-button.tsx b/modules/widgets/src/lib/components/grouped-icon-button.tsx index a358c12e02b..a43b2ca7078 100644 --- a/modules/widgets/src/lib/components/grouped-icon-button.tsx +++ b/modules/widgets/src/lib/components/grouped-icon-button.tsx @@ -3,14 +3,18 @@ // Copyright (c) vis.gl contributors export type GroupedIconButtonProps = { - className?: string; label: string; + /** Icons can be loaded from style sheet using class name */ + className: string; + /** Alterhnatively an SVG icon element can be provided */ + icon?: () => JSX.Element; + /** Action to take when button was clicked */ onClick: () => void; }; /** Renders an icon button as part of a ButtonGroup */ export const GroupedIconButton = props => { - const {className, label, onClick} = props; + const {className, label, icon, onClick} = props; return ( ); }; diff --git a/modules/widgets/src/menu-widget.tsx b/modules/widgets/src/menu-widget.tsx new file mode 100644 index 00000000000..94a03aee802 --- /dev/null +++ b/modules/widgets/src/menu-widget.tsx @@ -0,0 +1,101 @@ +/* global document */ +import {Widget, WidgetProps} from '@deck.gl/core'; +import type {Deck, PickingInfo} from '@deck.gl/core'; +import {render} from 'preact'; +import {SimpleMenu} from './lib/components/simple-menu'; + +/** The standard, modern way is to use event.button === 2, where button is the standardized property (0 = left, 1 = middle, 2 = right). */ +const MOUSE_BUTTON_RIGHT = 2; +/** 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) */ +const MOUSE_WHICH_RIGHT = 3; + +export type MenuItem = { + label: string; + key: string; +}; + +export type MenuWidgetProps = WidgetProps & { + /** View to attach to and interact with. Required when using multiple views. */ + viewId?: string | null; + /** Controls visibility of the context menu */ + visible?: boolean; + /** Items to render */ + menuItems: MenuItem[]; + /** Callback with the selected item */ + onMenuItemSelected?: (key: string, pickInfo: PickingInfo | null) => void; +}; + +export class MenuWidget extends Widget { + static defaultProps: Required = { + ...Widget.defaultProps, + viewId: null, + visible: false, + menuItems: [], + // eslint-disable-next-line no-console + onMenuItemSelected: (key, pickInfo) => console.log('Context menu item selected:', key, pickInfo) + }; + + className = 'deck-widget-menu'; + placement = 'fill' as const; + + pickInfo: PickingInfo | null = null; + + constructor(props: MenuWidgetProps) { + super(props, MenuWidget.defaultProps); + this.pickInfo = null; + this.setProps(props); + } + + onAdd({deck}: {deck: Deck}): HTMLDivElement { + const element = document.createElement('div'); + element.classList.add('deck-widget', 'deck-widget-menu'); + const style = { + margin: '0px', + top: '0px', + left: '0px', + position: 'absolute', + pointerEvents: 'auto' + }; + Object.entries(style).forEach(([key, value]) => element.style.setProperty(key, value)); + + deck.getCanvas()?.addEventListener('click', () => this.hide()); + deck.getCanvas()?.addEventListener('contextmenu', event => this.handleContextMenu(event)); + return element; + } + + onRenderHTML(rootElement: HTMLElement): void { + const {visible, menuItems} = this.props; + + const ui = + visible && menuItems.length ? ( + this.props.onMenuItemSelected(key, this.pickInfo)} + position={position} + style={{pointerEvents: 'auto'}} + /> + ) : null; + render(ui, rootElement); + } + + handleContextMenu(srcEvent: MouseEvent): boolean { + if ( + srcEvent && + (srcEvent.button === MOUSE_BUTTON_RIGHT || srcEvent.which === MOUSE_WHICH_RIGHT) + ) { + const visible = this.props.menuItems.length > 0; + this.setProps({ + visible + }); + this.updateHTML(); + srcEvent.preventDefault(); + return visible; + } + + return false; + } + + hide(): void { + this.setProps({visible: false}); + } +} From a55c77e823c053696cb9025c4421c7755babcfa1 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Sun, 4 May 2025 19:06:03 -0400 Subject: [PATCH 2/3] wip --- .../widgets/src/context-menu-widget copy.tsx | 115 ------------------ modules/widgets/src/menu-widget.tsx | 101 --------------- 2 files changed, 216 deletions(-) delete mode 100644 modules/widgets/src/context-menu-widget copy.tsx delete mode 100644 modules/widgets/src/menu-widget.tsx diff --git a/modules/widgets/src/context-menu-widget copy.tsx b/modules/widgets/src/context-menu-widget copy.tsx deleted file mode 100644 index c4a39d48dd7..00000000000 --- a/modules/widgets/src/context-menu-widget copy.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* global document */ -import {Widget, WidgetProps} from '@deck.gl/core'; -import type {Deck, PickingInfo} from '@deck.gl/core'; -import {render} from 'preact'; -import {SimpleMenu} from './lib/components/simple-menu'; - -/** The standard, modern way is to use event.button === 2, where button is the standardized property (0 = left, 1 = middle, 2 = right). */ -const MOUSE_BUTTON_RIGHT = 2; -/** 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) */ -const MOUSE_WHICH_RIGHT = 3; - -export type ContextWidgetMenuItem = { - label: string; - key: string; -}; - -export type ContextMenuWidgetProps = WidgetProps & { - /** View to attach to and interact with. Required when using multiple views. */ - viewId?: string | null; - /** Controls visibility of the context menu */ - visible?: boolean; - /** Screen position at which to place the menu */ - position: {x: number; y: number}; - /** Items to render */ - menuItems: ContextWidgetMenuItem[]; - /** Provide menu items for the menu given the picked object */ - getMenuItems: (info: PickingInfo, widget: ContextMenuWidget) => ContextWidgetMenuItem[] | null; - /** Callback with the selected item */ - onMenuItemSelected?: (key: string, pickInfo: PickingInfo | null) => void; -}; - -export class ContextMenuWidget extends Widget { - static defaultProps: Required = { - ...Widget.defaultProps, - viewId: null, - visible: false, - position: {x: 0, y: 0}, - getMenuItems: undefined!, - menuItems: [], - // eslint-disable-next-line no-console - onMenuItemSelected: (key, pickInfo) => console.log('Context menu item selected:', key, pickInfo) - }; - - className = 'deck-widget-context-menu'; - placement = 'fill' as const; - - pickInfo: PickingInfo | null = null; - - constructor(props: ContextMenuWidgetProps) { - super(props, ContextMenuWidget.defaultProps); - this.pickInfo = null; - this.setProps(props); - } - - onAdd({deck}: {deck: Deck}): HTMLDivElement { - const element = document.createElement('div'); - element.classList.add('deck-widget', 'deck-widget-context-menu'); - const style = { - margin: '0px', - top: '0px', - left: '0px', - position: 'absolute', - pointerEvents: 'auto' - }; - Object.entries(style).forEach(([key, value]) => element.style.setProperty(key, value)); - - deck.getCanvas()?.addEventListener('click', () => this.hide()); - deck.getCanvas()?.addEventListener('contextmenu', event => this.handleContextMenu(event)); - return element; - } - - onRenderHTML(rootElement: HTMLElement): void { - const {visible, position, menuItems} = this.props; - - const ui = - visible && menuItems.length ? ( - this.props.onMenuItemSelected(key, this.pickInfo)} - position={position} - style={{pointerEvents: 'auto'}} - /> - ) : null; - render(ui, rootElement); - } - - handleContextMenu(srcEvent: MouseEvent): boolean { - if ( - srcEvent && - (srcEvent.button === MOUSE_BUTTON_RIGHT || srcEvent.which === MOUSE_WHICH_RIGHT) - ) { - this.pickInfo = - this.deck?.pickObject({ - x: srcEvent.clientX, - y: srcEvent.clientY - }) || null; - const menuItems = (this.pickInfo && this.props.getMenuItems?.(this.pickInfo, this)) || []; - const visible = menuItems.length > 0; - this.setProps({ - visible, - position: {x: srcEvent.clientX, y: srcEvent.clientY}, - menuItems - }); - this.updateHTML(); - srcEvent.preventDefault(); - return visible; - } - - return false; - } - - hide(): void { - this.setProps({visible: false}); - } -} diff --git a/modules/widgets/src/menu-widget.tsx b/modules/widgets/src/menu-widget.tsx deleted file mode 100644 index 94a03aee802..00000000000 --- a/modules/widgets/src/menu-widget.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/* global document */ -import {Widget, WidgetProps} from '@deck.gl/core'; -import type {Deck, PickingInfo} from '@deck.gl/core'; -import {render} from 'preact'; -import {SimpleMenu} from './lib/components/simple-menu'; - -/** The standard, modern way is to use event.button === 2, where button is the standardized property (0 = left, 1 = middle, 2 = right). */ -const MOUSE_BUTTON_RIGHT = 2; -/** 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) */ -const MOUSE_WHICH_RIGHT = 3; - -export type MenuItem = { - label: string; - key: string; -}; - -export type MenuWidgetProps = WidgetProps & { - /** View to attach to and interact with. Required when using multiple views. */ - viewId?: string | null; - /** Controls visibility of the context menu */ - visible?: boolean; - /** Items to render */ - menuItems: MenuItem[]; - /** Callback with the selected item */ - onMenuItemSelected?: (key: string, pickInfo: PickingInfo | null) => void; -}; - -export class MenuWidget extends Widget { - static defaultProps: Required = { - ...Widget.defaultProps, - viewId: null, - visible: false, - menuItems: [], - // eslint-disable-next-line no-console - onMenuItemSelected: (key, pickInfo) => console.log('Context menu item selected:', key, pickInfo) - }; - - className = 'deck-widget-menu'; - placement = 'fill' as const; - - pickInfo: PickingInfo | null = null; - - constructor(props: MenuWidgetProps) { - super(props, MenuWidget.defaultProps); - this.pickInfo = null; - this.setProps(props); - } - - onAdd({deck}: {deck: Deck}): HTMLDivElement { - const element = document.createElement('div'); - element.classList.add('deck-widget', 'deck-widget-menu'); - const style = { - margin: '0px', - top: '0px', - left: '0px', - position: 'absolute', - pointerEvents: 'auto' - }; - Object.entries(style).forEach(([key, value]) => element.style.setProperty(key, value)); - - deck.getCanvas()?.addEventListener('click', () => this.hide()); - deck.getCanvas()?.addEventListener('contextmenu', event => this.handleContextMenu(event)); - return element; - } - - onRenderHTML(rootElement: HTMLElement): void { - const {visible, menuItems} = this.props; - - const ui = - visible && menuItems.length ? ( - this.props.onMenuItemSelected(key, this.pickInfo)} - position={position} - style={{pointerEvents: 'auto'}} - /> - ) : null; - render(ui, rootElement); - } - - handleContextMenu(srcEvent: MouseEvent): boolean { - if ( - srcEvent && - (srcEvent.button === MOUSE_BUTTON_RIGHT || srcEvent.which === MOUSE_WHICH_RIGHT) - ) { - const visible = this.props.menuItems.length > 0; - this.setProps({ - visible - }); - this.updateHTML(); - srcEvent.preventDefault(); - return visible; - } - - return false; - } - - hide(): void { - this.setProps({visible: false}); - } -} From 4d84f0f33358b4ac5bfbfaa850dedd15f80e6523 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Sun, 4 May 2025 19:09:56 -0400 Subject: [PATCH 3/3] lint --- modules/widgets/src/button-group-widget.tsx | 24 ++++++++++--------- modules/widgets/src/index.ts | 5 +++- .../lib/components/grouped-icon-button.tsx | 4 +--- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/modules/widgets/src/button-group-widget.tsx b/modules/widgets/src/button-group-widget.tsx index f75456cc3e7..3991c5e4cc3 100644 --- a/modules/widgets/src/button-group-widget.tsx +++ b/modules/widgets/src/button-group-widget.tsx @@ -9,15 +9,17 @@ import {ButtonGroup} from './lib/components/button-group'; import {GroupedIconButton} from './lib/components/grouped-icon-button'; /** Defined one button in the menu */ -export type Button = { - id: string; - label: string; - icon?: () => JSX.Element; -} | { - id: string; - label: string; - className: string; -} +export type Button = + | { + id: string; + label: string; + icon?: () => JSX.Element; + } + | { + id: string; + label: string; + className: string; + }; export type ButtonGroupWidgetProps = WidgetProps & { /** Widget positioning within the view. Default 'top-left'. */ @@ -32,8 +34,8 @@ export type ButtonGroupWidgetProps = WidgetProps & { onButtonClick?: (id: string, widget: ButtonGroupWidget) => void; }; -/** - * A widget that lets the user add custom icon buttons to deck +/** + * A widget that lets the user add custom icon buttons to deck * The buttons participate in widget positioning and theming, * however the functionality is defined by the props.onButtonClick callback */ diff --git a/modules/widgets/src/index.ts b/modules/widgets/src/index.ts index 3646486aa45..2dff0faa607 100644 --- a/modules/widgets/src/index.ts +++ b/modules/widgets/src/index.ts @@ -22,7 +22,10 @@ export {ContextMenuWidget as _ContextMenuWidget} from './context-menu-widget'; export {SplitterWidget as _SplitterWidget} from './splitter-widget'; export {TimelineWidget as _TimelineWidget} from './timeline-widget'; export {ViewSelectorWidget as _ViewSelectorWidget} from './view-selector-widget'; -export {ButtonGroupWidget as _ButtonGroupWidget, type ButtonGroupWidgetProps} from './button-group-widget'; +export { + ButtonGroupWidget as _ButtonGroupWidget, + type ButtonGroupWidgetProps +} from './button-group-widget'; export type {FullscreenWidgetProps} from './fullscreen-widget'; export type {CompassWidgetProps} from './compass-widget'; diff --git a/modules/widgets/src/lib/components/grouped-icon-button.tsx b/modules/widgets/src/lib/components/grouped-icon-button.tsx index a43b2ca7078..cce1afd0bd9 100644 --- a/modules/widgets/src/lib/components/grouped-icon-button.tsx +++ b/modules/widgets/src/lib/components/grouped-icon-button.tsx @@ -22,9 +22,7 @@ export const GroupedIconButton = props => { onClick={onClick} title={label} > -
- {icon && icon()} -
+
{icon && icon()}
); };