diff --git a/frontend/react/src/constants/constants.ts b/frontend/react/src/constants/constants.ts index 7da5ae1c..93b11d36 100644 --- a/frontend/react/src/constants/constants.ts +++ b/frontend/react/src/constants/constants.ts @@ -3,6 +3,7 @@ import { Coalition, MapOptions } from "../types/types"; import { CommandModeOptions } from "../interfaces"; import { ContextAction } from "../unit/contextaction"; import { + faClone, faExplosion, faHand, faLocationCrosshairs, @@ -264,12 +265,12 @@ export const mapBounds = { export const defaultMapMirrors = {}; export const defaultMapLayers = { - "AWACS": { - "urlTemplate": 'https://abcd.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', - "minZoom": 1, - "maxZoom": 19, - "attribution": `© OpenStreetMap contributors © CARTO'` - }, + AWACS: { + urlTemplate: "https://abcd.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png", + minZoom: 1, + maxZoom: 19, + attribution: `© OpenStreetMap contributors © CARTO'`, + }, }; export enum OlympusState { @@ -286,7 +287,7 @@ export enum OlympusState { OPTIONS = "Options", AUDIO = "Audio", AIRBASE = "Airbase", - GAME_MASTER = "Game master" + GAME_MASTER = "Game master", } export const NO_SUBSTATE = "No substate"; @@ -304,7 +305,7 @@ export enum LoginSubState { NO_SUBSTATE = "No substate", CREDENTIALS = "Credentials", COMMAND_MODE = "Command mode", - CONNECT = "Connect" + CONNECT = "Connect", } export enum DrawSubState { @@ -359,7 +360,7 @@ export const MAP_OPTIONS_DEFAULTS: MapOptions = { cameraPluginMode: "map", tabletMode: false, AWACSMode: false, - AWACSCoalition: "blue" + AWACSCoalition: "blue", }; export const MAP_HIDDEN_TYPES_DEFAULTS = { @@ -602,9 +603,7 @@ export namespace ContextActions { ContextActionTarget.POINT, (units: Unit[], _, targetPosition: LatLng | null) => { if (targetPosition) - getApp() - .getUnitsManager() - .bombPoint(targetPosition, getApp().getMap().getKeepRelativePositions(), getApp().getMap().getDestinationRotation(), units); + getApp().getUnitsManager().bombPoint(targetPosition, getApp().getMap().getKeepRelativePositions(), getApp().getMap().getDestinationRotation(), units); }, { type: ContextActionType.ENGAGE, code: "KeyB", ctrlKey: false, shiftKey: false } ); @@ -617,11 +616,9 @@ export namespace ContextActions { ContextActionTarget.POINT, (units: Unit[], _, targetPosition: LatLng | null) => { if (targetPosition) - getApp() - .getUnitsManager() - .carpetBomb(targetPosition, getApp().getMap().getKeepRelativePositions(), getApp().getMap().getDestinationRotation(), units); + getApp().getUnitsManager().carpetBomb(targetPosition, getApp().getMap().getKeepRelativePositions(), getApp().getMap().getDestinationRotation(), units); }, - { type: ContextActionType.ENGAGE, code: "KeyC", ctrlKey: false, shiftKey: false } + { type: ContextActionType.ENGAGE, code: "KeyH", ctrlKey: false, shiftKey: false } ); export const LAND = new ContextAction( @@ -644,9 +641,7 @@ export namespace ContextActions { ContextActionTarget.POINT, (units: Unit[], _, targetPosition: LatLng | null) => { if (targetPosition) - getApp() - .getUnitsManager() - .landAtPoint(targetPosition, getApp().getMap().getKeepRelativePositions(), getApp().getMap().getDestinationRotation(), units); + getApp().getUnitsManager().landAtPoint(targetPosition, getApp().getMap().getKeepRelativePositions(), getApp().getMap().getDestinationRotation(), units); }, { type: ContextActionType.ADMIN, code: "KeyK", ctrlKey: false, shiftKey: false } ); @@ -660,7 +655,7 @@ export namespace ContextActions { (units: Unit[], _1, _2) => { getApp().getUnitsManager().createGroup(units); }, - { type: ContextActionType.OTHER, code: "KeyG", ctrlKey: false, shiftKey: false, altKey: false } + { type: ContextActionType.OTHER, code: "KeyG", ctrlKey: false, shiftKey: false, altKey: false } ); export const ATTACK = new ContextAction( @@ -683,9 +678,7 @@ export namespace ContextActions { ContextActionTarget.POINT, (units: Unit[], _, targetPosition: LatLng | null) => { if (targetPosition) - getApp() - .getUnitsManager() - .fireAtArea(targetPosition, getApp().getMap().getKeepRelativePositions(), getApp().getMap().getDestinationRotation(), units); + getApp().getUnitsManager().fireAtArea(targetPosition, getApp().getMap().getKeepRelativePositions(), getApp().getMap().getDestinationRotation(), units); }, { type: ContextActionType.ENGAGE, code: "KeyV", ctrlKey: false, shiftKey: false } ); @@ -714,6 +707,19 @@ export namespace ContextActions { (units: Unit[], _1, _2) => { getApp().getUnitsManager().setAWACSReference(units[0].ID); }, - { type: ContextActionType.ADMIN, code: "KeyU", ctrlKey: false, shiftKey: false, altKey: false } + { type: ContextActionType.ADMIN, code: "KeyU", ctrlKey: false, shiftKey: false, altKey: false } + ); + + export const CLONE = new ContextAction( + "clone", + "Clone unit", + "Clone the unit at the given location", + faClone, + ContextActionTarget.POINT, + (units: Unit[], _1, targetPosition) => { + getApp().getUnitsManager().copy(units); + if (targetPosition) getApp().getUnitsManager().paste(targetPosition); + }, + { type: ContextActionType.ADMIN, code: "KeyC", ctrlKey: false, shiftKey: false, altKey: false } ); } diff --git a/frontend/react/src/events.ts b/frontend/react/src/events.ts index 651b06f3..bc29b7ea 100644 --- a/frontend/react/src/events.ts +++ b/frontend/react/src/events.ts @@ -1,7 +1,7 @@ import { AudioSink } from "./audio/audiosink"; import { AudioSource } from "./audio/audiosource"; import { OlympusState, OlympusSubState } from "./constants/constants"; -import { CommandModeOptions, OlympusConfig, ServerStatus, SessionData, SpawnRequestTable } from "./interfaces"; +import { CommandModeOptions, OlympusConfig, ServerStatus, SessionData, SpawnRequestTable, UnitData } from "./interfaces"; import { CoalitionCircle } from "./map/coalitionarea/coalitioncircle"; import { CoalitionPolygon } from "./map/coalitionarea/coalitionpolygon"; import { Airbase } from "./mission/airbase"; @@ -242,6 +242,32 @@ export class AirbaseSelectedEvent { } } +export class SelectionEnabledChangedEvent { + static on(callback: (enabled: boolean) => void) { + document.addEventListener(this.name, (ev: CustomEventInit) => { + callback(ev.detail.enabled); + }); + } + + static dispatch(enabled: boolean) { + document.dispatchEvent(new CustomEvent(this.name, { detail: { enabled } })); + console.log(`Event ${this.name} dispatched`); + } +}; + +export class PasteEnabledChangedEvent { + static on(callback: (enabled: boolean) => void) { + document.addEventListener(this.name, (ev: CustomEventInit) => { + callback(ev.detail.enabled); + }); + } + + static dispatch(enabled: boolean) { + document.dispatchEvent(new CustomEvent(this.name, { detail: { enabled } })); + console.log(`Event ${this.name} dispatched`); + } +}; + export class ContactsUpdatedEvent { static on(callback: () => void) { document.addEventListener(this.name, (ev: CustomEventInit) => { @@ -281,6 +307,19 @@ export class ContextActionChangedEvent { } } +export class CopiedUnitsEvents { + static on(callback: (unitsData: UnitData[]) => void) { + document.addEventListener(this.name, (ev: CustomEventInit) => { + callback(ev.detail.unitsData); + }); + } + + static dispatch(unitsData: UnitData[]) { + document.dispatchEvent(new CustomEvent(this.name, { detail: { unitsData } })); + console.log(`Event ${this.name} dispatched`); + } +} + export class UnitUpdatedEvent extends BaseUnitEvent { static dispatch(unit: Unit) { document.dispatchEvent(new CustomEvent(this.name, { detail: { unit } })); diff --git a/frontend/react/src/map/boxselect.ts b/frontend/react/src/map/boxselect.ts index b8cb922e..d4db058c 100644 --- a/frontend/react/src/map/boxselect.ts +++ b/frontend/react/src/map/boxselect.ts @@ -46,7 +46,7 @@ export var BoxSelect = Handler.extend({ }, _onMouseDown: function (e: any) { - if (this._map.getEnableSelection() && e.button == 0) { + if (this._map.getSelectionEnabled() && e.button == 0) { // Clear the deferred resetState if it hasn't executed yet, otherwise it // will interrupt the interaction and orphan a box element in the container. this._clearDeferredResetState(); diff --git a/frontend/react/src/map/map.ts b/frontend/react/src/map/map.ts index 92d07e2e..3c9411da 100644 --- a/frontend/react/src/map/map.ts +++ b/frontend/react/src/map/map.ts @@ -53,7 +53,9 @@ import { MapOptionsChangedEvent, MapSourceChangedEvent, MouseMovedEvent, + PasteEnabledChangedEvent, SelectionClearedEvent, + SelectionEnabledChangedEvent, SpawnContextMenuRequestEvent, StarredSpawnsChangedEvent, UnitDeselectedEvent, @@ -113,7 +115,8 @@ export class Map extends L.Map { #lastMouseCoordinates: L.LatLng = new L.LatLng(0, 0); #previousZoom: number = 0; #keepRelativePositions: boolean = false; - #enableSelection: boolean = false; + #selectionEnabled: boolean = false; + #pasteEnabled: boolean = false; /* Camera control plugin */ #slaveDCSCamera: boolean = false; @@ -363,10 +366,10 @@ export class Map extends L.Map { shiftKey: false, ctrlKey: false, }) - .addShortcut("toggleEnableSelection", { + .addShortcut("toggleSelectionEnabled", { label: "Toggle box selection", - keyUpCallback: () => this.setEnableSelection(false), - keyDownCallback: () => this.setEnableSelection(true), + keyUpCallback: () => this.setSelectionEnabled(false), + keyDownCallback: () => this.setSelectionEnabled(true), code: "ShiftLeft", altKey: false, ctrlKey: false, @@ -755,12 +758,22 @@ export class Map extends L.Map { return this.#keepRelativePositions; } - setEnableSelection(enableSelection: boolean) { - this.#enableSelection = enableSelection; + setSelectionEnabled(selectionEnabled: boolean) { + this.#selectionEnabled = selectionEnabled; + SelectionEnabledChangedEvent.dispatch(selectionEnabled) } - getEnableSelection() { - return this.#enableSelection; + getSelectionEnabled() { + return this.#selectionEnabled; + } + + setPasteEnabled(pasteEnabled: boolean) { + this.#pasteEnabled = pasteEnabled; + PasteEnabledChangedEvent.dispatch(pasteEnabled) + } + + getPasteEnabled() { + return this.#pasteEnabled; } increaseCameraZoom() { @@ -805,8 +818,12 @@ export class Map extends L.Map { this.#currentSpawnMarker = null; this.#currentEffectMarker?.removeFrom(this); this.#currentEffectMarker = null; - this.setContextAction(null); - if (state !== OlympusState.UNIT_CONTROL) getApp().getUnitsManager().deselectAllUnits(); + + if (state !== OlympusState.UNIT_CONTROL) { + getApp().getUnitsManager().deselectAllUnits(); + this.setContextAction(null); + this.setContextActionSet(null); + } if (state !== OlympusState.DRAW || (state === OlympusState.DRAW && subState !== DrawSubState.EDIT)) this.deselectAllCoalitionAreas(); this.getContainer().classList.remove(`explosion-cursor`); ["white", "blue", "red", "green", "orange"].forEach((color) => this.getContainer().classList.remove(`smoke-${color}-cursor`)); @@ -908,6 +925,10 @@ export class Map extends L.Map { if (!this.#isSelecting) { console.log(`Left short click at ${e.latlng}`); + if (this.#pasteEnabled) { + getApp().getUnitsManager().paste(e.latlng) + } + /* Execute the short click action */ if (getApp().getState() === OlympusState.IDLE) { /* Do nothing */ @@ -1023,6 +1044,10 @@ export class Map extends L.Map { } getApp().setState(OlympusState.JTAC); this.#drawIPToTargetLine(); + } else if (getApp().getState() === OlympusState.UNIT_CONTROL) { + if (this.#contextAction !== null) this.executeContextAction(null, e.latlng, e.originalEvent); + else if (getApp().getSubState() === NO_SUBSTATE) getApp().setState(OlympusState.IDLE); + else getApp().setState(OlympusState.UNIT_CONTROL); } else { if (getApp().getSubState() === NO_SUBSTATE) getApp().setState(OlympusState.IDLE); else getApp().setState(OlympusState.UNIT_CONTROL); @@ -1041,8 +1066,7 @@ export class Map extends L.Map { SpawnContextMenuRequestEvent.dispatch(e.latlng); getApp().setState(OlympusState.SPAWN_CONTEXT); } else if (getApp().getState() === OlympusState.UNIT_CONTROL) { - if (this.#contextAction !== null) this.executeContextAction(null, e.latlng, e.originalEvent); - else this.executeDefaultContextAction(null, e.latlng, e.originalEvent); + this.executeDefaultContextAction(null, e.latlng, e.originalEvent); } } @@ -1062,6 +1086,8 @@ export class Map extends L.Map { if (this.#debounceTimeout) window.clearTimeout(this.#debounceTimeout); + this.setPasteEnabled(false); + if (getApp().getSubState() === NO_SUBSTATE) getApp().setState(OlympusState.IDLE); else getApp().setState(getApp().getState()); } diff --git a/frontend/react/src/map/markers/stylesheets/units.css b/frontend/react/src/map/markers/stylesheets/units.css index ad15e2f6..41a659be 100644 --- a/frontend/react/src/map/markers/stylesheets/units.css +++ b/frontend/react/src/map/markers/stylesheets/units.css @@ -172,9 +172,10 @@ /*** Cursors ***/ [data-is-dead], -[data-object|="unit-missile"], -[data-object|="unit-bomb"] { - cursor: default; +[data-object|="unit-missile"] *, +[data-object|="unit-bomb"] *{ + pointer-events: none; + } /*** Labels ***/ diff --git a/frontend/react/src/ui/components/olstatebutton.tsx b/frontend/react/src/ui/components/olstatebutton.tsx index ed158141..42bd329a 100644 --- a/frontend/react/src/ui/components/olstatebutton.tsx +++ b/frontend/react/src/ui/components/olstatebutton.tsx @@ -9,7 +9,8 @@ export function OlStateButton(props: { buttonColor?: string | null; checked: boolean; icon?: IconProp; - tooltip: string; + tooltip?: string | JSX.Element | JSX.Element[]; + tooltipPosition?: string; onClick: () => void; onMouseUp?: () => void; onMouseDown?: () => void; @@ -21,7 +22,8 @@ export function OlStateButton(props: { const className = (props.className ?? "") + ` - h-[40px] w-[40px] flex-none rounded-md text-lg font-medium + pointer-events-auto h-[40px] w-[40px] flex-none rounded-md text-lg + font-medium dark:bg-olympus-600 dark:text-gray-300 `; @@ -57,12 +59,12 @@ export function OlStateButton(props: { setHover(false); }} > -
+
{props.icon && } {props.children}
- {hover && } + {hover && props.tooltip && } ); } diff --git a/frontend/react/src/ui/components/oltooltip.tsx b/frontend/react/src/ui/components/oltooltip.tsx index 628f6bd1..1038065d 100644 --- a/frontend/react/src/ui/components/oltooltip.tsx +++ b/frontend/react/src/ui/components/oltooltip.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; -export function OlTooltip(props: { content: string; buttonRef: React.MutableRefObject }) { +export function OlTooltip(props: { content: string | JSX.Element | JSX.Element[]; buttonRef: React.MutableRefObject; position?: string }) { var contentRef = useRef(null); function setPosition(content: HTMLDivElement, button: HTMLButtonElement) { @@ -13,18 +13,18 @@ export function OlTooltip(props: { content: string; buttonRef: React.MutableRefO let [cxl, cyt, cxr, cyb, cw, ch] = [ content.getBoundingClientRect().x, content.getBoundingClientRect().y, - content.getBoundingClientRect().x + content.clientWidth, - content.getBoundingClientRect().y + content.clientHeight, - content.clientWidth, - content.clientHeight, + content.getBoundingClientRect().x + content.offsetWidth, + content.getBoundingClientRect().y + content.offsetHeight, + content.offsetWidth, + content.offsetHeight, ]; let [bxl, byt, bxr, byb, bbw, bh] = [ button.getBoundingClientRect().x, button.getBoundingClientRect().y, - button.getBoundingClientRect().x + button.clientWidth, - button.getBoundingClientRect().y + button.clientHeight, - button.clientWidth, - button.clientHeight, + button.getBoundingClientRect().x + button.offsetWidth, + button.getBoundingClientRect().y + button.offsetHeight, + button.offsetWidth, + button.offsetHeight, ]; /* Limit the maximum height */ @@ -37,19 +37,29 @@ export function OlTooltip(props: { content: string; buttonRef: React.MutableRefO var cxc = (cxl + cxr) / 2; var bxc = (bxl + bxr) / 2; - /* Compute the x and y offsets needed to align the button and element horizontally, and to put the content below the button */ - var offsetX = bxc - cxc; - var offsetY = byb - cyt + 8; + /* Compute the x and y offsets needed to align the button and element horizontally, and to put the content depending on the requested position */ + var offsetX = 0; + var offsetY = 0; + + if (props.position === undefined || props.position === "below") { + offsetX = bxc - cxc; + offsetY = byb - cyt + 8; + } else if (props.position === "side") { + offsetX = bxr + 8; + offsetY = byt - cyt + (bh - ch) / 2; + } /* Compute the new position of the left and right margins of the content */ - cxl += offsetX; - cxr += offsetX; - cyb += offsetY; + let ncxl = cxl + offsetX; + let ncxr = cxr + offsetX; + let ncyb = cyb + offsetY; /* Try and move the content so it is inside the screen */ - if (cxl < 0) offsetX -= cxl; - if (cxr > window.innerWidth) offsetX -= cxr - window.innerWidth; - if (cyb > window.innerHeight) offsetY -= bh + ch + 16; + if (ncxl < 0) offsetX -= cxl; + if (ncxr > window.innerWidth) { + offsetX = bxl - cxl - cw - 12; + } + if (ncyb > window.innerHeight) offsetY -= bh + ch + 16; /* Apply the offset */ content.style.left = `${offsetX}px`; diff --git a/frontend/react/src/ui/panels/controlspanel.tsx b/frontend/react/src/ui/panels/controlspanel.tsx index fda86a97..efa44d8a 100644 --- a/frontend/react/src/ui/panels/controlspanel.tsx +++ b/frontend/react/src/ui/panels/controlspanel.tsx @@ -5,6 +5,7 @@ import { MAP_OPTIONS_DEFAULTS, NO_SUBSTATE, OlympusState, OlympusSubState, Spawn import { AppStateChangedEvent, ContextActionSetChangedEvent, MapOptionsChangedEvent, ShortcutsChangedEvent } from "../../events"; import { ContextAction } from "../../unit/contextaction"; import { ContextActionSet } from "../../unit/contextactionset"; +import { MapToolBar } from "./maptoolbar"; export function ControlsPanel(props: {}) { const [controls, setControls] = useState( @@ -19,8 +20,8 @@ export function ControlsPanel(props: {}) { const [appState, setAppState] = useState(OlympusState.NOT_INITIALIZED); const [appSubState, setAppSubState] = useState(NO_SUBSTATE as OlympusSubState); const [mapOptions, setMapOptions] = useState(MAP_OPTIONS_DEFAULTS); - const [shortcuts, setShortcuts] = useState({}) - const [contextActionSet, setContextActionSet] = useState(null as null | ContextActionSet) + const [shortcuts, setShortcuts] = useState({}); + const [contextActionSet, setContextActionSet] = useState(null as null | ContextActionSet); useEffect(() => { AppStateChangedEvent.on((state, subState) => { @@ -74,53 +75,28 @@ export function ControlsPanel(props: {}) { } ); } else if (appState === OlympusState.UNIT_CONTROL) { - if (!mapOptions.tabletMode) { - controls = Object.values(contextActionSet?.getContextActions() ?? {}) - .sort((a: ContextAction, b: ContextAction) => (a.getLabel() > b.getLabel() ? 1 : -1)) - .filter((contextAction: ContextAction) => contextAction.getOptions().code) - .map((contextAction: ContextAction) => { - let actions: (string | IconDefinition)[] = []; - contextAction.getOptions().shiftKey && actions.push("Shift"); - contextAction.getOptions().altKey && actions.push("Alt"); - contextAction.getOptions().ctrlKey && actions.push("Ctrl"); - actions.push( - (contextAction.getOptions().code as string) - .replace("Key", "") - .replace("ControlLeft", "Left Ctrl") - .replace("AltLeft", "Left Alt") - .replace("ShiftLeft", "Left Shift") - .replace("ControlRight", "Right Ctrl") - .replace("AltRight", "Right Alt") - .replace("ShiftRight", "Right Shift") - ); - return { - actions: actions, - text: contextAction.getLabel(), - }; - }); - controls.unshift({ - actions: ["RMB"], - text: "Move", - }); - controls.push({ - actions: ["RMB", "Hold"], - target: faMap, - text: "Show point actions", - }); - controls.push({ - actions: ["RMB", "Hold"], - target: faFighterJet, - text: "Show unit actions", - }); - controls.push({ - actions: shortcuts["toggleRelativePositions"]?.toActions(), - text: "Activate group movement", - }); - controls.push({ - actions: [...shortcuts["toggleRelativePositions"]?.toActions(), "Wheel"], - text: "Rotate formation", - }); - } + controls.unshift({ + actions: ["RMB"], + text: "Move", + }); + controls.push({ + actions: ["RMB", "Hold"], + target: faMap, + text: "Show point actions", + }); + controls.push({ + actions: ["RMB", "Hold"], + target: faFighterJet, + text: "Show unit actions", + }); + controls.push({ + actions: shortcuts["toggleRelativePositions"]?.toActions(), + text: "Activate group movement", + }); + controls.push({ + actions: [...shortcuts["toggleRelativePositions"]?.toActions(), "Wheel"], + text: "Rotate formation", + }); } else if (appState === OlympusState.SPAWN) { controls = [ { @@ -151,8 +127,8 @@ export function ControlsPanel(props: {}) { controls = baseControls; controls.push({ actions: ["LMB"], - text: "Return to idle state" - }) + text: "Return to idle state", + }); } setControls(controls); @@ -163,11 +139,13 @@ export function ControlsPanel(props: {}) { return (
+ {controls?.map((control) => { return (
- {typeof action === "string" || typeof action === "number" ? action : } + {typeof action === "string" || typeof action === "number" ? ( + action + ) : ( + + )}
{idx < control.actions.length - 1 && typeof control.actions[idx + 1] === "string" &&
+
} {idx < control.actions.length - 1 && typeof control.actions[idx + 1] === "number" &&
x
} diff --git a/frontend/react/src/ui/panels/maptoolbar.tsx b/frontend/react/src/ui/panels/maptoolbar.tsx new file mode 100644 index 00000000..5061b0ed --- /dev/null +++ b/frontend/react/src/ui/panels/maptoolbar.tsx @@ -0,0 +1,275 @@ +import React, { useEffect, useRef, useState } from "react"; +import { ContextActionSet } from "../../unit/contextactionset"; +import { OlStateButton } from "../components/olstatebutton"; +import { getApp } from "../../olympusapp"; +import { ContextAction, ContextActionOptions } from "../../unit/contextaction"; +import { CONTEXT_ACTION_COLORS, ContextActionTarget, MAP_OPTIONS_DEFAULTS } from "../../constants/constants"; +import { FaChevronDown, FaChevronUp } from "react-icons/fa6"; +import { OlympusState } from "../../constants/constants"; +import { + AppStateChangedEvent, + ContextActionChangedEvent, + ContextActionSetChangedEvent, + CopiedUnitsEvents, + MapOptionsChangedEvent, + PasteEnabledChangedEvent, + SelectedUnitsChangedEvent, + SelectionClearedEvent, + SelectionEnabledChangedEvent, + ShortcutsChangedEvent, +} from "../../events"; +import { faCopy, faObjectGroup, faPaste } from "@fortawesome/free-solid-svg-icons"; +import { Shortcut } from "../../shortcut/shortcut"; +import { ShortcutOptions, UnitData } from "../../interfaces"; +import { Unit } from "../../unit/unit"; + +export function MapToolBar(props: {}) { + const [appState, setAppState] = useState(OlympusState.IDLE); + const [contextActionSet, setcontextActionSet] = useState(null as ContextActionSet | null); + const [contextAction, setContextAction] = useState(null as ContextAction | null); + const [scrolledTop, setScrolledTop] = useState(true); + const [scrolledBottom, setScrolledBottom] = useState(false); + const [mapOptions, setMapOptions] = useState(MAP_OPTIONS_DEFAULTS); + const [selectionEnabled, setSelectionEnabled] = useState(false); + const [pasteEnabled, setPasteEnabled] = useState(false); + const [controller, setController] = useState(new AbortController()); + const [shortcuts, setShortcuts] = useState( + {} as { + [key: string]: Shortcut; + } + ); + const [selectedUnits, setSelectedUnits] = useState([] as Unit[]); + const [copiedUnitsData, setCopiedUnitsData] = useState([] as UnitData[]); + + /* Initialize the "scroll" position of the element */ + var scrollRef = useRef(null); + useEffect(() => { + if (scrollRef.current) onScroll(scrollRef.current); + }); + + useEffect(() => { + AppStateChangedEvent.on((state, subState) => setAppState(state)); + ContextActionSetChangedEvent.on((contextActionSet) => setcontextActionSet(contextActionSet)); + ContextActionChangedEvent.on((contextAction) => setContextAction(contextAction)); + MapOptionsChangedEvent.on((mapOptions) => setMapOptions(mapOptions)); + SelectionEnabledChangedEvent.on((selectionEnabled) => setSelectionEnabled(selectionEnabled)); + PasteEnabledChangedEvent.on((pasteEnabled) => setPasteEnabled(pasteEnabled)); + ShortcutsChangedEvent.on((shortcuts) => setShortcuts({ ...shortcuts })); + SelectedUnitsChangedEvent.on((selectedUnits) => setSelectedUnits(selectedUnits)); + SelectionClearedEvent.on(() => setSelectedUnits([])); + CopiedUnitsEvents.on((unitsData) => setCopiedUnitsData(unitsData)); + }, []); + + function onScroll(el) { + const sl = el.scrollTop; + const sr = el.scrollHeight - el.scrollTop - el.clientHeight; + + sl < 1 && !scrolledTop && setScrolledTop(true); + sl > 1 && scrolledTop && setScrolledTop(false); + + sr < 1 && !scrolledBottom && setScrolledBottom(true); + sr > 1 && scrolledBottom && setScrolledBottom(false); + } + + function shortcutCombination(options: ShortcutOptions | ContextActionOptions) { + if (options === undefined) return <>; + return ( + <> + {options.ctrlKey && ( + + Ctrl + + )} + {options.altKey && ( + + Alt + + )} + {options.shiftKey && ( + + Shift + + )} + + {options.code && ( + + {options.code?.replace("Key", "")} + + )} + + ); + } + + let reorderedActions: ContextAction[] = contextActionSet + ? Object.values(contextActionSet.getContextActions()).sort((a: ContextAction, b: ContextAction) => (a.getOptions().type < b.getOptions().type ? -1 : 1)) + : []; + + return ( + <> + <> +
+ {!scrolledTop && ( + + )} +
onScroll(ev.target)} ref={scrollRef}> + <> +
+ + {shortcutCombination(shortcuts["toggleSelectionEnabled"]?.getOptions())} +
Box selection
+
+ } + tooltipPosition="side" + onClick={() => { + getApp().getMap().setSelectionEnabled(!selectionEnabled); + if (!selectionEnabled) { + getApp() + .getMap() + .getContainer() + .addEventListener( + "mouseup", + () => { + getApp().getMap().setSelectionEnabled(false); + }, + { once: true, signal: controller.signal } + ); + } else { + controller.abort(); + } + }} + /> +
+ {selectedUnits.length > 0 && ( +
+ + {shortcutCombination(shortcuts["copyUnits"]?.getOptions())} +
Copy selected units
+
+ } + tooltipPosition="side" + onClick={() => { + getApp().getUnitsManager().copy(selectedUnits); + }} + /> +
+ )} + {copiedUnitsData.length > 0 && ( +
+ + {shortcutCombination(shortcuts["pasteUnits"]?.getOptions())} +
Paste copied units
+
+ } tooltipPosition="side" onClick={() => { + getApp().getMap().setPasteEnabled(!pasteEnabled) + }} /> +
+ )} + + + {reorderedActions.map((contextActionIt: ContextAction) => { + return ( +
+ + {shortcutCombination(contextActionIt.getOptions())} +
{contextActionIt.getLabel()}
+
+ } + tooltipPosition="side" + buttonColor={CONTEXT_ACTION_COLORS[contextActionIt.getOptions().type ?? 0]} + onClick={() => { + if (contextActionIt.getTarget() === ContextActionTarget.NONE) { + contextActionIt.executeCallback(null, null); + } else { + contextActionIt !== contextAction ? getApp().getMap().setContextAction(contextActionIt) : getApp().getMap().setContextAction(null); + } + }} + /> +
+ ); + })} +
+ {!scrolledBottom && ( + + )} + + + + {/*} + {contextAction && ( +
+
+ )} + {*/} + + ); +} diff --git a/frontend/react/src/ui/panels/minimappanel.tsx b/frontend/react/src/ui/panels/minimappanel.tsx index cb694c43..d0a201f9 100644 --- a/frontend/react/src/ui/panels/minimappanel.tsx +++ b/frontend/react/src/ui/panels/minimappanel.tsx @@ -20,7 +20,7 @@ export function MiniMapPanel(props: {}) { useEffect(() => { let miniMap = document.querySelector(".leaflet-control-minimap"); if (miniMap) { - miniMap.classList.add("rounded-t-lg"); + miniMap.classList.add("rounded-b-lg"); } }); diff --git a/frontend/react/src/ui/panels/sidebar.tsx b/frontend/react/src/ui/panels/sidebar.tsx index 0e87c1a0..b4b95783 100644 --- a/frontend/react/src/ui/panels/sidebar.tsx +++ b/frontend/react/src/ui/panels/sidebar.tsx @@ -32,6 +32,7 @@ export function SideBar() { checked={appState === OlympusState.MAIN_MENU} icon={faEllipsisV} tooltip="Hide/show main menu" + tooltipPosition="side" > { @@ -40,6 +41,7 @@ export function SideBar() { checked={appState === OlympusState.SPAWN} icon={faPlusSquare} tooltip="Hide/show unit spawn menu" + tooltipPosition="side" > { @@ -48,6 +50,7 @@ export function SideBar() { checked={appState === OlympusState.UNIT_CONTROL} icon={faGamepad} tooltip="Hide/show selection tool and unit control menu" + tooltipPosition="side" > { @@ -56,6 +59,7 @@ export function SideBar() { checked={appState === OlympusState.DRAW} icon={faPencil} tooltip="Hide/show drawing menu" + tooltipPosition="side" > { @@ -64,6 +68,7 @@ export function SideBar() { checked={appState === OlympusState.AUDIO} icon={faVolumeHigh} tooltip="Hide/show audio menu" + tooltipPosition="side" > {/*} { @@ -80,6 +85,7 @@ export function SideBar() { checked={appState === OlympusState.AWACS} icon={faA} tooltip="Hide/show AWACS menu" + tooltipPosition="side" > { @@ -88,6 +94,7 @@ export function SideBar() { checked={appState === OlympusState.GAME_MASTER} icon={faCrown} tooltip="Hide/show Game Master menu" + tooltipPosition="side" > @@ -98,6 +105,7 @@ export function SideBar() { checked={false} icon={faQuestionCircle} tooltip="Open user guide on separate window" + tooltipPosition="side" > { @@ -106,6 +114,7 @@ export function SideBar() { checked={appState === OlympusState.OPTIONS} icon={faCog} tooltip="Hide/show settings menu" + tooltipPosition="side" > diff --git a/frontend/react/src/ui/panels/unitcontrolbar.tsx b/frontend/react/src/ui/panels/unitcontrolbar.tsx deleted file mode 100644 index 43bd83fa..00000000 --- a/frontend/react/src/ui/panels/unitcontrolbar.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React, { useEffect, useRef, useState } from "react"; -import { ContextActionSet } from "../../unit/contextactionset"; -import { OlStateButton } from "../components/olstatebutton"; -import { getApp } from "../../olympusapp"; -import { ContextAction } from "../../unit/contextaction"; -import { CONTEXT_ACTION_COLORS, ContextActionTarget, MAP_OPTIONS_DEFAULTS } from "../../constants/constants"; -import { FaChevronDown,FaChevronUp } from "react-icons/fa6"; -import { OlympusState } from "../../constants/constants"; -import { AppStateChangedEvent, ContextActionChangedEvent, ContextActionSetChangedEvent, MapOptionsChangedEvent } from "../../events"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -export function UnitControlBar(props: {}) { - const [appState, setAppState] = useState(OlympusState.NOT_INITIALIZED); - const [contextActionSet, setcontextActionSet] = useState(null as ContextActionSet | null); - const [contextAction, setContextAction] = useState(null as ContextAction | null); - const [scrolledTop, setScrolledTop] = useState(true); - const [scrolledBottom, setScrolledBottom] = useState(false); - const [menuHidden, setMenuHidden] = useState(false); - const [mapOptions, setMapOptions] = useState(MAP_OPTIONS_DEFAULTS); - - /* Initialize the "scroll" position of the element */ - var scrollRef = useRef(null); - useEffect(() => { - if (scrollRef.current) onScroll(scrollRef.current); - }); - - useEffect(() => { - AppStateChangedEvent.on((state, subState) => setAppState(state)); - ContextActionSetChangedEvent.on((contextActionSet) => setcontextActionSet(contextActionSet)); - ContextActionChangedEvent.on((contextAction) => setContextAction(contextAction)); - MapOptionsChangedEvent.on((mapOptions) => setMapOptions({ ...mapOptions })); - }, []); - - function onScroll(el) { - const sl = el.scrollTop; - const sr = el.scrollHeight - el.scrollTop - el.clientHeight; - - sl < 1 && !scrolledTop && setScrolledTop(true); - sl > 1 && scrolledTop && setScrolledTop(false); - - sr < 1 && !scrolledBottom && setScrolledBottom(true); - sr > 1 && scrolledBottom && setScrolledBottom(false); - } - - let reorderedActions: ContextAction[] = contextActionSet - ? Object.values(contextActionSet.getContextActions()).sort((a: ContextAction, b: ContextAction) => (a.getOptions().type < b.getOptions().type ? -1 : 1)) - : []; - - return ( - <> - {appState === OlympusState.UNIT_CONTROL && contextActionSet && Object.keys(contextActionSet.getContextActions()).length > 0 && ( - <> - {mapOptions.tabletMode && ( - <> -
- {!scrolledTop && ( - - )} -
onScroll(ev.target)} ref={scrollRef}> - {reorderedActions.map((contextActionIt: ContextAction) => { - return ( -
- { - if (contextActionIt.getTarget() === ContextActionTarget.NONE) { - contextActionIt.executeCallback(null, null); - } else { - contextActionIt !== contextAction - ? getApp().getMap().setContextAction(contextActionIt) - : getApp().getMap().setContextAction(null); - } - }} - /> -
- ); - })} -
- {!scrolledBottom && ( - - )} -
- - )} - - {contextAction && ( -
-
- )} - - )} - - ); -} diff --git a/frontend/react/src/ui/ui.tsx b/frontend/react/src/ui/ui.tsx index c8b28985..7f382287 100644 --- a/frontend/react/src/ui/ui.tsx +++ b/frontend/react/src/ui/ui.tsx @@ -13,7 +13,7 @@ import { getApp, setupApp } from "../olympusapp"; import { LoginModal } from "./modals/loginmodal"; import { MiniMapPanel } from "./panels/minimappanel"; -import { UnitControlBar } from "./panels/unitcontrolbar"; +import { MapToolBar } from "./panels/maptoolbar"; import { DrawingMenu } from "./panels/drawingmenu"; import { ControlsPanel } from "./panels/controlspanel"; import { MapContextMenu } from "./contextmenus/mapcontextmenu"; @@ -105,7 +105,6 @@ export function UI() { - diff --git a/frontend/react/src/unit/unit.ts b/frontend/react/src/unit/unit.ts index 0f30d1f6..60fa5731 100644 --- a/frontend/react/src/unit/unit.ts +++ b/frontend/react/src/unit/unit.ts @@ -836,6 +836,9 @@ export abstract class Unit extends CustomMarker { contextActionSet.addContextAction(this, ContextActions.PATH); contextActionSet.addContextAction(this, ContextActions.DELETE); contextActionSet.addContextAction(this, ContextActions.EXPLODE); + contextActionSet.addContextAction(this, ContextActions.CENTER_MAP); + contextActionSet.addContextAction(this, ContextActions.CLONE); + contextActionSet.addContextAction(this, ContextActions.ATTACK); contextActionSet.addDefaultContextAction(this, ContextActions.MOVE); } @@ -1357,16 +1360,18 @@ export abstract class Unit extends CustomMarker { this.#debounceTimeout = window.setTimeout(() => { console.log(`Left short click on ${this.getUnitName()}`); - if (!e.originalEvent.ctrlKey) getApp().getUnitsManager().deselectAllUnits(); - this.setSelected(!this.getSelected()); + if (getApp().getState() === OlympusState.UNIT_CONTROL && getApp().getMap().getContextAction()) { + if (getApp().getMap().getContextAction()?.getTarget() === ContextActionTarget.UNIT) getApp().getMap().executeContextAction(this, null, e.originalEvent); + else getApp().getMap().executeContextAction(null, this.getPosition(), e.originalEvent); + } else { + if (!e.originalEvent.ctrlKey) getApp().getUnitsManager().deselectAllUnits(); + this.setSelected(!this.getSelected()); + } }, SHORT_PRESS_MILLISECONDS); } #onRightShortClick(e: any) { console.log(`Right short click on ${this.getUnitName()}`); - - if (getApp().getState() === OlympusState.UNIT_CONTROL && getApp().getMap().getContextAction()?.getTarget() === ContextActionTarget.UNIT) - getApp().getMap().executeContextAction(this, null, e.originalEvent); } #onRightLongClick(e: any) { @@ -1848,7 +1853,7 @@ export abstract class AirUnit extends Unit { showAmmo: belongsToCommandedCoalition, showSummary: belongsToCommandedCoalition || this.getDetectionMethods().some((value) => [VISUAL, OPTIC, RADAR, IRST, DLINK].includes(value)), showCallsign: belongsToCommandedCoalition && (!getApp().getMap().getOptions().AWACSMode || this.getHuman()), - rotateToHeading: false + rotateToHeading: false, } as ObjectIconOptions; } @@ -1857,10 +1862,8 @@ export abstract class AirUnit extends Unit { /* Context actions to be executed immediately */ contextActionSet.addContextAction(this, ContextActions.REFUEL); - contextActionSet.addContextAction(this, ContextActions.CENTER_MAP); /* Context actions that require a target unit */ - contextActionSet.addContextAction(this, ContextActions.ATTACK); contextActionSet.addContextAction(this, ContextActions.FOLLOW); contextActionSet.addContextAction(this, ContextActions.SET_AWACS_REFERENCE); @@ -1946,10 +1949,6 @@ export class GroundUnit extends Unit { /* Context actions to be executed immediately */ contextActionSet.addContextAction(this, ContextActions.GROUP); - contextActionSet.addContextAction(this, ContextActions.CENTER_MAP); - - /* Context actions that require a target unit */ - contextActionSet.addContextAction(this, ContextActions.ATTACK); /* Context actions that require a target position */ if (this.canTargetPoint()) { @@ -2015,10 +2014,6 @@ export class NavyUnit extends Unit { /* Context actions to be executed immediately */ contextActionSet.addContextAction(this, ContextActions.GROUP); - contextActionSet.addContextAction(this, ContextActions.CENTER_MAP); - - /* Context actions that require a target unit */ - contextActionSet.addContextAction(this, ContextActions.ATTACK); /* Context actions that require a target position */ contextActionSet.addContextAction(this, ContextActions.FIRE_AT_AREA); diff --git a/frontend/react/src/unit/unitsmanager.ts b/frontend/react/src/unit/unitsmanager.ts index 3d797309..bba9d958 100644 --- a/frontend/react/src/unit/unitsmanager.ts +++ b/frontend/react/src/unit/unitsmanager.ts @@ -17,6 +17,7 @@ import { AWACSReferenceChangedEvent, CommandModeOptionsChangedEvent, ContactsUpdatedEvent, + CopiedUnitsEvents, HotgroupsChangedEvent, SelectedUnitsChangedEvent, SelectionClearedEvent, @@ -1234,6 +1235,8 @@ export class UnitsManager { ) ); /* Can be applied to humans too */ getApp().addInfoMessage(`${this.#copiedUnits.length} units copied`); + + CopiedUnitsEvents.dispatch(this.#copiedUnits) } /*********************** Unit manipulation functions ************************/ @@ -1241,7 +1244,7 @@ export class UnitsManager { * * @returns True if units were pasted successfully */ - paste() { + paste(location?: LatLng) { let spawnPoints = 0; /* If spawns are restricted, check that the user has the necessary spawn points */ @@ -1285,7 +1288,10 @@ export class UnitsManager { var units: { ID: number; location: LatLng }[] = []; let markers: TemporaryUnitMarker[] = []; groups[groupName].forEach((unit: UnitData) => { - var position = new LatLng( + var position = location ? new LatLng( + location.lat + unit.position.lat - avgLat, + location.lng + unit.position.lng - avgLng + ) : new LatLng( getApp().getMap().getMouseCoordinates().lat + unit.position.lat - avgLat, getApp().getMap().getMouseCoordinates().lng + unit.position.lng - avgLng );