diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index d2d2de2a21..c1bda519be 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -229,6 +229,7 @@ export class InputHandler { private readonly PAN_SPEED = 5; private readonly ZOOM_SPEED = 10; private readonly DRAG_THRESHOLD_PX = 10; + private wasFullscreen = false; private readonly userSettings: UserSettings = new UserSettings(); @@ -241,6 +242,7 @@ export class InputHandler { initialize() { this.keybinds = this.userSettings.keybinds(Platform.isMac); + this.wasFullscreen = !!document.fullscreenElement; // Listen for warship selection to change cursor this.eventBus.on(UnitSelectionEvent, (e) => { @@ -360,6 +362,9 @@ export class InputHandler { } }, 1); + window.addEventListener("keydown", this.handleEscapeCapture, true); + document.addEventListener("fullscreenchange", this.handleFullscreenChange); + window.addEventListener("keydown", (e) => { const isTextInput = this.isTextInputTarget(e.target); if (isTextInput && e.code !== "Escape") { @@ -848,6 +853,34 @@ export class InputHandler { this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY)); } + private handleEscapeCapture = (event: KeyboardEvent) => { + if ( + event.code !== "Escape" || + this.uiState.ghostStructure === null || + this.isTextInputTarget(event.target) + ) { + return; + } + + this.cancelGhostStructure(); + event.preventDefault(); + }; + + private handleFullscreenChange = () => { + const isFullscreen = !!document.fullscreenElement; + if (this.wasFullscreen && !isFullscreen) { + this.eventBus.emit(new CloseViewEvent()); + if (this.uiState.ghostStructure) { + this.cancelGhostStructure(); + } + } + this.wasFullscreen = isFullscreen; + }; + + private cancelGhostStructure() { + this.setGhostStructure(null); + } + private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) { this.uiState.ghostStructure = ghostStructure; this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure)); @@ -992,6 +1025,11 @@ export class InputHandler { if (this.moveInterval !== null) { clearInterval(this.moveInterval); } + window.removeEventListener("keydown", this.handleEscapeCapture, true); + document.removeEventListener( + "fullscreenchange", + this.handleFullscreenChange, + ); this.activeKeys.clear(); } diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 3da402e6f2..8d5261b287 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -1,6 +1,8 @@ import { AutoUpgradeEvent, + CloseViewEvent, ConfirmGhostStructureEvent, + GhostStructureChangedEvent, InputHandler, WarshipSelectionBoxCancelEvent, WarshipSelectionBoxCompleteEvent, @@ -590,6 +592,130 @@ describe("InputHandler AutoUpgrade", () => { }); }); + describe("Escape while building", () => { + let uiState: UIState; + + beforeEach(() => { + inputHandler.destroy(); + uiState = { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + overlappingRailroads: [], + ghostRailPaths: [], + } as UIState; + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + inputHandler.initialize(); + }); + + test("captures Escape and cancels active ghost structure without blocking later handlers", () => { + const mockEmit = vi.spyOn(eventBus, "emit"); + const laterHandler = vi.fn(); + uiState.ghostStructure = UnitType.City; + + window.addEventListener("keydown", laterHandler); + const event = new KeyboardEvent("keydown", { + code: "Escape", + cancelable: true, + }); + window.dispatchEvent(event); + window.removeEventListener("keydown", laterHandler); + + expect(uiState.ghostStructure).toBeNull(); + expect(event.defaultPrevented).toBe(true); + expect(laterHandler).toHaveBeenCalled(); + expect(mockEmit).toHaveBeenCalledWith( + expect.any(GhostStructureChangedEvent), + ); + }); + + test("closes views and cancels active ghost structure if fullscreen exits before Escape keydown is delivered", () => { + const mockEmit = vi.spyOn(eventBus, "emit"); + const fullscreenDescriptor = Object.getOwnPropertyDescriptor( + document, + "fullscreenElement", + ); + let fullscreenElement: Element | null = document.documentElement; + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get: () => fullscreenElement, + }); + + try { + inputHandler.destroy(); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + inputHandler.initialize(); + uiState.ghostStructure = UnitType.Factory; + + fullscreenElement = null; + document.dispatchEvent(new Event("fullscreenchange")); + + expect(uiState.ghostStructure).toBeNull(); + expect(mockEmit).toHaveBeenCalledWith(expect.any(CloseViewEvent)); + expect(mockEmit).toHaveBeenCalledWith( + expect.any(GhostStructureChangedEvent), + ); + } finally { + Reflect.deleteProperty(document, "fullscreenElement"); + if (fullscreenDescriptor) { + Object.defineProperty( + document, + "fullscreenElement", + fullscreenDescriptor, + ); + } + } + }); + + test("closes views if fullscreen exits with no active ghost structure", () => { + const mockEmit = vi.spyOn(eventBus, "emit"); + const fullscreenDescriptor = Object.getOwnPropertyDescriptor( + document, + "fullscreenElement", + ); + let fullscreenElement: Element | null = document.documentElement; + Object.defineProperty(document, "fullscreenElement", { + configurable: true, + get: () => fullscreenElement, + }); + + try { + inputHandler.destroy(); + inputHandler = new InputHandler( + mockGameView, + uiState, + mockCanvas, + eventBus, + ); + inputHandler.initialize(); + + fullscreenElement = null; + document.dispatchEvent(new Event("fullscreenchange")); + + expect(mockEmit).toHaveBeenCalledWith(expect.any(CloseViewEvent)); + } finally { + Reflect.deleteProperty(document, "fullscreenElement"); + if (fullscreenDescriptor) { + Object.defineProperty( + document, + "fullscreenElement", + fullscreenDescriptor, + ); + } + } + }); + }); + describe("Numpad number keys for build keybinds", () => { beforeEach(() => { inputHandler.destroy();