• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-monorepo / 20661584252

02 Jan 2026 03:50PM UTC coverage: 77.924% (+7.3%) from 70.62%
20661584252

push

github

apowers313
ci: fix flakey performance test

13438 of 17822 branches covered (75.4%)

Branch coverage included in aggregate %.

41247 of 52355 relevant lines covered (78.78%)

145534.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

60.0
/graphty-element/src/managers/InputManager.ts
1
import type { Vector2 } from "@babylonjs/core/Maths/math.vector";
1✔
2
import { Observable } from "@babylonjs/core/Misc/observable";
3

4
import { BabylonInputSystem } from "../input/babylon-input-system";
14✔
5
import { MockDeviceInputSystem } from "../input/mock-device-input-system";
14✔
6
import type { KeyboardInfo, MouseButton, PointerInfo, TouchPoint, WheelInfo } from "../input/types";
7
import type { Manager, ManagerContext } from "./interfaces";
8

9
/**
10
 * Recorded input event structure
11
 */
12
export interface RecordedInputEvent {
13
    timestamp: number;
14
    type: string;
15
    data: Record<string, unknown>;
16
}
17

18
/**
19
 * Configuration options for InputManager
20
 */
21
export interface InputManagerConfig {
22
    /**
23
     * Whether to use mock input system for testing
24
     */
25
    useMockInput?: boolean;
26

27
    /**
28
     * Whether touch input is enabled
29
     */
30
    touchEnabled?: boolean;
31

32
    /**
33
     * Whether keyboard input is enabled
34
     */
35
    keyboardEnabled?: boolean;
36

37
    /**
38
     * Whether pointer lock is enabled for FPS-style controls
39
     */
40
    pointerLockEnabled?: boolean;
41

42
    /**
43
     * Input recording/playback for automation
44
     */
45
    recordInput?: boolean;
46
    playbackFile?: string;
47
}
48

49
/**
50
 * Manages all user input for the graph
51
 * Provides a unified interface for mouse, keyboard, and touch input
52
 */
