Browse Source

fix(devtools): harden overlay lifecycle and event observation

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
a5e8fd1f22
  1. 77
      src/devtools/CommandPalette.ts
  2. 58
      src/devtools/overlay/DebugOverlay.ts
  3. 25
      src/devtools/overlay/widgets/EventWidget.ts
  4. 18
      src/kernel/RuntimeEvents.ts
  5. 14
      src/main.ts
  6. 16
      tests/kernel/runtime-events.test.ts

77
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";
}

58
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";
}

25
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<string, number>();
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();
}
}

18
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<string, Set<EventCallback>>();
private anyEventCallbacks = new Set<AnyEventCallback>();
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);
};
}
}

14
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__;
});
}

16
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", () => {

Loading…
Cancel
Save