From 6fdfb194a6f1510b8bcce6fe770fc38f76d92761 Mon Sep 17 00:00:00 2001 From: Davide Passoni Date: Thu, 8 Aug 2024 15:32:59 +0200 Subject: [PATCH] Implemented context menu and multiple control tweaks --- .gitignore | 1 + frontend/react/src/dom.d.ts | 5 +- frontend/react/src/main.tsx | 3 +- frontend/react/src/map/map.css | 6 + frontend/react/src/map/map.ts | 120 ++++++++---- frontend/react/src/server/servermanager.ts | 5 +- frontend/react/src/statecontext.tsx | 4 +- .../react/src/ui/components/oldropdown.tsx | 13 +- .../src/ui/contextmenus/mapcontextmenu.tsx | 139 ++++++++++++++ frontend/react/src/ui/panels/controls.tsx | 26 ++- frontend/react/src/ui/panels/drawingmenu.tsx | 28 +-- frontend/react/src/ui/panels/header.tsx | 9 +- frontend/react/src/ui/panels/minimappanel.tsx | 20 +- frontend/react/src/ui/panels/sidebar.tsx | 11 +- .../react/src/ui/panels/unitcontrolmenu.tsx | 53 +++--- .../src/ui/panels/unitmousecontrolbar.tsx | 55 +++--- frontend/react/src/ui/ui.tsx | 138 +++++++------- frontend/react/src/unit/contextaction.ts | 8 +- frontend/react/src/unit/contextactionset.ts | 33 +++- frontend/react/src/unit/unit.ts | 180 +++++++++++++----- frontend/react/src/unit/unitsmanager.ts | 10 +- 21 files changed, 592 insertions(+), 275 deletions(-) create mode 100644 frontend/react/src/ui/contextmenus/mapcontextmenu.tsx diff --git a/.gitignore b/.gitignore index a53ce9ec..bbc0561d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ manager/manager.log /frontend/server/public/assets /frontend/server/public/vite /frontend/server/build +/frontend/react/.vite diff --git a/frontend/react/src/dom.d.ts b/frontend/react/src/dom.d.ts index 8a343f3a..68f9e88e 100644 --- a/frontend/react/src/dom.d.ts +++ b/frontend/react/src/dom.d.ts @@ -11,7 +11,6 @@ interface CustomEventMap { unitDeath: CustomEvent; unitUpdated: CustomEvent; mapStateChanged: CustomEvent; - mapContextMenu: CustomEvent; mapOptionChanged: CustomEvent; mapSourceChanged: CustomEvent; mapOptionsChanged: CustomEvent; // TODO not very clear, why the two options? @@ -23,6 +22,10 @@ interface CustomEventMap { serverStatusUpdated: CustomEvent; mapForceBoxSelect: CustomEvent; coalitionAreaSelected: CustomEvent; + showMapContextMenu: CustomEvent; + hideMapContextMenu: CustomEvent; + showUnitContextMenu: CustomEvent; + hideUnitContextMenu: CustomEvent; } declare global { diff --git a/frontend/react/src/main.tsx b/frontend/react/src/main.tsx index 571c5a25..05667135 100644 --- a/frontend/react/src/main.tsx +++ b/frontend/react/src/main.tsx @@ -1,11 +1,12 @@ /***************** UI *******************/ import React from "react"; import ReactDOM from "react-dom/client"; -import { setupApp } from "./olympusapp.js"; import { UI } from "./ui/ui.js"; import "./index.css"; +window.addEventListener("contextmenu", e => e. preventDefault()); + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/frontend/react/src/map/map.css b/frontend/react/src/map/map.css index 0639722e..5b74dd17 100644 --- a/frontend/react/src/map/map.css +++ b/frontend/react/src/map/map.css @@ -129,4 +129,10 @@ .ol-coalitionarea-label.red { color: #461818; +} + +.ol-target-icon { + background-image: url("/vite/images/markers/target.svg"); + height: 100%; + width: 100%; } \ No newline at end of file diff --git a/frontend/react/src/map/map.ts b/frontend/react/src/map/map.ts index 400573e5..b26e9b1e 100644 --- a/frontend/react/src/map/map.ts +++ b/frontend/react/src/map/map.ts @@ -23,7 +23,7 @@ import { import { CoalitionPolygon } from "./coalitionarea/coalitionpolygon"; import { MapHiddenTypes, MapOptions } from "../types/types"; import { SpawnRequestTable } from "../interfaces"; -import { ContextAction } from "../unit/contextaction"; +import { ContextAction, ContextActionCallback } from "../unit/contextaction"; /* Stylesheets */ import "./markers/stylesheets/airbase.css"; @@ -33,7 +33,7 @@ import "./map.css"; import { CoalitionCircle } from "./coalitionarea/coalitioncircle"; import { initDraggablePath } from "./coalitionarea/draggablepath"; -import { faComputerMouse, faDrawPolygon, faHandPointer, faJetFighter, faMap } from "@fortawesome/free-solid-svg-icons"; +import { faDrawPolygon, faHandPointer, faJetFighter, faMap } from "@fortawesome/free-solid-svg-icons"; /* Register the handler for the box selection */ L.Map.addInitHook("addHandler", "boxSelect", BoxSelect); @@ -103,6 +103,7 @@ export class Map extends L.Map { /* Unit context actions */ #contextAction: null | ContextAction = null; + #defaultContextAction: null | ContextAction = null; /* Unit spawning */ #spawnRequestTable: SpawnRequestTable | null = null; @@ -156,6 +157,9 @@ export class Map extends L.Map { this.on("dblclick", (e: any) => this.#onDoubleClick(e)); this.on("mouseup", (e: any) => this.#onMouseUp(e)); this.on("mousedown", (e: any) => this.#onMouseDown(e)); + this.on("contextmenu", (e: any) => { + e.originalEvent.preventDefault(); + }); this.on("mousemove", (e: any) => this.#onMouseMove(e)); @@ -337,6 +341,7 @@ export class Map extends L.Map { options?: { spawnRequestTable?: SpawnRequestTable; contextAction?: ContextAction | null; + defaultContextAction?: ContextAction | null; } ) { console.log(`Switching from state ${this.#state} to ${state}`); @@ -358,8 +363,11 @@ export class Map extends L.Map { } else if (this.#state === CONTEXT_ACTION) { this.deselectAllCoalitionAreas(); this.#contextAction = options?.contextAction ?? null; + this.#defaultContextAction = options?.defaultContextAction ?? null; console.log(`Context action:`); console.log(this.#contextAction); + console.log(`Default context action callback:`); + console.log(this.#defaultContextAction); } else if (this.#state === COALITIONAREA_DRAW_POLYGON) { getApp().getUnitsManager().deselectAllUnits(); this.#coalitionAreas.push(new CoalitionPolygon([])); @@ -382,7 +390,7 @@ export class Map extends L.Map { if (this.#state === IDLE) { return [ { - actions: [touch ? faHandPointer : faComputerMouse], + actions: [touch ? faHandPointer : "LMB"], target: faJetFighter, text: "Select unit", }, @@ -393,12 +401,12 @@ export class Map extends L.Map { text: "Box selection", } : { - actions: ["Shift", faComputerMouse, "Drag"], + actions: ["Shift", "LMB", "Drag"], target: faMap, text: "Box selection", }, { - actions: [touch ? faHandPointer : faComputerMouse, "Drag"], + actions: [touch ? faHandPointer : "LMB", "Drag"], target: faMap, text: "Move map location", }, @@ -406,17 +414,17 @@ export class Map extends L.Map { } else if (this.#state === SPAWN_UNIT) { return [ { - actions: [touch ? faHandPointer : faComputerMouse], + actions: [touch ? faHandPointer : "LMB"], target: faMap, text: "Spawn unit", }, { - actions: [touch ? faHandPointer : faComputerMouse, touch ? faHandPointer : faComputerMouse], + actions: [touch ? faHandPointer : "LMB", 2], target: faMap, text: "Exit spawn mode", }, { - actions: [touch ? faHandPointer : faComputerMouse, "Drag"], + actions: [touch ? faHandPointer : "LMB", "Drag"], target: faMap, text: "Move map location", }, @@ -424,42 +432,53 @@ export class Map extends L.Map { } else if (this.#state === CONTEXT_ACTION) { let controls = [ { - actions: [touch ? faHandPointer : faComputerMouse, touch ? faHandPointer : faComputerMouse], + actions: [touch ? faHandPointer : "LMB"], target: faMap, text: "Deselect units", }, { - actions: [touch ? faHandPointer : faComputerMouse, "Drag"], + actions: [touch ? faHandPointer : "LMB", "Drag"], target: faMap, text: "Move map location", }, ]; if (this.#contextAction) { - /* TODO: I don't like this approach, it relies on the arguments names of the callback. We should find a better method */ - const args = getFunctionArguments(this.#contextAction.getCallback()); controls.push({ - actions: [touch ? faHandPointer : faComputerMouse], - target: args.includes("targetUnit") ? faJetFighter : faMap, + actions: [touch ? faHandPointer : "LMB"], + target: this.#contextAction.getTarget() === "unit" ? faJetFighter : faMap, text: this.#contextAction?.getLabel() ?? "", }); } + if (!touch && this.#defaultContextAction) { + controls.push({ + actions: ["RMB"], + target: faMap, + text: this.#defaultContextAction?.getLabel() ?? "", + }); + controls.push({ + actions: ["RMB", "hold"], + target: faMap, + text: "Open context menu", + }); + } + return controls; } else if (this.#state === COALITIONAREA_EDIT) { return [ { - actions: [touch ? faHandPointer : faComputerMouse], + actions: [touch ? faHandPointer : "LMB"], target: faDrawPolygon, text: "Select shape", }, { - actions: [touch ? faHandPointer : faComputerMouse, touch ? faHandPointer : faComputerMouse], + actions: [touch ? faHandPointer : "LMB", 2], target: faMap, text: "Exit drawing mode", }, { - actions: [touch ? faHandPointer : faComputerMouse, "Drag"], + actions: [touch ? faHandPointer : "LMB", "Drag"], target: faMap, text: "Move map location", }, @@ -467,17 +486,17 @@ export class Map extends L.Map { } else if (this.#state === COALITIONAREA_DRAW_POLYGON) { return [ { - actions: [touch ? faHandPointer : faComputerMouse], + actions: [touch ? faHandPointer : "LMB"], target: faMap, text: "Add vertex to polygon", }, { - actions: [touch ? faHandPointer : faComputerMouse, touch ? faHandPointer : faComputerMouse], + actions: [touch ? faHandPointer : "LMB", 2], target: faMap, text: "Finalize polygon", }, { - actions: [touch ? faHandPointer : faComputerMouse, "Drag"], + actions: [touch ? faHandPointer : "LMB", "Drag"], target: faMap, text: "Move map location", }, @@ -485,12 +504,12 @@ export class Map extends L.Map { } else if (this.#state === COALITIONAREA_DRAW_CIRCLE) { return [ { - actions: [touch ? faHandPointer : faComputerMouse], + actions: [touch ? faHandPointer : "LMB"], target: faMap, text: "Add circle", }, { - actions: [touch ? faHandPointer : faComputerMouse, "Drag"], + actions: [touch ? faHandPointer : "LMB", "Drag"], target: faMap, text: "Move map location", }, @@ -732,6 +751,18 @@ export class Map extends L.Map { this.#contextAction?.executeCallback(targetUnit, targetPosition); } + getContextAction() { + return this.#contextAction; + } + + executeDefaultContextAction(targetUnit: Unit | null, targetPosition: L.LatLng | null) { + if (this.#defaultContextAction) this.#defaultContextAction.executeCallback(targetUnit, targetPosition); + } + + getDefaultContextAction() { + return this.#defaultContextAction; + } + preventClicks() { console.log("Preventing clicks on map"); window.clearTimeout(this.#shortPressTimer); @@ -779,7 +810,7 @@ export class Map extends L.Map { this.#longPressTimer = window.setTimeout(() => { /* If the mouse is still being pressed, execute the long press action */ if (this.#isMouseDown && !this.#isDragging && !this.#isZooming) this.#onLongPress(e); - }, 500); + }, 350); } #onDoubleClick(e: any) { @@ -796,17 +827,20 @@ export class Map extends L.Map { } #onShortPress(e: any) { - let touchLocation: L.LatLng; - if (e.type === "touchstart") touchLocation = this.containerPointToLatLng(this.mouseEventToContainerPoint(e.touches[0])); - else touchLocation = new L.LatLng(e.latlng.lat, e.latlng.lng); + let pressLocation: L.LatLng; + if (e.type === "touchstart") pressLocation = this.containerPointToLatLng(this.mouseEventToContainerPoint(e.touches[0])); + else pressLocation = new L.LatLng(e.latlng.lat, e.latlng.lng); - console.log(`Short press at ${touchLocation}`); + console.log(`Short press at ${pressLocation}`); + + document.dispatchEvent(new CustomEvent("hideMapContextMenu")); + document.dispatchEvent(new CustomEvent("hideUnitContextMenu")); /* Execute the short click action */ if (this.#state === IDLE) { } else if (this.#state === SPAWN_UNIT) { if (this.#spawnRequestTable !== null) { - this.#spawnRequestTable.unit.location = touchLocation; + this.#spawnRequestTable.unit.location = pressLocation; getApp() .getUnitsManager() .spawnUnits( @@ -817,25 +851,25 @@ export class Map extends L.Map { undefined, undefined, (hash) => { - this.addTemporaryMarker(touchLocation, this.#spawnRequestTable?.unit.unitType ?? "unknown", this.#spawnRequestTable?.coalition ?? "blue", hash); + this.addTemporaryMarker(pressLocation, this.#spawnRequestTable?.unit.unitType ?? "unknown", this.#spawnRequestTable?.coalition ?? "blue", hash); } ); } } else if (this.#state === COALITIONAREA_DRAW_POLYGON) { const selectedArea = this.getSelectedCoalitionArea(); if (selectedArea && selectedArea instanceof CoalitionPolygon) { - selectedArea.addTemporaryLatLng(touchLocation); + selectedArea.addTemporaryLatLng(pressLocation); } } else if (this.#state === COALITIONAREA_DRAW_CIRCLE) { const selectedArea = this.getSelectedCoalitionArea(); if (selectedArea && selectedArea instanceof CoalitionCircle) { - if (selectedArea.getLatLng().lat == 0 && selectedArea.getLatLng().lng == 0) selectedArea.setLatLng(touchLocation); + if (selectedArea.getLatLng().lat == 0 && selectedArea.getLatLng().lng == 0) selectedArea.setLatLng(pressLocation); this.setState(COALITIONAREA_EDIT); } } else if (this.#state == COALITIONAREA_EDIT) { this.deselectAllCoalitionAreas(); for (let idx = 0; idx < this.#coalitionAreas.length; idx++) { - if (areaContains(touchLocation, this.#coalitionAreas[idx])) { + if (areaContains(pressLocation, this.#coalitionAreas[idx])) { this.#coalitionAreas[idx].setSelected(true); document.dispatchEvent( new CustomEvent("coalitionAreaSelected", { @@ -846,23 +880,35 @@ export class Map extends L.Map { } } } else if (this.#state === CONTEXT_ACTION) { - this.executeContextAction(null, touchLocation); + if (e.originalEvent.buttons === 1) { + if (this.#contextAction !== null) this.executeContextAction(null, pressLocation); + else this.setState(IDLE); + } else if (e.originalEvent.buttons === 2) { + if (this.#defaultContextAction !== null) this.executeDefaultContextAction(null, pressLocation); + } } else { } } #onLongPress(e: any) { - let touchLocation: L.LatLng; - if (e.type === "touchstart") touchLocation = this.containerPointToLatLng(this.mouseEventToContainerPoint(e.touches[0])); - else touchLocation = new L.LatLng(e.latlng.lat, e.latlng.lng); + let pressLocation: L.LatLng; + if (e.type === "touchstart") pressLocation = this.containerPointToLatLng(this.mouseEventToContainerPoint(e.touches[0])); + else pressLocation = new L.LatLng(e.latlng.lat, e.latlng.lng); - console.log(`Long press at ${touchLocation}`); + console.log(`Long press at ${pressLocation}`); if (!this.#isDragging && !this.#isZooming) { this.deselectAllCoalitionAreas(); if (this.#state === IDLE) { if (e.type === "touchstart") document.dispatchEvent(new CustomEvent("mapForceBoxSelect", { detail: e })); else document.dispatchEvent(new CustomEvent("mapForceBoxSelect", { detail: e.originalEvent })); + } else if (this.#state === CONTEXT_ACTION) { + if (e.originalEvent.button === 2) { + document.dispatchEvent(new CustomEvent("showMapContextMenu", { detail: e })); + } else { + if (e.type === "touchstart") document.dispatchEvent(new CustomEvent("mapForceBoxSelect", { detail: e })); + else document.dispatchEvent(new CustomEvent("mapForceBoxSelect", { detail: e.originalEvent })); + } } } } diff --git a/frontend/react/src/server/servermanager.ts b/frontend/react/src/server/servermanager.ts index 1d169684..d6779888 100644 --- a/frontend/react/src/server/servermanager.ts +++ b/frontend/react/src/server/servermanager.ts @@ -141,7 +141,10 @@ export class ServerManager { } setAddress(address: string) { - this.#REST_ADDRESS = `${address.replace("vite/", "")}olympus`; + this.#REST_ADDRESS = `${address.replace("vite/", "").replace("vite", "")}olympus`; + // TODO: TEMPORARY FOR DEBUGGING + // this.#REST_ADDRESS = `https://refugees.dcsolympus.com/olympus`; + console.log(`Setting REST address to ${this.#REST_ADDRESS}`); } diff --git a/frontend/react/src/statecontext.tsx b/frontend/react/src/statecontext.tsx index ed76597f..9281bf2f 100644 --- a/frontend/react/src/statecontext.tsx +++ b/frontend/react/src/statecontext.tsx @@ -1,5 +1,5 @@ import { createContext } from "react"; -import { MAP_HIDDEN_TYPES_DEFAULTS, MAP_OPTIONS_DEFAULTS } from "./constants/constants"; +import { IDLE, MAP_HIDDEN_TYPES_DEFAULTS, MAP_OPTIONS_DEFAULTS } from "./constants/constants"; export const StateContext = createContext({ mainMenuVisible: false, @@ -12,7 +12,7 @@ export const StateContext = createContext({ mapOptions: MAP_OPTIONS_DEFAULTS, mapSources: [] as string[], activeMapSource: "", - mapBoxSelection: false, + mapState: IDLE }); export const StateProvider = StateContext.Provider; diff --git a/frontend/react/src/ui/components/oldropdown.tsx b/frontend/react/src/ui/components/oldropdown.tsx index a26a0724..2768949e 100644 --- a/frontend/react/src/ui/components/oldropdown.tsx +++ b/frontend/react/src/ui/components/oldropdown.tsx @@ -103,7 +103,12 @@ export function OlDropdown(props: { `} type="button" > - {props.leftIcon && } + {props.leftIcon && ( + + )} {props.label ?? ""} void; + className?: string; + children?: string | JSX.Element | JSX.Element[]; +}) { return (
+
+ {Object.values(contextActionsSet.getContextActions(latLng? "position": "unit")).map((contextAction) => { + return ( + { + if (contextAction.getOptions().executeImmediately) { + contextAction.executeCallback(null, null); + } else { + if (latLng !== null) { + contextAction.executeCallback(null, latLng); + setOpen(false); + } else if (unit !== null) { + contextAction.executeCallback(unit, null); + setOpen(false); + } + } + }} + > + +
{contextAction.getLabel()}
+
+ ); + })} +
+
+ + )} + + ); +} diff --git a/frontend/react/src/ui/panels/controls.tsx b/frontend/react/src/ui/panels/controls.tsx index d6132779..ec64ef1d 100644 --- a/frontend/react/src/ui/panels/controls.tsx +++ b/frontend/react/src/ui/panels/controls.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; export function ControlsPanel(props: {}) { const [controls, setControls] = useState( [] as { - actions: (string | IconDefinition)[]; + actions: (string | number | IconDefinition)[]; target: IconDefinition; text: string; }[] @@ -17,9 +17,11 @@ export function ControlsPanel(props: {}) { } }); - document.addEventListener("mapStateChanged", (ev) => { - setControls(getApp().getMap().getCurrentControls()); - }); + useEffect(() => { + document.addEventListener("mapStateChanged", (ev) => { + setControls(getApp().getMap().getCurrentControls()); + }); + }, []); return (
- {typeof action === "string" ? ( - action - ) : ( - - )} + {typeof action === "string" || typeof action === "number" ? action : }
- {idx < control.actions.length - 1 &&
+
} + {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/drawingmenu.tsx b/frontend/react/src/ui/panels/drawingmenu.tsx index d78f7f83..cb1271c1 100644 --- a/frontend/react/src/ui/panels/drawingmenu.tsx +++ b/frontend/react/src/ui/panels/drawingmenu.tsx @@ -39,26 +39,26 @@ export function DrawingMenu(props: { open: boolean; onClose: () => void }) { /* Align the state of the coalition toggle to the coalition of the area */ if (activeCoalitionArea && activeCoalitionArea?.getCoalition() !== areaCoalition) setAreaCoalition(activeCoalitionArea?.getCoalition()); - }); - useEffect(() => { if (!props.open) { if ([COALITIONAREA_EDIT, COALITIONAREA_DRAW_CIRCLE, COALITIONAREA_DRAW_POLYGON].includes(getApp()?.getMap()?.getState())) getApp().getMap().setState(IDLE); } }); - document.addEventListener("mapStateChanged", (event: any) => { - if (drawingPolygon && getApp().getMap().getState() !== COALITIONAREA_DRAW_POLYGON) setDrawingPolygon(false); + useEffect(() => { + document.addEventListener("mapStateChanged", (event: any) => { + if (drawingPolygon && getApp().getMap().getState() !== COALITIONAREA_DRAW_POLYGON) setDrawingPolygon(false); - if (getApp().getMap().getState() == COALITIONAREA_EDIT) { - setActiveCoalitionArea(getApp().getMap().getSelectedCoalitionArea() ?? null); - } - }); + if (getApp().getMap().getState() == COALITIONAREA_EDIT) { + setActiveCoalitionArea(getApp().getMap().getSelectedCoalitionArea() ?? null); + } + }); - document.addEventListener("coalitionAreaSelected", (event: any) => { - setActiveCoalitionArea(event.detail); - }); + document.addEventListener("coalitionAreaSelected", (event: any) => { + setActiveCoalitionArea(event.detail); + }); + }, []); return ( void }) { setTypesSelection(JSON.parse(JSON.stringify(typesSelection))); }} /> - {type} +
{type}
); })} @@ -256,7 +256,7 @@ export function DrawingMenu(props: { open: boolean; onClose: () => void }) { setErasSelection(JSON.parse(JSON.stringify(erasSelection))); }} /> - {era} +
{era}
); })} @@ -277,7 +277,7 @@ export function DrawingMenu(props: { open: boolean; onClose: () => void }) { setErasSelection(JSON.parse(JSON.stringify(rangesSelection))); }} /> - {range} +
{range}
); })} diff --git a/frontend/react/src/ui/panels/header.tsx b/frontend/react/src/ui/panels/header.tsx index 0603faf0..4a10d6ad 100644 --- a/frontend/react/src/ui/panels/header.tsx +++ b/frontend/react/src/ui/panels/header.tsx @@ -27,9 +27,8 @@ export function Header() { /* Initialize the "scroll" position of the element */ var scrollRef = useRef(null); useEffect(() => { - if (scrollRef.current) { + if (scrollRef.current) onScroll(scrollRef.current); - } }); function onScroll(el) { @@ -56,10 +55,8 @@ export function Header() { `} > {!scrolledLeft && ( { - const detail = (ev as CustomEvent).detail; - setFrameRate(detail.frameRate); - setLoad(detail.load); - setElapsedTime(detail.elapsedTime); - setMissionTime(detail.missionTime); - setConnected(detail.connected); - setPaused(detail.paused); - }); + useEffect(() => { + document.addEventListener("serverStatusUpdated", (ev) => { + const detail = (ev as CustomEvent).detail; + setFrameRate(detail.frameRate); + setLoad(detail.load); + setElapsedTime(detail.elapsedTime); + setMissionTime(detail.missionTime); + setConnected(detail.connected); + setPaused(detail.paused); + }); + }, []); // A bit of a hack to set the rounded borders to the minimap useEffect(() => { diff --git a/frontend/react/src/ui/panels/sidebar.tsx b/frontend/react/src/ui/panels/sidebar.tsx index 31249a82..58e856ca 100644 --- a/frontend/react/src/ui/panels/sidebar.tsx +++ b/frontend/react/src/ui/panels/sidebar.tsx @@ -1,8 +1,9 @@ import React, { useState } from "react"; import { OlStateButton } from "../components/olstatebutton"; -import { faGamepad, faRuler, faPencil, faEllipsisV, faCog, faQuestionCircle, faPlusSquare } from "@fortawesome/free-solid-svg-icons"; +import { faGamepad, faRuler, faPencil, faEllipsisV, faCog, faQuestionCircle, faPlusSquare, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { EventsConsumer } from "../../eventscontext"; import { StateConsumer } from "../../statecontext"; +import { IDLE } from "../../constants/constants"; export function SideBar() { return ( @@ -12,7 +13,7 @@ export function SideBar() { {(events) => (
); @@ -1337,7 +1340,7 @@ export function UnitControlMenu(props: { open: boolean; onClose: () => void }) {
{ - if (scrollRef.current) { + if (scrollRef.current) onScroll(scrollRef.current); - } }); - /* When a unit is selected, open the menu */ - document.addEventListener("unitsSelection", (ev: CustomEventInit) => { - setOpen(true); - setSelectedUnits(ev.detail as Unit[]); + useEffect(() => { + /* When a unit is selected, open the menu */ + document.addEventListener("unitsSelection", (ev: CustomEventInit) => { + setOpen(true); + updateData(); + setActiveContextAction(null); + }); - updateData(); - }); + /* When a unit is deselected, refresh the view */ + document.addEventListener("unitDeselection", (ev: CustomEventInit) => { + window.setTimeout(() => updateData(), 200); + }); - /* When a unit is deselected, refresh the view */ - document.addEventListener("unitDeselection", (ev: CustomEventInit) => { - /* TODO add delay to avoid doing it too many times */ - updateData(); - }); + /* When all units are deselected clean the view */ + document.addEventListener("clearSelection", () => { + setOpen(false); + updateData(); + }); - /* When all units are deselected clean the view */ - document.addEventListener("clearSelection", () => { - setOpen(false); - setSelectedUnits([]); - updateData(); - }); - - /* Deselect the context action when exiting state */ - document.addEventListener("mapStateChanged", (ev) => { - setOpen((ev as CustomEvent).detail === CONTEXT_ACTION); - }); + /* Deselect the context action when exiting state */ + document.addEventListener("mapStateChanged", (ev) => { + setOpen((ev as CustomEvent).detail === CONTEXT_ACTION); + }); + }, []); /* Update the current values of the shown data */ function updateData() { @@ -62,7 +59,7 @@ export function UnitMouseControlBar(props: {}) { }); setContextActionsSet(newContextActionSet); - setActiveContextAction(null); + return newContextActionSet; } function onScroll(el) { @@ -78,7 +75,7 @@ export function UnitMouseControlBar(props: {}) { return ( <> - {open && ( + {open && Object.keys(contextActionsSet.getContextActions()).length > 0 && ( <>
{ - setMapHiddenTypes({ ...getApp().getMap().getHiddenTypes() }); - }); + useEffect(() => { + document.addEventListener("hiddenTypesChanged", (ev) => { + setMapHiddenTypes({ ...getApp().getMap().getHiddenTypes() }); + }); - document.addEventListener("mapOptionsChanged", (ev) => { - setMapOptions({ ...getApp().getMap().getOptions() }); - }); + document.addEventListener("mapOptionsChanged", (ev) => { + setMapOptions({ ...getApp().getMap().getOptions() }); + }); - document.addEventListener("mapStateChanged", (ev) => { - if ((ev as CustomEvent).detail === IDLE && mapState !== IDLE) hideAllMenus(); + document.addEventListener("mapStateChanged", (ev) => { + if ((ev as CustomEvent).detail === IDLE) hideAllMenus(); + else if ((ev as CustomEvent).detail === CONTEXT_ACTION) setUnitControlMenuVisible(true); + setMapState(String((ev as CustomEvent).detail)); + }); - setMapState(String((ev as CustomEvent).detail)); - }); + document.addEventListener("mapSourceChanged", (ev) => { + var source = (ev as CustomEvent).detail; + setActiveMapSource(source); + }); - document.addEventListener("mapSourceChanged", (ev) => { - var source = (ev as CustomEvent).detail; - if (source !== activeMapSource) setActiveMapSource(source); - }); - - document.addEventListener("configLoaded", (ev) => { - let config = getApp().getConfig(); - var sources = Object.keys(config.mapMirrors).concat(Object.keys(config.mapLayers)); - setMapSources(sources); - setActiveMapSource(sources[0]); - }); - - document.addEventListener("mapForceBoxSelect", (ev) => { - setMapBoxSelection(true); - }); - - document.addEventListener("mapSelectionEnd", (ev) => { - setMapBoxSelection(false); - }); + document.addEventListener("configLoaded", (ev) => { + let config = getApp().getConfig(); + var sources = Object.keys(config.mapMirrors).concat(Object.keys(config.mapLayers)); + setMapSources(sources); + setActiveMapSource(sources[0]); + }); + }, []); function hideAllMenus() { setMainMenuVisible(false); @@ -132,7 +126,8 @@ export function UI() { return (
@@ -148,7 +143,7 @@ export function UI() { mapHiddenTypes: mapHiddenTypes, mapSources: mapSources, activeMapSource: activeMapSource, - mapBoxSelection: mapBoxSelection, + mapState: mapState, }} > -
-
-
- {loginModalVisible && ( - <> -
- { - checkPassword(password); - }} - onContinue={(username) => { - connect(username); - }} - onBack={() => { - setCommandMode(null); - }} - checkingPassword={checkingPassword} - loginError={loginError} - commandMode={commandMode} - /> - - )} -
- setMainMenuVisible(false)} /> - setSpawnMenuVisible(false)} /> - setOptionsMenuVisible(false)} options={mapOptions} /> - - - setUnitControlMenuVisible(false)} /> - setDrawingMenuVisible(false)} /> +
+
+ {loginModalVisible && ( + <> +
+ { + checkPassword(password); + }} + onContinue={(username) => { + connect(username); + }} + onBack={() => { + setCommandMode(null); + }} + checkingPassword={checkingPassword} + loginError={loginError} + commandMode={commandMode} + /> + + )} +
+ setMainMenuVisible(false)} /> + setSpawnMenuVisible(false)} /> + setOptionsMenuVisible(false)} options={mapOptions} /> + setUnitControlMenuVisible(false)} /> + setDrawingMenuVisible(false)} /> - - -
+ + + + +
diff --git a/frontend/react/src/unit/contextaction.ts b/frontend/react/src/unit/contextaction.ts index 1d2c9644..7848de78 100644 --- a/frontend/react/src/unit/contextaction.ts +++ b/frontend/react/src/unit/contextaction.ts @@ -16,11 +16,13 @@ export class ContextAction { #units: Unit[] = []; #icon: IconDefinition; #options: ContextActionOptions; + #target: "unit" | "position" | null = null; - constructor(id: string, label: string, description: string, icon: IconDefinition, callback: ContextActionCallback, options: ContextActionOptions) { + constructor(id: string, label: string, description: string, icon: IconDefinition, target: "unit" | "position" | null, callback: ContextActionCallback, options: ContextActionOptions) { this.#id = id; this.#label = label; this.#description = description; + this.#target = target; this.#callback = callback; this.#icon = icon; this.#options = { @@ -57,6 +59,10 @@ export class ContextAction { return this.#icon; } + getTarget() { + return this.#target; + } + executeCallback(targetUnit: Unit | null, targetPosition: LatLng | null) { if (this.#callback) this.#callback(this.#units, targetUnit, targetPosition); } diff --git a/frontend/react/src/unit/contextactionset.ts b/frontend/react/src/unit/contextactionset.ts index ea57aa16..91eff67a 100644 --- a/frontend/react/src/unit/contextactionset.ts +++ b/frontend/react/src/unit/contextactionset.ts @@ -4,6 +4,7 @@ import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; export class ContextActionSet { #contextActions: { [key: string]: ContextAction } = {}; + #defaultContextAction: ContextAction | null = null; addContextAction( unit: Unit, @@ -11,18 +12,44 @@ export class ContextActionSet { label: string, description: string, icon: IconDefinition, + target: "unit" | "position" | null, callback: ContextActionCallback, options?: ContextActionOptions ) { options = options || {}; if (!(id in this.#contextActions)) { - this.#contextActions[id] = new ContextAction(id, label, description, icon, callback, options); + this.#contextActions[id] = new ContextAction(id, label, description, icon, target, callback, options); } this.#contextActions[id].addUnit(unit); } - getContextActions() { - return this.#contextActions; + getContextActions(targetFilter?: string) { + if (targetFilter) { + var filteredContextActionSet = new ContextActionSet(); + Object.keys(this.#contextActions).forEach((key) => { + if (this.#contextActions[key].getTarget() === targetFilter) filteredContextActionSet[key] = this.#contextActions[key]; + }); + return filteredContextActionSet; + } else return this.#contextActions; + } + + addDefaultContextAction( + unit: Unit, + id: string, + label: string, + description: string, + icon: IconDefinition, + target: "unit" | "position" | null, + callback: ContextActionCallback, + options?: ContextActionOptions + ) { + options = options || {}; + if (this.#defaultContextAction === null) this.#defaultContextAction = new ContextAction(id, label, description, icon, target, callback, options); + this.#defaultContextAction.addUnit(unit); + } + + getDefaultContextAction() { + return this.#defaultContextAction; } } diff --git a/frontend/react/src/unit/unit.ts b/frontend/react/src/unit/unit.ts index 53980782..cd9b7ee0 100644 --- a/frontend/react/src/unit/unit.ts +++ b/frontend/react/src/unit/unit.ts @@ -1,4 +1,4 @@ -import { Marker, LatLng, Polyline, Icon, DivIcon, CircleMarker, Map, Point } from "leaflet"; +import { Marker, LatLng, Polyline, Icon, DivIcon, CircleMarker, Map, Point, LeafletMouseEvent, DomEvent, DomUtil } from "leaflet"; import { getApp } from "../olympusapp"; import { enumToCoalition, @@ -77,6 +77,7 @@ import { faXmarksLines, } from "@fortawesome/free-solid-svg-icons"; import { FaXmarksLines } from "react-icons/fa6"; +import { ContextAction } from "./contextaction"; var pathIcon = new Icon({ iconUrl: "/vite/images/markers/marker-icon.png", @@ -160,7 +161,6 @@ export abstract class Unit extends CustomMarker { #selected: boolean = false; #hidden: boolean = false; #highlighted: boolean = false; - #waitingForDoubleClick: boolean = false; #pathMarkers: Marker[] = []; #pathPolyline: Polyline; #contactsPolylines: Polyline[] = []; @@ -169,10 +169,16 @@ export abstract class Unit extends CustomMarker { #miniMapMarker: CircleMarker | null = null; #targetPositionMarker: TargetMarker; #targetPositionPolyline: Polyline; - #doubleClickTimer: number = 0; #hotgroup: number | null = null; #detectionMethods: number[] = []; + /* Inputs timers */ + #mouseCooldownTimer: number = 0; + #shortPressTimer: number = 0; + #longPressTimer: number = 0; + #isMouseOnCooldown: boolean = false; + #isMouseDown: boolean = false; + /* Getters for backend driven data */ getAlive() { return this.#alive; @@ -315,7 +321,7 @@ export abstract class Unit extends CustomMarker { } constructor(ID: number) { - super(new LatLng(0, 0), { riseOnHover: true, keyboard: false }); + super(new LatLng(0, 0), { riseOnHover: true, keyboard: false, bubblingMouseEvents: false }); this.ID = ID; @@ -353,7 +359,8 @@ export abstract class Unit extends CustomMarker { }); /* Leaflet events listeners */ - this.on("click", (e) => this.#onClick(e)); + this.on("mousedown", (e) => this.#onMouseDown(e)); + this.on("mouseup", (e) => this.#onMouseUp(e)); this.on("dblclick", (e) => this.#onDoubleClick(e)); this.on("mouseover", () => { if (this.belongsToCommandedCoalition()) { @@ -802,7 +809,7 @@ export abstract class Unit extends CustomMarker { return this.getDatabase()?.getSpawnPointsByName(this.getName()); } - getDatabaseEntry() { + getBlueprint() { return this.getDatabase()?.getByName(this.#name) ?? this.getDatabase()?.getUnkownUnit(this.getName()); } @@ -824,9 +831,10 @@ export abstract class Unit extends CustomMarker { "Set destination", "Click on the map to move the units there", faLocationDot, + "position", (units: Unit[], _, targetPosition) => { getApp().getUnitsManager().clearDestinations(units); - if (targetPosition) getApp().getUnitsManager().addDestination(targetPosition, false, 0); + if (targetPosition) getApp().getUnitsManager().addDestination(targetPosition, false, 0, units); } ); @@ -836,10 +844,26 @@ export abstract class Unit extends CustomMarker { "Append destination", "Click on the map to add a destination to the path", faRoute, + "position", (units: Unit[], _, targetPosition) => { if (targetPosition) getApp().getUnitsManager().addDestination(targetPosition, false, 0, units); } ); + + contextActionSet.addDefaultContextAction( + this, + "default", + "Set destination", + "", + faRoute, + null, + (units: Unit[], targetUnit, targetPosition) => { + if (targetPosition) { + getApp().getUnitsManager().clearDestinations(units); + getApp().getUnitsManager().addDestination(targetPosition, false, 0, units); + } + } + ) } drawLines() { @@ -909,7 +933,7 @@ export abstract class Unit extends CustomMarker { /* If a unit does not belong to the commanded coalition or it is not visually detected, show it with the generic aircraft square */ var marker; if (this.belongsToCommandedCoalition() || this.getDetectionMethods().some((value) => [VISUAL, OPTIC].includes(value))) - marker = this.getDatabaseEntry()?.markerFile ?? this.getDefaultMarker(); + marker = this.getBlueprint()?.markerFile ?? this.getDefaultMarker(); else marker = "aircraft"; img.src = `/vite/images/units/${marker}.svg`; img.onload = () => SVGInjector(img); @@ -930,7 +954,7 @@ export abstract class Unit extends CustomMarker { if (iconOptions.showShortLabel) { var shortLabel = document.createElement("div"); shortLabel.classList.add("unit-short-label"); - shortLabel.innerText = this.getDatabaseEntry()?.shortLabel || ""; + shortLabel.innerText = this.getBlueprint()?.shortLabel || ""; el.append(shortLabel); } @@ -1068,7 +1092,7 @@ export abstract class Unit extends CustomMarker { canFulfillRole(roles: string | string[]) { if (typeof roles === "string") roles = [roles]; - var loadouts = this.getDatabaseEntry()?.loadouts; + var loadouts = this.getBlueprint()?.loadouts; if (loadouts) { return loadouts.some((loadout: LoadoutBlueprint) => { return (roles as string[]).some((role: string) => { @@ -1083,19 +1107,19 @@ export abstract class Unit extends CustomMarker { } canTargetPoint() { - return this.getDatabaseEntry()?.canTargetPoint === true; + return this.getBlueprint()?.canTargetPoint === true; } canRearm() { - return this.getDatabaseEntry()?.canRearm === true; + return this.getBlueprint()?.canRearm === true; } canAAA() { - return this.getDatabaseEntry()?.canAAA === true; + return this.getBlueprint()?.canAAA === true; } isIndirectFire() { - return this.getDatabaseEntry()?.indirectFire === true; + return this.getBlueprint()?.indirectFire === true; } isTanker() { @@ -1282,13 +1306,13 @@ export abstract class Unit extends CustomMarker { var contextActionSet = new ContextActionSet(); // TODO FIX - contextActionSet.addContextAction(this, "trail", "Trail", "Follow unit in trail formation", olButtonsContextTrail, () => + contextActionSet.addContextAction(this, "trail", "Trail", "Follow unit in trail formation", olButtonsContextTrail, null, () => this.applyFollowOptions("trail", units) ); - contextActionSet.addContextAction(this, "echelon-lh", "Echelon (LH)", "Follow unit in echelon left formation", olButtonsContextEchelonLh, () => + contextActionSet.addContextAction(this, "echelon-lh", "Echelon (LH)", "Follow unit in echelon left formation", olButtonsContextEchelonLh, null, () => this.applyFollowOptions("echelon-lh", units) ); - contextActionSet.addContextAction(this, "echelon-rh", "Echelon (RH)", "Follow unit in echelon right formation", olButtonsContextEchelonRh, () => + contextActionSet.addContextAction(this, "echelon-rh", "Echelon (RH)", "Follow unit in echelon right formation", olButtonsContextEchelonRh, null, () => this.applyFollowOptions("echelon-rh", units) ); contextActionSet.addContextAction( @@ -1297,7 +1321,7 @@ export abstract class Unit extends CustomMarker { "Line abreast (LH)", "Follow unit in line abreast left formation", olButtonsContextLineAbreast, - () => this.applyFollowOptions("line-abreast-lh", units) + null, () => this.applyFollowOptions("line-abreast-lh", units) ); contextActionSet.addContextAction( this, @@ -1305,13 +1329,13 @@ export abstract class Unit extends CustomMarker { "Line abreast (RH)", "Follow unit in line abreast right formation", olButtonsContextLineAbreast, - () => this.applyFollowOptions("line-abreast-rh", units) + null, () => this.applyFollowOptions("line-abreast-rh", units) ); - contextActionSet.addContextAction(this, "front", "Front", "Fly in front of unit", olButtonsContextFront, () => this.applyFollowOptions("front", units)); - contextActionSet.addContextAction(this, "diamond", "Diamond", "Follow unit in diamond formation", olButtonsContextDiamond, () => + contextActionSet.addContextAction(this, "front", "Front", "Fly in front of unit", olButtonsContextFront, null, () => this.applyFollowOptions("front", units)); + contextActionSet.addContextAction(this, "diamond", "Diamond", "Follow unit in diamond formation", olButtonsContextDiamond, null, () => this.applyFollowOptions("diamond", units) ); - contextActionSet.addContextAction(this, "custom", "Custom", "Set a custom formation position", faExclamation, () => + contextActionSet.addContextAction(this, "custom", "Custom", "Set a custom formation position", faExclamation, null, () => this.applyFollowOptions("custom", units) ); } @@ -1349,35 +1373,73 @@ export abstract class Unit extends CustomMarker { } /***********************************************/ - #onClick(e: any) { - /* Exit if we were waiting for a doubleclick */ - if (this.#waitingForDoubleClick) { - return; - } + #onMouseUp(e: any) { + this.#isMouseDown = false; - /* We'll wait for a doubleclick */ - this.#waitingForDoubleClick = true; - this.#doubleClickTimer = window.setTimeout(() => { - /* Still waiting so no doubleclick; do the click action */ - if (this.#waitingForDoubleClick) { - if (getApp().getMap().getState() === IDLE || e.originalEvent.ctrlKey) { - if (!e.originalEvent.ctrlKey) getApp().getUnitsManager().deselectAllUnits(); + DomEvent.stop(e); + DomEvent.preventDefault(e); + e.originalEvent.stopImmediatePropagation(); - this.setSelected(!this.getSelected()); - } else if (getApp().getMap().getState() === CONTEXT_ACTION) { - getApp().getMap().executeContextAction(this, null); - } - } + e.originalEvent.stopPropagation(); - /* No longer waiting for a doubleclick */ - this.#waitingForDoubleClick = false; + window.clearTimeout(this.#longPressTimer); + + this.#isMouseOnCooldown = true; + this.#mouseCooldownTimer = window.setTimeout(() => { + this.#isMouseOnCooldown = false; }, 200); } + #onMouseDown(e: any) { + this.#isMouseDown = true; + + DomEvent.stop(e); + DomEvent.preventDefault(e); + e.originalEvent.stopImmediatePropagation(); + + if (this.#isMouseOnCooldown) { + return; + } + + this.#shortPressTimer = window.setTimeout(() => { + /* If the mouse is no longer being pressed, execute the short press action */ + if (!this.#isMouseDown) this.#onShortPress(e); + }, 200); + + this.#longPressTimer = window.setTimeout(() => { + /* If the mouse is still being pressed, execute the long press action */ + if (this.#isMouseDown) this.#onLongPress(e); + }, 350); + } + + #onShortPress(e: LeafletMouseEvent) { + console.log(`Short press on ${this.getUnitName()}`); + + if (getApp().getMap().getState() === IDLE || e.originalEvent.ctrlKey) { + if (!e.originalEvent.ctrlKey) getApp().getUnitsManager().deselectAllUnits(); + this.setSelected(!this.getSelected()); + } else if (getApp().getMap().getState() === CONTEXT_ACTION) { + if (getApp().getMap().getContextAction()) getApp().getMap().executeContextAction(this, null); + else { + getApp().getUnitsManager().deselectAllUnits(); + this.setSelected(!this.getSelected()); + } + } + } + + #onLongPress(e: any) { + console.log(`Long press on ${this.getUnitName()}`); + + if (e.originalEvent.button === 2) { + document.dispatchEvent(new CustomEvent("showUnitContextMenu", { detail: e })); + } + } + #onDoubleClick(e: any) { - /* Let single clicks work again */ - this.#waitingForDoubleClick = false; - clearTimeout(this.#doubleClickTimer); + console.log(`Double click on ${this.getUnitName()}`); + + window.clearTimeout(this.#shortPressTimer); + window.clearTimeout(this.#longPressTimer); /* Select all matching units in the viewport */ const unitsManager = getApp().getUnitsManager(); @@ -1790,6 +1852,7 @@ export abstract class AirUnit extends Unit { "Refuel at tanker", "Refuel units at the nearest AAR Tanker. If no tanker is available the unit will RTB", olButtonsContextRefuel, + null, (units: Unit[]) => { getApp().getUnitsManager().refuel(units); }, @@ -1801,19 +1864,21 @@ export abstract class AirUnit extends Unit { "Center map", "Center the map on the unit and follow it", faMapLocation, + null, (units: Unit[]) => { getApp().getMap().centerOnUnit(units[0]); }, { executeImmediately: true } ); - /* Context actions with a target unit */ + /* Context actions that require a target unit */ contextActionSet.addContextAction( this, "attack", "Attack unit", "Click on a unit to attack it using A/A or A/G weapons", olButtonsContextAttack, + "unit", (units: Unit[], targetUnit: Unit | null, _) => { if (targetUnit) getApp().getUnitsManager().attackUnit(targetUnit.ID, units); } @@ -1824,18 +1889,20 @@ export abstract class AirUnit extends Unit { "Follow unit", "Click on a unit to follow it in formation", olButtonsContextFollow, + "unit", (units: Unit[], targetUnit: Unit | null, _) => { if (targetUnit) targetUnit.showFollowOptions(units); } ); - /* Context actions with a target position */ + /* Context actions that require a target position */ contextActionSet.addContextAction( this, "bomb", "Precision bomb location", "Click on a point to execute a precision bombing attack", faLocationCrosshairs, + "position", (units: Unit[], _, targetPosition: LatLng | null) => { if (targetPosition) getApp().getUnitsManager().bombPoint(targetPosition, units); } @@ -1846,6 +1913,7 @@ export abstract class AirUnit extends Unit { "Carpet bomb location", "Click on a point to execute a carpet bombing attack", faXmarksLines, + "position", (units: Unit[], _, targetPosition: LatLng | null) => { if (targetPosition) getApp().getUnitsManager().carpetBomb(targetPosition, units); } @@ -1892,6 +1960,7 @@ export class Helicopter extends AirUnit { "Land at location", "Click on a point to land there", olButtonsContextLandAtPoint, + "position", (units: Unit[], _, targetPosition: LatLng | null) => { if (targetPosition) getApp().getUnitsManager().landAtPoint(targetPosition, units); } @@ -1920,7 +1989,7 @@ export class GroundUnit extends Unit { showHealth: true, showHotgroup: belongsToCommandedCoalition, showUnitIcon: belongsToCommandedCoalition || this.getDetectionMethods().some((value) => [VISUAL, OPTIC, RADAR, IRST, DLINK].includes(value)), - showShortLabel: this.getDatabaseEntry()?.type === "SAM Site", + showShortLabel: this.getBlueprint()?.type === "SAM Site", showFuel: false, showAmmo: false, showSummary: false, @@ -1939,6 +2008,7 @@ export class GroundUnit extends Unit { "Group ground units", "Create a group of ground units", faPeopleGroup, + null, (units: Unit[], _1, _2) => { getApp().getUnitsManager().createGroup(units); }, @@ -1950,6 +2020,7 @@ export class GroundUnit extends Unit { "Center map", "Center the map on the unit and follow it", faMapLocation, + null, (units: Unit[]) => { getApp().getMap().centerOnUnit(units[0]); }, @@ -1963,6 +2034,7 @@ export class GroundUnit extends Unit { "Scenic AAA", "Shoot AAA in the air without aiming at any target, when an enemy unit gets close enough. WARNING: works correctly only on neutral units, blue or red units will aim", olButtonsContextScenicAaa, + null, (units: Unit[]) => { getApp().getUnitsManager().scenicAAA(units); }, @@ -1974,6 +2046,7 @@ export class GroundUnit extends Unit { "Dynamic accuracy AAA", "Shoot AAA towards the closest enemy unit, but don't aim precisely. WARNING: works correctly only on neutral units, blue or red units will aim", olButtonsContextMissOnPurpose, + null, (units: Unit[]) => { getApp().getUnitsManager().missOnPurpose(units); }, @@ -1988,6 +2061,7 @@ export class GroundUnit extends Unit { "Attack unit", "Click on a unit to attack it", olButtonsContextAttack, + "unit", (units: Unit[], targetUnit: Unit | null, _) => { if (targetUnit) getApp().getUnitsManager().attackUnit(targetUnit.ID, units); } @@ -2001,6 +2075,7 @@ export class GroundUnit extends Unit { "Fire at area", "Click on a point to precisely fire at it (if possible)", faLocationCrosshairs, + "position", (units: Unit[], _, targetPosition: LatLng | null) => { if (targetPosition) getApp().getUnitsManager().fireAtArea(targetPosition, units); } @@ -2011,6 +2086,7 @@ export class GroundUnit extends Unit { "Simulate fire fight", "Simulate a fire fight by shooting randomly in a certain large area. WARNING: works correctly only on neutral units, blue or red units will aim", olButtonsContextSimulateFireFight, + "position", (units: Unit[], _, targetPosition: LatLng | null) => { if (targetPosition) getApp().getUnitsManager().simulateFireFight(targetPosition, units); } @@ -2028,7 +2104,7 @@ export class GroundUnit extends Unit { } /* When a unit is a leader of a group, the map is zoomed out and grouping when zoomed out is enabled, check if the unit should be shown as a specific group. This is used to show a SAM battery instead of the group leader */ - getDatabaseEntry() { + getBlueprint() { let unitWhenGrouped: string | undefined | null = null; if ( !this.getSelected() && @@ -2038,10 +2114,10 @@ export class GroundUnit extends Unit { ) { unitWhenGrouped = this.getDatabase()?.getByName(this.getName())?.unitWhenGrouped ?? null; let member = this.getGroupMembers().reduce((prev: Unit | null, unit: Unit, index: number) => { - if (unit.getDatabaseEntry()?.unitWhenGrouped != undefined) return unit; + if (unit.getBlueprint()?.unitWhenGrouped != undefined) return unit; return prev; }, null); - unitWhenGrouped = member !== null ? member?.getDatabaseEntry()?.unitWhenGrouped : unitWhenGrouped; + unitWhenGrouped = member !== null ? member?.getBlueprint()?.unitWhenGrouped : unitWhenGrouped; } if (unitWhenGrouped) return this.getDatabase()?.getByName(unitWhenGrouped) ?? this.getDatabase()?.getUnkownUnit(this.getName()); else return this.getDatabase()?.getByName(this.getName()) ?? this.getDatabase()?.getUnkownUnit(this.getName()); @@ -2099,6 +2175,7 @@ export class NavyUnit extends Unit { "Group navy units", "Create a group of navy units", faQuestionCircle, + null, (units: Unit[], _1, _2) => { getApp().getUnitsManager().createGroup(units); }, @@ -2110,6 +2187,7 @@ export class NavyUnit extends Unit { "Center map", "Center the map on the unit and follow it", faMapLocation, + null, (units: Unit[]) => { getApp().getMap().centerOnUnit(units[0]); }, @@ -2123,6 +2201,7 @@ export class NavyUnit extends Unit { "Attack unit", "Click on a unit to attack it", faQuestionCircle, + "unit", (units: Unit[], targetUnit: Unit | null, _) => { if (targetUnit) getApp().getUnitsManager().attackUnit(targetUnit.ID, units); } @@ -2135,6 +2214,7 @@ export class NavyUnit extends Unit { "Fire at area", "Click on a point to precisely fire at it (if possible)", faQuestionCircle, + "position", (units: Unit[], _, targetPosition: LatLng | null) => { if (targetPosition) getApp().getUnitsManager().fireAtArea(targetPosition, units); } diff --git a/frontend/react/src/unit/unitsmanager.ts b/frontend/react/src/unit/unitsmanager.ts index cbaea8ce..f401e96c 100644 --- a/frontend/react/src/unit/unitsmanager.ts +++ b/frontend/react/src/unit/unitsmanager.ts @@ -34,6 +34,7 @@ import { Group } from "./group"; import { UnitDataFileExport } from "./importexport/unitdatafileexport"; import { UnitDataFileImport } from "./importexport/unitdatafileimport"; import { CoalitionCircle } from "../map/coalitionarea/coalitioncircle"; +import { ContextActionSet } from "./contextactionset"; /** The UnitsManager handles the creation, update, and control of units. Data is strictly updated by the server ONLY. This means that any interaction from the user will always and only * result in a command to the server, executed by means of a REST PUT request. Any subsequent change in data will be reflected only when the new data is sent back by the server. This strategy allows @@ -1731,7 +1732,6 @@ export class UnitsManager { #onUnitSelection(unit: Unit) { if (this.getSelectedUnits().length > 0) { - getApp().getMap().setState(CONTEXT_ACTION); /* Disable the firing of the selection event for a certain amount of time. This avoids firing many events if many units are selected */ if (!this.#selectionEventDisabled) { window.setTimeout(() => { @@ -1740,6 +1740,14 @@ export class UnitsManager { detail: this.getSelectedUnits(), }) ); + + let newContextActionSet = new ContextActionSet(); + this.getSelectedUnits().forEach((unit) => unit.appendContextActions(newContextActionSet)); + getApp().getMap().setState(CONTEXT_ACTION, { + contextAction: null, + defaultContextAction: newContextActionSet.getDefaultContextAction(), + }); + this.#selectionEventDisabled = false; this.#showNumberOfSelectedProtectedUnits(); }, 100);