From 3a5a2706543fa0d658081f0aa49454fbc509b589 Mon Sep 17 00:00:00 2001 From: Nils Petter Fremming <35219649+nilscognite@users.noreply.github.com> Date: Thu, 3 Oct 2024 19:09:34 +0200 Subject: [PATCH] feat(react-components): Add segmented buttons to the architecture (#4781) * Add segmented button * Set right * Fixing toolbars * Fixes according to review --- .../base/commands/BaseOptionCommand.ts | 15 +++ .../SetFlexibleControlsTypeCommand.ts | 10 +- .../SetOrbitOrFirstPersonModeCommand.ts | 43 ++++++++ .../base/renderTarget/BaseRevealConfig.ts | 10 +- .../concrete/config/StoryBookConfig.ts | 21 ++-- .../components/Architecture/CommandButton.tsx | 2 +- .../Architecture/CommandButtons.tsx | 14 ++- .../{OptionButton.tsx => DropdownButton.tsx} | 8 +- .../components/Architecture/FilterButton.tsx | 2 +- .../components/Architecture/RevealButtons.tsx | 8 +- .../Architecture/SegmentedButtons.tsx | 101 ++++++++++++++++++ .../Architecture/SettingsButton.tsx | 10 +- .../src/components/Architecture/Toolbar.tsx | 18 +++- .../stories/Architecture.stories.tsx | 3 +- 14 files changed, 233 insertions(+), 32 deletions(-) create mode 100644 react-components/src/architecture/base/concreteCommands/SetOrbitOrFirstPersonModeCommand.ts rename react-components/src/components/Architecture/{OptionButton.tsx => DropdownButton.tsx} (96%) create mode 100644 react-components/src/components/Architecture/SegmentedButtons.tsx diff --git a/react-components/src/architecture/base/commands/BaseOptionCommand.ts b/react-components/src/architecture/base/commands/BaseOptionCommand.ts index f5877abc51..6ed50da15d 100644 --- a/react-components/src/architecture/base/commands/BaseOptionCommand.ts +++ b/react-components/src/architecture/base/commands/BaseOptionCommand.ts @@ -5,6 +5,11 @@ import { type BaseCommand } from './BaseCommand'; import { RenderTargetCommand } from './RenderTargetCommand'; +export enum OptionType { + Dropdown, + Segmented +} + /** * Base class for all option like commands. Override createOptions to add options * or use add method to add them in. @@ -15,6 +20,7 @@ export abstract class BaseOptionCommand extends RenderTargetCommand { // INSTANCE FIELDS/PROPERTIES // ================================================== + public readonly optionType: OptionType; private _children: BaseCommand[] | undefined = undefined; public get children(): BaseCommand[] | undefined { @@ -28,6 +34,15 @@ export abstract class BaseOptionCommand extends RenderTargetCommand { return this._children !== undefined && this._children.length > 0; } + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor(optionType: OptionType = OptionType.Dropdown) { + super(); + this.optionType = optionType; + } + // ================================================== // OVERRIDES // ================================================== diff --git a/react-components/src/architecture/base/concreteCommands/SetFlexibleControlsTypeCommand.ts b/react-components/src/architecture/base/concreteCommands/SetFlexibleControlsTypeCommand.ts index 74b7d11af6..63ba096b6b 100644 --- a/react-components/src/architecture/base/concreteCommands/SetFlexibleControlsTypeCommand.ts +++ b/react-components/src/architecture/base/concreteCommands/SetFlexibleControlsTypeCommand.ts @@ -10,14 +10,16 @@ import { type TranslateKey } from '../utilities/TranslateKey'; export class SetFlexibleControlsTypeCommand extends RenderTargetCommand { private readonly _controlsType: FlexibleControlsType; + private readonly _standAlone: boolean; // False if part of a group // ================================================== // CONSTRUCTOR // ================================================== - public constructor(controlsType: FlexibleControlsType) { + public constructor(controlsType: FlexibleControlsType, standAlone: boolean = true) { super(); this._controlsType = controlsType; + this._standAlone = standAlone; } // ================================================== @@ -37,6 +39,9 @@ export class SetFlexibleControlsTypeCommand extends RenderTargetCommand { public override dispose(): void { super.dispose(); + if (!this._standAlone) { + return; // Done by parent + } const { flexibleCameraManager } = this.renderTarget; flexibleCameraManager.removeControlsTypeChangeListener(this._controlsTypeChangeHandler); } @@ -85,6 +90,9 @@ export class SetFlexibleControlsTypeCommand extends RenderTargetCommand { public override attach(renderTarget: RevealRenderTarget): void { super.attach(renderTarget); + if (!this._standAlone) { + return; // Done by parent + } const { flexibleCameraManager } = renderTarget; flexibleCameraManager.addControlsTypeChangeListener(this._controlsTypeChangeHandler); } diff --git a/react-components/src/architecture/base/concreteCommands/SetOrbitOrFirstPersonModeCommand.ts b/react-components/src/architecture/base/concreteCommands/SetOrbitOrFirstPersonModeCommand.ts new file mode 100644 index 0000000000..2975e478e2 --- /dev/null +++ b/react-components/src/architecture/base/concreteCommands/SetOrbitOrFirstPersonModeCommand.ts @@ -0,0 +1,43 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type RevealRenderTarget } from '../renderTarget/RevealRenderTarget'; +import { FlexibleControlsType } from '@cognite/reveal'; +import { type TranslateKey } from '../utilities/TranslateKey'; +import { BaseOptionCommand, OptionType } from '../commands/BaseOptionCommand'; +import { SetFlexibleControlsTypeCommand } from './SetFlexibleControlsTypeCommand'; + +export class SetOrbitOrFirstPersonModeCommand extends BaseOptionCommand { + // ================================================== + // CONSTRUCTOR + // ================================================== + + public constructor() { + super(OptionType.Segmented); + this.add(new SetFlexibleControlsTypeCommand(FlexibleControlsType.Orbit, false)); + this.add(new SetFlexibleControlsTypeCommand(FlexibleControlsType.FirstPerson, false)); + } + + // ================================================== + // OVERRIDES + // ================================================== + + public override get tooltip(): TranslateKey { + return { key: 'CONTROLS_TYPE_TOOLTIP', fallback: 'Set Camera to Orbit or Fly mode' }; + } + + public override attach(renderTarget: RevealRenderTarget): void { + super.attach(renderTarget); + const { flexibleCameraManager } = renderTarget; + flexibleCameraManager.addControlsTypeChangeListener(this._controlsTypeChangeHandler); + } + + // ================================================== + // INSTANCE METHODS + // ================================================== + + private readonly _controlsTypeChangeHandler = (_newControlsType: FlexibleControlsType): void => { + this.update(); + }; +} diff --git a/react-components/src/architecture/base/renderTarget/BaseRevealConfig.ts b/react-components/src/architecture/base/renderTarget/BaseRevealConfig.ts index 4868f3b993..a5414e8118 100644 --- a/react-components/src/architecture/base/renderTarget/BaseRevealConfig.ts +++ b/react-components/src/architecture/base/renderTarget/BaseRevealConfig.ts @@ -14,12 +14,20 @@ export abstract class BaseRevealConfig { // VIRTUAL METHODS: Override these to config the viewer // ================================================== + public createTopToolbar(): Array { + return []; + } + + public createTopToolbarStyle(): PopupStyle { + return new PopupStyle({ left: 0, top: 0, horizontal: true }); + } + public createMainToolbar(): Array { return []; } public createMainToolbarStyle(): PopupStyle { - return new PopupStyle({ right: 0, top: 0, horizontal: false }); + return new PopupStyle({ left: 0, top: 48, horizontal: false }); } public createAxisGizmoTool(): AxisGizmoTool | undefined { diff --git a/react-components/src/architecture/concrete/config/StoryBookConfig.ts b/react-components/src/architecture/concrete/config/StoryBookConfig.ts index d0c9282e76..4c70c999fe 100644 --- a/react-components/src/architecture/concrete/config/StoryBookConfig.ts +++ b/react-components/src/architecture/concrete/config/StoryBookConfig.ts @@ -2,10 +2,7 @@ * Copyright 2024 Cognite AS */ -import { FlexibleControlsType } from '@cognite/reveal'; import { type BaseCommand } from '../../base/commands/BaseCommand'; -import { PopupStyle } from '../../base/domainObjectsHelpers/PopupStyle'; -import { SetFlexibleControlsTypeCommand } from '../../base/concreteCommands/SetFlexibleControlsTypeCommand'; import { SetTerrainVisibleCommand } from '../terrain/SetTerrainVisibleCommand'; import { UpdateTerrainCommand } from '../terrain/UpdateTerrainCommand'; import { FitViewCommand } from '../../base/concreteCommands/FitViewCommand'; @@ -25,6 +22,7 @@ import { SettingsCommand } from '../../base/concreteCommands/SettingsCommand'; import { MockSettingsCommand } from '../../base/commands/mocks/MockSettingsCommand'; import { MockFilterCommand } from '../../base/commands/mocks/MockFilterCommand'; import { ToggleAllModelsVisibleCommand } from '../../base/concreteCommands/ToggleAllModelsVisibleCommand'; +import { SetOrbitOrFirstPersonModeCommand } from '../../base/concreteCommands/SetOrbitOrFirstPersonModeCommand'; export class StoryBookConfig extends BaseRevealConfig { // ================================================== @@ -35,16 +33,19 @@ export class StoryBookConfig extends BaseRevealConfig { return new NavigationTool(); } - public override createMainToolbar(): Array { + public override createTopToolbar(): Array { return [ - new SetFlexibleControlsTypeCommand(FlexibleControlsType.Orbit), - new SetFlexibleControlsTypeCommand(FlexibleControlsType.FirstPerson), - undefined, + new SetOrbitOrFirstPersonModeCommand(), new FitViewCommand(), new SetAxisVisibleCommand(), + new KeyboardSpeedCommand() + ]; + } + + public override createMainToolbar(): Array { + return [ new ToggleAllModelsVisibleCommand(), new ToggleMetricUnitsCommand(), - new KeyboardSpeedCommand(), new SettingsCommand(), new MockSettingsCommand(), new MockFilterCommand(), @@ -60,10 +61,6 @@ export class StoryBookConfig extends BaseRevealConfig { ]; } - public override createMainToolbarStyle(): PopupStyle { - return new PopupStyle({ right: 0, top: 0, horizontal: false }); - } - public override createAxisGizmoTool(): AxisGizmoTool | undefined { return new AxisGizmoTool(); } diff --git a/react-components/src/components/Architecture/CommandButton.tsx b/react-components/src/components/Architecture/CommandButton.tsx index 6bddbc8ccc..6088cec8f4 100644 --- a/react-components/src/components/Architecture/CommandButton.tsx +++ b/react-components/src/components/Architecture/CommandButton.tsx @@ -62,7 +62,7 @@ export const CommandButton = ({ disabled={!isEnabled} toggled={isChecked} aria-label={label} - iconPlacement="right" + iconPlacement="left" onClick={() => { command.invoke(); renderTarget.domElement.focus(); diff --git a/react-components/src/components/Architecture/CommandButtons.tsx b/react-components/src/components/Architecture/CommandButtons.tsx index a813e4cd76..1d909b3bde 100644 --- a/react-components/src/components/Architecture/CommandButtons.tsx +++ b/react-components/src/components/Architecture/CommandButtons.tsx @@ -5,13 +5,14 @@ import { useMemo, type ReactElement } from 'react'; import { Divider } from '@cognite/cogs.js'; import { type BaseCommand } from '../../architecture/base/commands/BaseCommand'; -import { OptionButton } from './OptionButton'; -import { BaseOptionCommand } from '../../architecture/base/commands/BaseOptionCommand'; +import { DropdownButton } from './DropdownButton'; +import { BaseOptionCommand, OptionType } from '../../architecture/base/commands/BaseOptionCommand'; import { CommandButton } from './CommandButton'; import { SettingsButton } from './SettingsButton'; import { BaseSettingsCommand } from '../../architecture/base/commands/BaseSettingsCommand'; import { BaseFilterCommand } from '../../architecture/base/commands/BaseFilterCommand'; import { FilterButton } from './FilterButton'; +import { SegmentedButtons } from './SegmentedButtons'; export function createButton(command: BaseCommand, isHorizontal = false): ReactElement { if (command instanceof BaseFilterCommand) { @@ -21,7 +22,14 @@ export function createButton(command: BaseCommand, isHorizontal = false): ReactE return ; } if (command instanceof BaseOptionCommand) { - return ; + switch (command.optionType) { + case OptionType.Dropdown: + return ; + case OptionType.Segmented: + return ; + default: + return <>; + } } return ; } diff --git a/react-components/src/components/Architecture/OptionButton.tsx b/react-components/src/components/Architecture/DropdownButton.tsx similarity index 96% rename from react-components/src/components/Architecture/OptionButton.tsx rename to react-components/src/components/Architecture/DropdownButton.tsx index 857754020a..18f71bc496 100644 --- a/react-components/src/components/Architecture/OptionButton.tsx +++ b/react-components/src/components/Architecture/DropdownButton.tsx @@ -28,7 +28,7 @@ import { type TranslateDelegate } from '../../architecture/base/utilities/Transl import { useClickOutside } from './useClickOutside'; import { DEFAULT_PADDING, OPTION_MIN_WIDTH } from './constants'; -export const OptionButton = ({ +export const DropdownButton = ({ inputCommand, isHorizontal = false, usedInSettings = false @@ -84,7 +84,7 @@ export const OptionButton = ({ } const placement = getTooltipPlacement(isHorizontal); const label = usedInSettings ? undefined : command.getLabel(t); - const flexDirection = getFlexDirection(isHorizontal); + const flexDirection = getFlexDirection(false); // Always vertical const children = command.children; const selectedLabel = command.selectedChild?.getLabel(t); @@ -123,7 +123,7 @@ export const OptionButton = ({ key={uniqueId} disabled={!isEnabled} toggled={isOpen} - iconPlacement="right" + iconPlacement="left" aria-label={command.getLabel(t)} onClick={(event: MouseEvent) => { event.stopPropagation(); @@ -148,7 +148,7 @@ export function createMenuItem( icon={getIcon(command)} disabled={!command.isEnabled} toggled={command.isChecked} - iconPlacement="right" + iconPlacement="left" onClick={() => { command.invoke(); postAction(); diff --git a/react-components/src/components/Architecture/FilterButton.tsx b/react-components/src/components/Architecture/FilterButton.tsx index ac1b7f8b7a..8019eba3a2 100644 --- a/react-components/src/components/Architecture/FilterButton.tsx +++ b/react-components/src/components/Architecture/FilterButton.tsx @@ -141,7 +141,7 @@ export const FilterButton = ({ key={uniqueId} disabled={!isEnabled} toggled={isOpen} - iconPlacement="right" + iconPlacement="left" aria-label={command.getLabel(t)} style={{ minWidth: usedInSettings ? OPTION_MIN_WIDTH : undefined, diff --git a/react-components/src/components/Architecture/RevealButtons.tsx b/react-components/src/components/Architecture/RevealButtons.tsx index ae30c69905..fe6a3f4527 100644 --- a/react-components/src/components/Architecture/RevealButtons.tsx +++ b/react-components/src/components/Architecture/RevealButtons.tsx @@ -15,6 +15,7 @@ import { ObservationsTool } from '../../architecture/concrete/observations/Obser import { createButtonFromCommandConstructor } from './CommandButtons'; import { SettingsCommand } from '../../architecture/base/concreteCommands/SettingsCommand'; import { PointCloudFilterCommand } from '../../architecture'; +import { SetOrbitOrFirstPersonModeCommand } from '../../architecture/base/concreteCommands/SetOrbitOrFirstPersonModeCommand'; export class RevealButtons { static Settings = (): ReactElement => @@ -37,12 +38,15 @@ export class RevealButtons { static Clip = (): ReactElement => createButtonFromCommandConstructor(() => new ClipTool()); - static SetFlexibleControlsTypeOrbit = (): ReactElement => + static SetOrbitOrFirstPersonMode = (): ReactElement => + createButtonFromCommandConstructor(() => new SetOrbitOrFirstPersonModeCommand()); + + static SetOrbitMode = (): ReactElement => createButtonFromCommandConstructor( () => new SetFlexibleControlsTypeCommand(FlexibleControlsType.Orbit) ); - static SetFlexibleControlsTypeFirstPerson = (): ReactElement => + static SetFirstPersonMode = (): ReactElement => createButtonFromCommandConstructor( () => new SetFlexibleControlsTypeCommand(FlexibleControlsType.FirstPerson) ); diff --git a/react-components/src/components/Architecture/SegmentedButtons.tsx b/react-components/src/components/Architecture/SegmentedButtons.tsx new file mode 100644 index 0000000000..1e1485b7cd --- /dev/null +++ b/react-components/src/components/Architecture/SegmentedButtons.tsx @@ -0,0 +1,101 @@ +/*! + * Copyright 2024 Cognite AS + */ + +import { type ReactElement, useState, useEffect, useMemo, useCallback } from 'react'; +import { useRenderTarget } from '../RevealCanvas/ViewerContext'; +import { SegmentedControl, Tooltip as CogsTooltip } from '@cognite/cogs.js'; +import { useTranslation } from '../i18n/I18n'; +import { type BaseCommand } from '../../architecture/base/commands/BaseCommand'; +import { getDefaultCommand, getIcon, getTooltipPlacement } from './utilities'; +import { BaseOptionCommand } from '../../architecture/base/commands/BaseOptionCommand'; +import { LabelWithShortcut } from './LabelWithShortcut'; + +export const SegmentedButtons = ({ + inputCommand, + isHorizontal = false +}: { + inputCommand: BaseOptionCommand; + isHorizontal: boolean; +}): ReactElement => { + const renderTarget = useRenderTarget(); + const { t } = useTranslation(); + const command = useMemo( + () => getDefaultCommand(inputCommand, renderTarget), + [] + ); + + const [isEnabled, setEnabled] = useState(true); + const [isVisible, setVisible] = useState(true); + const [uniqueId, setUniqueId] = useState(0); + const [selected, setSelected] = useState(getSelectedKey(command)); + + const update = useCallback((command: BaseCommand) => { + setEnabled(command.isEnabled); + setVisible(command.isVisible); + setUniqueId(command.uniqueId); + if (command instanceof BaseOptionCommand) { + setSelected(getSelectedKey(command)); + } + }, []); + + useEffect(() => { + update(command); + command.addEventListener(update); + return () => { + command.removeEventListener(update); + }; + }, [command]); + + if (!isVisible || command.children === undefined) { + return <>; + } + const placement = getTooltipPlacement(isHorizontal); + const label = command.getLabel(t); + + return ( + } + disabled={label === undefined} + appendTo={document.body} + placement={placement}> + { + if (command.children === undefined) { + return; + } + for (const child of command.children) { + if (getKey(child) === selectedKey) { + child.invoke(); + renderTarget.domElement.focus(); + break; + } + } + }}> + {command.children.map((child) => ( + + {child.getLabel(t)} + + ))} + + + ); +}; + +// Note: It should use number, but it didn't work. + +function getSelectedKey(command: BaseOptionCommand): string { + const child = command.selectedChild; + if (child === undefined) { + return 'undefined'; + } + return getKey(child); +} + +function getKey(command: BaseCommand): string { + return command.uniqueId.toString(); +} diff --git a/react-components/src/components/Architecture/SettingsButton.tsx b/react-components/src/components/Architecture/SettingsButton.tsx index a0d7316f40..0d0ed7f21a 100644 --- a/react-components/src/components/Architecture/SettingsButton.tsx +++ b/react-components/src/components/Architecture/SettingsButton.tsx @@ -34,7 +34,7 @@ import { type TranslateDelegate } from '../../architecture/base/utilities/Transl import styled from 'styled-components'; import { type BaseSettingsCommand } from '../../architecture/base/commands/BaseSettingsCommand'; import { BaseOptionCommand } from '../../architecture/base/commands/BaseOptionCommand'; -import { OptionButton } from './OptionButton'; +import { DropdownButton } from './DropdownButton'; import { BaseSliderCommand } from '../../architecture/base/commands/BaseSliderCommand'; import { BaseFilterCommand } from '../../architecture/base/commands/BaseFilterCommand'; import { FilterButton } from './FilterButton'; @@ -131,7 +131,7 @@ export const SettingsButton = ({ disabled={!isEnabled} toggled={isOpen} aria-label={label} - iconPlacement="right" + iconPlacement="left" onClick={(event: MouseEvent) => { event.stopPropagation(); event.preventDefault(); @@ -148,7 +148,7 @@ function createMenuItem(command: BaseCommand, t: TranslateDelegate): ReactElemen return createSlider(command, t); } if (command instanceof BaseOptionCommand) { - return createOptionButton(command, t); + return createDropdownButton(command, t); } if (command instanceof BaseFilterCommand) { return createFilterButton(command, t); @@ -226,14 +226,14 @@ function createSlider(command: BaseSliderCommand, t: TranslateDelegate): ReactEl ); } -function createOptionButton(command: BaseOptionCommand, t: TranslateDelegate): ReactElement { +function createDropdownButton(command: BaseOptionCommand, t: TranslateDelegate): ReactElement { if (!command.isVisible) { return <>; } return ( - + ); } diff --git a/react-components/src/components/Architecture/Toolbar.tsx b/react-components/src/components/Architecture/Toolbar.tsx index e2e90fa6d8..5562000de7 100644 --- a/react-components/src/components/Architecture/Toolbar.tsx +++ b/react-components/src/components/Architecture/Toolbar.tsx @@ -11,6 +11,23 @@ import { useRenderTarget } from '../RevealCanvas/ViewerContext'; import { ActiveToolUpdater } from '../../architecture/base/reactUpdaters/ActiveToolUpdater'; import { type PopupStyle } from '../../architecture/base/domainObjectsHelpers/PopupStyle'; +export const TopToolbar = (): ReactElement => { + const renderTarget = useRenderTarget(); + if (renderTarget === undefined) { + return <>; + } + const config = renderTarget.config; + if (config === undefined) { + return <>; + } + const commands = useMemo(() => config.createTopToolbar(), [config]); + if (commands.length === 0) { + return <>; + } + const style = config.createTopToolbarStyle(); + return ; +}; + export const MainToolbar = (): ReactElement => { const renderTarget = useRenderTarget(); if (renderTarget === undefined) { @@ -65,7 +82,6 @@ const ToolbarContent = ({ top: style.topPx, bottom: style.bottomPx, margin: style.marginPx - // Padding is not used here }}> +