53
export class InputManager implements Manager {
14✔
54
    // Observable events (exposed for backward compatibility)
55
    public readonly onPointerMove: Observable<PointerInfo>;
56
    public readonly onPointerDown: Observable<PointerInfo>;
57
    public readonly onPointerUp: Observable<PointerInfo>;
58
    public readonly onWheel: Observable<WheelInfo>;
59
    public readonly onTouchStart: Observable<TouchPoint[]>;
60
    public readonly onTouchMove: Observable<TouchPoint[]>;
61
    public readonly onTouchEnd: Observable<number[]>;
62
    public readonly onKeyDown: Observable<KeyboardInfo>;
63
    public readonly onKeyUp: Observable<KeyboardInfo>;
64

65
    private inputSystem: BabylonInputSystem | MockDeviceInputSystem;
66
    private enabled = true;
14✔
67
    private recordedEvents: RecordedInputEvent[] = [];
14✔
68
    private playbackIndex = 0;
14✔
69
    private playbackStartTime = 0;
14✔
70

71
    /**
72
     * Creates an instance of InputManager
73
     * @param context - Manager context providing access to scene, canvas, and event manager
74
     * @param config - Input manager configuration options
75
     */
76
    constructor(
14✔
77
        private context: ManagerContext,
999✔
78
        private config: InputManagerConfig = {},
999✔
79
    ) {
999✔
80
        // Create appropriate input system based on config
81
        this.inputSystem = this.config.useMockInput
999✔
82
            ? new MockDeviceInputSystem()
7✔
83
            : new BabylonInputSystem(this.context.scene);
992✔
84

85
        // Expose observables from the input system
86
        this.onPointerMove = this.inputSystem.onPointerMove;
999✔
87
        this.onPointerDown = this.inputSystem.onPointerDown;
999✔
88
        this.onPointerUp = this.inputSystem.onPointerUp;
999✔
89
        this.onWheel = this.inputSystem.onWheel;
999✔
90
        this.onTouchStart = this.inputSystem.onTouchStart;
999✔
91
        this.onTouchMove = this.inputSystem.onTouchMove;
999✔
92
        this.onTouchEnd = this.inputSystem.onTouchEnd;
999✔
93
        this.onKeyDown = this.inputSystem.onKeyDown;
999✔
94
        this.onKeyUp = this.inputSystem.onKeyUp;
999✔
95
    }
999✔
96

97
    /**
98
     * Initializes the input manager and sets up event bridges
99
     */
100
    async init(): Promise<void> {
14✔
101
        try {
925✔
102
            // Attach input system to canvas
103
            this.inputSystem.attach(this.context.canvas as HTMLElement);
925✔
104

105
            // Set up event bridges to EventManager
106
            this.setupEventBridges();
925✔
107

108
            // Load playback file if specified
109
            if (this.config.playbackFile) {
925!
110
                await this.loadPlaybackFile(this.config.playbackFile);
×
111
            }
×
112

113
            // Emit initialization event
114
            this.context.eventManager.emitGraphEvent("input-initialized", {
925✔
115
                inputManager: this,
925✔
116
                config: this.config,
925✔
117
            });
925✔
118
        } catch (error) {
925!
119
            const err = error instanceof Error ? error : new Error(String(error));
×
120
            this.context.eventManager.emitGraphError(null, err, "init", { component: "InputManager" });
×
121
            throw new Error(`Failed to initialize InputManager: ${err.message}`);
×
122
        }
×
123
    }
925✔
124

125
    /**
126
     * Disposes of the input manager and cleans up resources
127
     */
128
    dispose(): void {
14✔
129
        // Save recorded events if recording
130
        if (this.config.recordInput && this.recordedEvents.length > 0) {
786!
131
            this.saveRecordedEvents();
×
132
        }
×
133

134
        // Dispose input system
135
        this.inputSystem.dispose();
786✔
136

137
        // Clear references
138
        this.recordedEvents = [];
786✔
139
    }
786✔
140

141
    /**
142
     * Enable or disable all input
143
     * @param enabled - Whether input should be enabled
144
     */
145
    setEnabled(enabled: boolean): void {
14✔
146
        this.enabled = enabled;
2✔
147

148
        if (!enabled && this.inputSystem instanceof BabylonInputSystem) {
2!
149
            // Clear any active states when disabling
150
            this.inputSystem.detach();
×
151
        } else if (enabled && this.inputSystem instanceof BabylonInputSystem) {
2!
152
            this.inputSystem.attach(this.context.canvas as HTMLElement);
×
153
        }
×
154

155
        this.context.eventManager.emitGraphEvent("input-enabled-changed", { enabled });
2✔
156
    }
2✔
157

158
    /**
159
     * Get the current pointer position
160
     * @returns Current pointer position as Vector2
161
     */
162
    getPointerPosition(): Vector2 {
14✔
163
        return this.inputSystem.getPointerPosition();
1✔
164
    }
1✔
165

166
    /**
167
     * Check if a pointer button is currently down
168
     * @param button - Mouse button to check (left, middle, right)
169
     * @returns True if the button is pressed, false otherwise
170
     */
171
    isPointerDown(button?: MouseButton): boolean {
14✔
172
        return this.inputSystem.isPointerDown(button);
4✔
173
    }
4✔
174

175
    /**
176
     * Get all active touch points
177
     * @returns Array of active touch points
178
     */
179
    getActiveTouches(): TouchPoint[] {
14✔
180
        return this.inputSystem.getActiveTouches();
1✔
181
    }
1✔
182

183
    /**
184
     * Get the mock input system for testing
185
     * @returns MockDeviceInputSystem instance
186
     * @throws Error if not using mock input
187
     */
188
    getMockInputSystem(): MockDeviceInputSystem {
14✔
189
        if (!(this.inputSystem instanceof MockDeviceInputSystem)) {
6✔
190
            throw new Error("Not using mock input system");
1✔
191
        }
1✔
192

193
        return this.inputSystem;
5✔
194
    }
6✔
195

196
    /**
197
     * Start recording input events
198
     */
199
    startRecording(): void {
14✔
200
        this.config.recordInput = true;
×
201
        this.recordedEvents = [];
×
202
        this.context.eventManager.emitGraphEvent("input-recording-started", {});
×
203
    }
×
204

205
    /**
206
     * Stop recording input events
207
     * @returns Array of recorded events
208
     */
209
    stopRecording(): RecordedInputEvent[] {
14✔
210
        this.config.recordInput = false;
×
211
        this.context.eventManager.emitGraphEvent("input-recording-stopped", {
×
212
            eventCount: this.recordedEvents.length,
×
213
        });
×
214
        return [...this.recordedEvents];
×
215
    }
×
216

217
    /**
218
     * Start playback of recorded events
219
     * @param events - Optional array of events to play back
220
     * @returns Promise that resolves when playback completes
221
     */
222
    startPlayback(events?: RecordedInputEvent[]): Promise<void> {
14✔
223
        if (events) {
×
224
            this.recordedEvents = events;
×
225
        }
×
226

227
        if (this.recordedEvents.length === 0) {
×
228
            throw new Error("No events to playback");
×
229
        }
×
230

231
        // Switch to mock input for playback
232
        if (!(this.inputSystem instanceof MockDeviceInputSystem)) {
×
233
            this.inputSystem.dispose();
×
234
            this.inputSystem = new MockDeviceInputSystem();
×
235
            this.inputSystem.attach(this.context.canvas as HTMLElement);
×
236
            this.setupEventBridges();
×
237
        }
×
238

239
        this.playbackIndex = 0;
×
240
        this.playbackStartTime = Date.now();
×
241

242
        // Start playback loop and return the promise
243
        return this.runPlayback();
×
244
    }
×
245

246
    /**
247
     * Set up bridges between input system and event manager
248
     */
249
    private setupEventBridges(): void {
14✔
250
        // Only bridge events if enabled
251
        const createBridge = <T>(observable: Observable<T>, eventName: string, shouldRecord = true): void => {
925✔
252
            observable.add((data) => {
8,325✔
253
                if (!this.enabled) {
138✔
254
                    return;
1✔
255
                }
1✔
256

257
                // Record event if recording
258
                if (this.config.recordInput && shouldRecord) {
138✔
259
                    this.recordedEvents.push({
×
260
                        timestamp: Date.now(),
×
261
                        type: eventName,
×
262
                        data: this.serializeEventData(data),
×
263
                    });
×
264
                }
✔
265

266
                // Emit through event manager
267
                const eventData = this.serializeEventData(data);
137✔
268
                this.context.eventManager.emitGraphEvent(eventName, eventData);
137✔
269
            });
8,325✔
270
        };
8,325✔
271

272
        // Bridge all input events
273
        createBridge(this.onPointerMove, "input:pointer-move");
925✔
274
        createBridge(this.onPointerDown, "input:pointer-down");
925✔
275
        createBridge(this.onPointerUp, "input:pointer-up");
925✔
276
        createBridge(this.onWheel, "input:wheel");
925✔
277
        createBridge(this.onTouchStart, "input:touch-start");
925✔
278
        createBridge(this.onTouchMove, "input:touch-move");
925✔
279
        createBridge(this.onTouchEnd, "input:touch-end");
925✔
280
        createBridge(this.onKeyDown, "input:key-down");
925✔
281
        createBridge(this.onKeyUp, "input:key-up");
925✔
282

283
        // Special handling for keyboard shortcuts
284
        this.onKeyDown.add((info) => {
925✔
285
            if (!this.enabled) {
44✔
286
                return;
×
287
            }
×
288

289
            // Emit specific shortcut events
290
            if (info.ctrlKey && info.key === "z") {
44✔
291
                this.context.eventManager.emitGraphEvent("input:undo", {});
1✔
292
            } else if (info.ctrlKey && info.key === "y") {
44✔
293
                this.context.eventManager.emitGraphEvent("input:redo", {});
1✔
294
            } else if (info.ctrlKey && info.key === "a") {
42✔
295
                this.context.eventManager.emitGraphEvent("input:select-all", {});
1✔
296
            }
1✔
297
            // Add more shortcuts as needed
298
        });
925✔
299
    }
925✔
300

301
    /**
302
     * Serialize event data for recording
303
     * @param data - Event data to serialize
304
     * @returns Serialized event data as a plain object
305
     */
306
    private serializeEventData(data: unknown): Record<string, unknown> {
14✔
307
        // Handle Vector2 objects
308
        if (
533✔
309
            data &&
533✔
310
            typeof data === "object" &&
215✔
311
            "x" in data &&
139✔
312
            "y" in data &&
72✔
313
            typeof data.x === "number" &&
72✔
314
            typeof data.y === "number"
72✔
315
        ) {
533✔
316
            return { x: data.x, y: data.y };
72✔
317
        }
72✔
318

319
        // Handle arrays
320
        if (Array.isArray(data)) {
504!
321
            return { array: data.map((item) => this.serializeEventData(item)) };
1✔
322
        }
1✔
323

324
        // Handle objects
325
        if (data && typeof data === "object") {
533✔
326
            const serialized: Record<string, unknown> = {};
66✔
327
            for (const key in data) {
66✔
328
                if (Object.prototype.hasOwnProperty.call(data, key)) {
394✔
329
                    serialized[key] = this.serializeEventData((data as Record<string, unknown>)[key]);
394✔
330
                }
394✔
331
            }
394✔
332
            return serialized;
66✔
333
        }
66✔
334

335
        // Primitives - wrap in object
336
        return { value: data };
394✔
337
    }
533✔
338

339
    /**
340
     * Run playback of recorded events
341
     */
342
    private async runPlayback(): Promise<void> {
14✔
343
        const mockSystem = this.inputSystem as MockDeviceInputSystem;
×
344

345
        while (this.playbackIndex < this.recordedEvents.length) {
×
346
            const event = this.recordedEvents[this.playbackIndex];
×
347
            const elapsed = Date.now() - this.playbackStartTime;
×
348
            const eventTime = event.timestamp - this.recordedEvents[0].timestamp;
×
349

350
            // Wait until it's time for this event
351
            if (elapsed < eventTime) {
×
352
                await new Promise((resolve) => setTimeout(resolve, eventTime - elapsed));
×
353
            }
×
354

355
            // Replay the event
356
            switch (event.type) {
×
357
                case "input:pointer-move":
×
358
                    mockSystem.simulateMouseMove(event.data.x as number, event.data.y as number);
×
359
                    break;
×
360
                case "input:pointer-down":
×
361
                    mockSystem.simulateMouseDown(event.data.button as MouseButton);
×
362
                    break;
×
363
                case "input:pointer-up":
×
364
                    mockSystem.simulateMouseUp(event.data.button as MouseButton);
×
365
                    break;
×
366
                case "input:wheel":
×
367
                    mockSystem.simulateWheel(event.data.deltaY as number, event.data.deltaX as number);
×
368
                    break;
×
369
                case "input:touch-start":
×
370
                    mockSystem.simulateTouchStart(event.data.array as TouchPoint[]);
×
371
                    break;
×
372
                case "input:touch-move":
×
373
                    mockSystem.simulateTouchMove(event.data.array as TouchPoint[]);
×
374
                    break;
×
375
                case "input:touch-end":
×
376
                    mockSystem.simulateTouchEnd(event.data.array as number[]);
×
377
                    break;
×
378
                case "input:key-down":
×
379
                    mockSystem.simulateKeyDown(event.data.key as string, event.data as Partial<KeyboardInfo>);
×
380
                    break;
×
381
                case "input:key-up":
×
382
                    mockSystem.simulateKeyUp(event.data.key as string);
×
383
                    break;
×
384
                default:
×
385
                    // Unknown event type
386
                    break;
×
387
            }
×
388

389
            this.playbackIndex++;
×
390
        }
×
391

392
        this.context.eventManager.emitGraphEvent("input-playback-completed", {});
×
393
    }
×
394

395
    /**
396
     * Load playback file
397
     * @param filename - Path or URL to the playback file
398
     */
399
    private async loadPlaybackFile(filename: string): Promise<void> {
14✔
400
        try {
×
401
            const response = await fetch(filename);
×
402
            const data = await response.json();
×
403
            this.recordedEvents = data.events ?? [];
×
404
        } catch (error) {
×
405
            console.warn(`Failed to load playback file: ${filename}`, error);
×
406
        }
×
407
    }
×
408

409
    /**
410
     * Save recorded events (implementation depends on environment)
411
     */
412
    private saveRecordedEvents(): void {
14✔
413
        const data = {
×
414
            version: "1.0",
×
415
            timestamp: new Date().toISOString(),
×
416
            events: this.recordedEvents,
×
417
        };
×
418

419
        // In browser, download as file
420
        if (typeof window !== "undefined") {
×
421
            const blob = new Blob([JSON.stringify(data, null, 2)], {
×
422
                type: "application/json",
×
423
            });
×
424
            const url = URL.createObjectURL(blob);
×
425
            const a = document.createElement("a");
×
426
            a.href = url;
×
427
            a.download = `input-recording-${Date.now()}.json`;
×
428
            a.click();
×
429
            URL.revokeObjectURL(url);
×
430
        }
×
431
    }
×
432

433
    /**
434
     * Update configuration
435
     * @param config - Partial configuration to merge with existing config
436
     */
437
    updateConfig(config: Partial<InputManagerConfig>): void {
14✔
438
        Object.assign(this.config, config);
1✔
439

440
        // Handle specific config changes
441
        if ("touchEnabled" in config || "keyboardEnabled" in config) {
1!
442
            // These would affect input system behavior
443
            this.context.eventManager.emitGraphEvent("input-config-updated", config);
1✔
444
        }
1✔
445
    }
1✔
446

447
    /**
448
     * Enable pointer lock for FPS-style controls
449
     */
450
    async requestPointerLock(): Promise<void> {
14✔
451
        if (this.config.pointerLockEnabled) {
×
452
            try {
×
453
                await this.context.canvas.requestPointerLock();
×
454
                this.context.eventManager.emitGraphEvent("input-pointer-lock-changed", {
×
455
                    locked: true,
×
456
                });
×
457
            } catch (error) {
×
458
                console.warn("Failed to request pointer lock:", error);
×
459
            }
×
460
        }
×
461
    }
×
462

463
    /**
464
     * Exit pointer lock
465
     */
466
    exitPointerLock(): void {
14✔
467
        if (document.pointerLockElement === this.context.canvas) {
×
468
            document.exitPointerLock();
×
469
            this.context.eventManager.emitGraphEvent("input-pointer-lock-changed", {
×
470
                locked: false,
×
471
            });
×
472
        }
×
473
    }
×
474
}
14✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc