diff --git a/src/devtools/CommandPalette.ts b/src/devtools/CommandPalette.ts index e137d47..274bdcd 100644 --- a/src/devtools/CommandPalette.ts +++ b/src/devtools/CommandPalette.ts @@ -18,6 +18,34 @@ export class CommandPalette { private filtered: RuntimeCommand[] = []; private selectedIndex = 0; private isOpen = false; + private readonly onInput = () => this.refreshFiltered(); + private readonly onInputKeydown = (event: KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + this.selectedIndex = Math.min(this.selectedIndex + 1, this.filtered.length - 1); + this.renderList(); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + this.selectedIndex = Math.max(this.selectedIndex - 1, 0); + this.renderList(); + } else if (event.key === "Enter") { + event.preventDefault(); + this.executeSelected(); + } else if (event.key === "Escape") { + this.hide(); + } + }; + private readonly onWindowKeydown = (event: KeyboardEvent) => { + if (isEditableTarget(event.target)) { + return; + } + const isToggle = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k"; + if (!isToggle) { + return; + } + event.preventDefault(); + this.toggle(); + }; constructor() { this.element = this.root.element; @@ -40,23 +68,8 @@ export class CommandPalette { this.input.style.padding = "6px"; this.input.style.borderRadius = "4px"; - this.input.addEventListener("input", () => this.refreshFiltered()); - this.input.addEventListener("keydown", (event) => { - if (event.key === "ArrowDown") { - event.preventDefault(); - this.selectedIndex = Math.min(this.selectedIndex + 1, this.filtered.length - 1); - this.renderList(); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - this.selectedIndex = Math.max(this.selectedIndex - 1, 0); - this.renderList(); - } else if (event.key === "Enter") { - event.preventDefault(); - this.executeSelected(); - } else if (event.key === "Escape") { - this.hide(); - } - }); + this.input.addEventListener("input", this.onInput); + this.input.addEventListener("keydown", this.onInputKeydown); this.root.append(this.hintText.element, this.input, this.list.element); document.body.appendChild(this.element); @@ -91,14 +104,7 @@ export class CommandPalette { } private bindKeyboard(): void { - window.addEventListener("keydown", (event) => { - const isToggle = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k"; - if (!isToggle) { - return; - } - event.preventDefault(); - this.toggle(); - }); + window.addEventListener("keydown", this.onWindowKeydown); } private refreshFiltered(): void { @@ -125,4 +131,25 @@ export class CommandPalette { command.run(); this.hide(); } + + dispose(): void { + window.removeEventListener("keydown", this.onWindowKeydown); + this.input.removeEventListener("input", this.onInput); + this.input.removeEventListener("keydown", this.onInputKeydown); + this.element.remove(); + this.commands.clear(); + this.filtered = []; + this.isOpen = false; + } +} + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + if (target.isContentEditable) { + return true; + } + const tagName = target.tagName.toLowerCase(); + return tagName === "input" || tagName === "textarea"; } diff --git a/src/devtools/overlay/DebugOverlay.ts b/src/devtools/overlay/DebugOverlay.ts index 0558d68..e1a1265 100644 --- a/src/devtools/overlay/DebugOverlay.ts +++ b/src/devtools/overlay/DebugOverlay.ts @@ -20,6 +20,17 @@ export class DebugOverlay { private readonly eventWidget: EventWidget; private readonly commandPalette = new CommandPalette(); private visible = false; + private readonly offCallbacks: Array<() => void> = []; + private readonly onWindowKeydown = (event: KeyboardEvent) => { + if (isEditableTarget(event.target)) { + return; + } + if (event.key !== "`" && event.code !== "Backquote") { + return; + } + event.preventDefault(); + this.toggle(); + }; constructor(private readonly deps: DebugOverlayDeps) { this.root = document.createElement("div"); @@ -57,24 +68,19 @@ export class DebugOverlay { } private bindKeys(): void { - window.addEventListener("keydown", (event) => { - if (event.key !== "`" && event.code !== "Backquote") { - return; - } - event.preventDefault(); - this.toggle(); - }); + window.addEventListener("keydown", this.onWindowKeydown); } private bindEvents(): void { - this.deps.sceneManager.onStageChange(() => this.refresh()); - this.deps.events.on("scene:changed", () => this.refresh()); - this.deps.events.on("runtime:ready", () => this.refresh()); - this.deps.events.on("game:frame-rendered", () => { - if (this.visible) { - this.eventWidget.render(); - } - }); + this.offCallbacks.push( + this.deps.events.on("scene:changed", () => this.refresh()), + this.deps.events.on("runtime:ready", () => this.refresh()), + this.deps.events.on("game:frame-rendered", () => { + if (this.visible) { + this.eventWidget.render(); + } + }) + ); } private registerCommands(): void { @@ -100,9 +106,31 @@ export class DebugOverlay { }); } + dispose(): void { + window.removeEventListener("keydown", this.onWindowKeydown); + for (const off of this.offCallbacks) { + off(); + } + this.offCallbacks.length = 0; + this.eventWidget.dispose(); + this.commandPalette.dispose(); + this.root.remove(); + } + private refresh(): void { this.sceneWidget.render(); this.assetWidget.render(); this.eventWidget.render(); } } + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + if (target.isContentEditable) { + return true; + } + const tagName = target.tagName.toLowerCase(); + return tagName === "input" || tagName === "textarea"; +} diff --git a/src/devtools/overlay/widgets/EventWidget.ts b/src/devtools/overlay/widgets/EventWidget.ts index a4d77b1..bfca964 100644 --- a/src/devtools/overlay/widgets/EventWidget.ts +++ b/src/devtools/overlay/widgets/EventWidget.ts @@ -2,34 +2,25 @@ import type { RuntimeEvents } from "@/kernel/RuntimeEvents"; import { UiList } from "@/ui-core/UiList"; import { UiPanel } from "@/ui-core/UiPanel"; -type RuntimeEventsWithEmit = RuntimeEvents & { - emit: (event: string, ...args: unknown[]) => void; -}; - export class EventWidget { readonly element: HTMLDivElement; private readonly list = new UiList(); private readonly eventCounts = new Map(); private readonly maxEvents = 6; - private readonly runtimeEvents: RuntimeEventsWithEmit; + private readonly runtimeEvents: RuntimeEvents; + private readonly disposeOnAny: () => void; constructor(runtimeEvents: RuntimeEvents) { - this.runtimeEvents = runtimeEvents as RuntimeEventsWithEmit; + this.runtimeEvents = runtimeEvents; const panel = new UiPanel("Events"); panel.append(this.list.element); this.element = panel.element; - this.hookEmit(); - this.render(); - } - - private hookEmit(): void { - const originalEmit = this.runtimeEvents.emit.bind(this.runtimeEvents); - this.runtimeEvents.emit = (event: string, ...args: unknown[]) => { + this.disposeOnAny = this.runtimeEvents.onAny((event) => { const count = this.eventCounts.get(event) ?? 0; this.eventCounts.set(event, count + 1); this.render(); - originalEmit(event, ...args); - }; + }); + this.render(); } render(): void { @@ -43,4 +34,8 @@ export class EventWidget { } this.list.setItems(items); } + + dispose(): void { + this.disposeOnAny(); + } } diff --git a/src/kernel/RuntimeEvents.ts b/src/kernel/RuntimeEvents.ts index 1873ac4..8d44250 100644 --- a/src/kernel/RuntimeEvents.ts +++ b/src/kernel/RuntimeEvents.ts @@ -1,4 +1,5 @@ type EventCallback = (...args: unknown[]) => void; +type AnyEventCallback = (event: string, ...args: unknown[]) => void; export interface RuntimeEventMap { "runtime:ready": { startedAt: number }; @@ -9,6 +10,7 @@ export interface RuntimeEventMap { export class RuntimeEvents { private events = new Map>(); + private anyEventCallbacks = new Set(); on(event: string, callback: EventCallback): () => void { if (!this.events.has(event)) { @@ -43,6 +45,14 @@ export class RuntimeEvents { } emit(event: string, ...args: unknown[]): void { + this.anyEventCallbacks.forEach((cb) => { + try { + cb(event, ...args); + } catch (error) { + console.error(`RuntimeEvents: tap listener failed for "${event}"`, error); + } + }); + const callbacks = this.events.get(event); if (!callbacks) { return; @@ -59,9 +69,17 @@ export class RuntimeEvents { clear(): void { this.events.clear(); + this.anyEventCallbacks.clear(); } getEventCount(): number { return this.events.size; } + + onAny(callback: AnyEventCallback): () => void { + this.anyEventCallbacks.add(callback); + return () => { + this.anyEventCallbacks.delete(callback); + }; + } } diff --git a/src/main.ts b/src/main.ts index 3f42e80..316aafd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,10 @@ import { initApp, game, sceneManager, assetManager } from "./init"; import { appRuntime } from "./kernel/AppRuntime"; import { DebugOverlay } from "./devtools/overlay/DebugOverlay"; +type DevtoolsWindow = Window & { + __PIXIDEMO_DEBUG_OVERLAY__?: DebugOverlay; +}; + void (async () => { try { await initApp(); @@ -13,12 +17,20 @@ void (async () => { })(); if (import.meta.env.DEV) { + const devWindow = window as DevtoolsWindow; + devWindow.__PIXIDEMO_DEBUG_OVERLAY__?.dispose(); + (window as unknown as { game: typeof game; sceneManager: typeof sceneManager }).game = game; (window as unknown as { game: typeof game; sceneManager: typeof sceneManager }).sceneManager = sceneManager; - new DebugOverlay({ + devWindow.__PIXIDEMO_DEBUG_OVERLAY__ = new DebugOverlay({ sceneManager: appRuntime.sceneManager, assetManager, events: appRuntime.events, }); + + import.meta.hot?.dispose(() => { + devWindow.__PIXIDEMO_DEBUG_OVERLAY__?.dispose(); + delete devWindow.__PIXIDEMO_DEBUG_OVERLAY__; + }); } diff --git a/tests/kernel/runtime-events.test.ts b/tests/kernel/runtime-events.test.ts index 6759ffd..bbdb85a 100644 --- a/tests/kernel/runtime-events.test.ts +++ b/tests/kernel/runtime-events.test.ts @@ -91,6 +91,22 @@ describe("RuntimeEvents", () => { expect(throwing).toHaveBeenCalledTimes(1); expect(after).toHaveBeenCalledTimes(2); }); + + it("supports onAny tap without affecting event listeners", () => { + const events = new RuntimeEvents(); + const tap = vi.fn(); + const handler = vi.fn(); + const offTap = events.onAny(tap); + + events.on("scene:changed", handler); + events.emit("scene:changed", { current: "a", previous: undefined }); + offTap(); + events.emit("scene:changed", { current: "b", previous: "a" }); + + expect(tap).toHaveBeenCalledTimes(1); + expect(tap).toHaveBeenCalledWith("scene:changed", { current: "a", previous: undefined }); + expect(handler).toHaveBeenCalledTimes(2); + }); }); describe("AppRuntime plugin lifecycle", () => {