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

graphty-org / graphty-element / 20514590651

26 Dec 2025 02:37AM UTC coverage: 70.559% (-0.3%) from 70.836%
20514590651

push

github

apowers313
ci: fix npm ci

9591 of 13363 branches covered (71.77%)

Branch coverage included in aggregate %.

25136 of 35854 relevant lines covered (70.11%)

6233.71 hits per line

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

58.92
/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 {
7
    KeyboardInfo,
8
    MouseButton,
9
    PointerInfo,
10
    TouchPoint,
11
    WheelInfo,
12
} from "../input/types";
13
import type {Manager, ManagerContext} from "./interfaces";
14

15
/**
16
 * Recorded input event structure
17
 */
18
export interface RecordedInputEvent {
19
    timestamp: number;
20
    type: string;
21
    data: Record<string, unknown>;
22
}
23

24
/**
25
 * Configuration options for InputManager
26
 */
27
export interface InputManagerConfig {
28
    /**
29
     * Whether to use mock input system for testing
30
     */
31
    useMockInput?: boolean;
32

33
    /**
34
     * Whether touch input is enabled
35
     */
36
    touchEnabled?: boolean;
37

38
    /**
39
     * Whether keyboard input is enabled
40
     */
41
    keyboardEnabled?: boolean;
42

43
    /**
44
     * Whether pointer lock is enabled for FPS-style controls
45
     */
46
    pointerLockEnabled?: boolean;
47

48
    /**
49
     * Input recording/playback for automation
50
     */
51
    recordInput?: boolean;
52
    playbackFile?: string;
53
}
54

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

71
    private inputSystem: BabylonInputSystem | MockDeviceInputSystem;
72
    private enabled = true;
14✔
73
    private recordedEvents: RecordedInputEvent[] = [];
14✔
74
    private playbackIndex = 0;
14✔
75
    private playbackStartTime = 0;
14✔
76

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

91
        // Expose observables from the input system
92
        this.onPointerMove = this.inputSystem.onPointerMove;
999✔
93
        this.onPointerDown = this.inputSystem.onPointerDown;
999✔
94
        this.onPointerUp = this.inputSystem.onPointerUp;
999✔
95
        this.onWheel = this.inputSystem.onWheel;
999✔
96
        this.onTouchStart = this.inputSystem.onTouchStart;
999✔
97
        this.onTouchMove = this.inputSystem.onTouchMove;
999✔
98
        this.onTouchEnd = this.inputSystem.onTouchEnd;
999✔
99
        this.onKeyDown = this.inputSystem.onKeyDown;
999✔
100
        this.onKeyUp = this.inputSystem.onKeyUp;
999✔
101
    }
999✔
102

103
    /**
104
     * Initializes the input manager and sets up event bridges
105
     */
106
    async init(): Promise<void> {
14✔
107
        try {
925✔
108
            // Attach input system to canvas
109
            this.inputSystem.attach(this.context.canvas as HTMLElement);
925✔
110

111
            // Set up event bridges to EventManager
112
            this.setupEventBridges();
925✔
113

114
            // Load playback file if specified
115
            if (this.config.playbackFile) {
925!
116
                await this.loadPlaybackFile(this.config.playbackFile);
×
117
            }
×
118

119
            // Emit initialization event
120
            this.context.eventManager.emitGraphEvent("input-initialized", {
925✔
121
                inputManager: this,
925✔
122
                config: this.config,
925✔
123
            });
925✔
124
        } catch (error) {
925!
125
            const err = error instanceof Error ? error : new Error(String(error));
×
126
            this.context.eventManager.emitGraphError(
×
127
                null,
×
128
                err,
×
129
                "init",
×
130
                {component: "InputManager"},
×
131
            );
×
132
            throw new Error(`Failed to initialize InputManager: ${err.message}`);
×
133
        }
×
134
    }
925✔
135

136
    /**
137
     * Disposes of the input manager and cleans up resources
138
     */
139
    dispose(): void {
14✔
140
        // Save recorded events if recording
141
        if (this.config.recordInput && this.recordedEvents.length > 0) {
786!
142
            this.saveRecordedEvents();
×
143
        }
×
144

145
        // Dispose input system
146
        this.inputSystem.dispose();
786✔
147

148
        // Clear references
149
        this.recordedEvents = [];
786✔
150
    }
786✔
151

152
    /**
153
     * Enable or disable all input
154
     * @param enabled - Whether input should be enabled
155
     */
156
    setEnabled(enabled: boolean): void {
14✔
157
        this.enabled = enabled;
2✔
158

159
        if (!enabled && this.inputSystem instanceof BabylonInputSystem) {
2!
160
            // Clear any active states when disabling
161
            this.inputSystem.detach();
×
162
        } else if (enabled && this.inputSystem instanceof BabylonInputSystem) {
2!
163
            this.inputSystem.attach(this.context.canvas as HTMLElement);
×
164
        }
×
165

166
        this.context.eventManager.emitGraphEvent("input-enabled-changed", {enabled});
2✔
167
    }
2✔
168

169
    /**
170
     * Get the current pointer position
171
     * @returns Current pointer position as Vector2
172
     */
173
    getPointerPosition(): Vector2 {
14✔
174
        return this.inputSystem.getPointerPosition();
1✔
175
    }
1✔
176

177
    /**
178
     * Check if a pointer button is currently down
179
     * @param button - Mouse button to check (left, middle, right)
180
     * @returns True if the button is pressed, false otherwise
181
     */
182
    isPointerDown(button?: MouseButton): boolean {
14✔
183
        return this.inputSystem.isPointerDown(button);
4✔
184
    }
4✔
185

186
    /**
187
     * Get all active touch points
188
     * @returns Array of active touch points
189
     */
190
    getActiveTouches(): TouchPoint[] {
14✔
191
        return this.inputSystem.getActiveTouches();
1✔
192
    }
1✔
193

194
    /**
195
     * Get the mock input system for testing
196
     * @returns MockDeviceInputSystem instance
197
     * @throws Error if not using mock input
198
     */
199
    getMockInputSystem(): MockDeviceInputSystem {
14✔
200
        if (!(this.inputSystem instanceof MockDeviceInputSystem)) {
6✔
201
            throw new Error("Not using mock input system");
1✔
202
        }
1✔
203

204
        return this.inputSystem;
5✔
205
    }
6✔
206

207
    /**
208
     * Start recording input events
209
     */
210
    startRecording(): void {
14✔
211
        this.config.recordInput = true;
×
212
        this.recordedEvents = [];
×
213
        this.context.eventManager.emitGraphEvent("input-recording-started", {});
×
214
    }
×
215

216
    /**
217
     * Stop recording input events
218
     * @returns Array of recorded events
219
     */
220
    stopRecording(): RecordedInputEvent[] {
14✔
221
        this.config.recordInput = false;
×
222
        this.context.eventManager.emitGraphEvent("input-recording-stopped", {
×
223
            eventCount: this.recordedEvents.length,
×
224
        });
×
225
        return [... this.recordedEvents];
×
226
    }
×
227

228
    /**
229
     * Start playback of recorded events
230
     * @param events - Optional array of events to play back
231
     * @returns Promise that resolves when playback completes
232
     */
233
    startPlayback(events?: RecordedInputEvent[]): Promise<void> {
14✔
234
        if (events) {
×
235
            this.recordedEvents = events;
×
236
        }
×
237

238
        if (this.recordedEvents.length === 0) {
×
239
            throw new Error("No events to playback");
×
240
        }
×
241

242
        // Switch to mock input for playback
243
        if (!(this.inputSystem instanceof MockDeviceInputSystem)) {
×
244
            this.inputSystem.dispose();
×
245
            this.inputSystem = new MockDeviceInputSystem();
×
246
            this.inputSystem.attach(this.context.canvas as HTMLElement);
×
247
            this.setupEventBridges();
×
248
        }
×
249

250
        this.playbackIndex = 0;
×
251
        this.playbackStartTime = Date.now();
×
252

253
        // Start playback loop and return the promise
254
        return this.runPlayback();
×
255
    }
×
256

257
    /**
258
     * Set up bridges between input system and event manager
259
     */
260
    private setupEventBridges(): void {
14✔
261
        // Only bridge events if enabled
262
        const createBridge = <T>(
925✔
263
            observable: Observable<T>,
8,325✔
264
            eventName: string,
8,325✔
265
            shouldRecord = true,
8,325✔
266
        ): void => {
8,325✔
267
            observable.add((data) => {
8,325✔
268
                if (!this.enabled) {
138✔
269
                    return;
1✔
270
                }
1✔
271

272
                // Record event if recording
273
                if (this.config.recordInput && shouldRecord) {
138✔
274
                    this.recordedEvents.push({
×
275
                        timestamp: Date.now(),
×
276
                        type: eventName,
×
277
                        data: this.serializeEventData(data),
×
278
                    });
×
279
                }
✔
280

281
                // Emit through event manager
282
                const eventData = this.serializeEventData(data);
137✔
283
                this.context.eventManager.emitGraphEvent(eventName, eventData);
137✔
284
            });
8,325✔
285
        };
8,325✔
286

287
        // Bridge all input events
288
        createBridge(this.onPointerMove, "input:pointer-move");
925✔
289
        createBridge(this.onPointerDown, "input:pointer-down");
925✔
290
        createBridge(this.onPointerUp, "input:pointer-up");
925✔
291
        createBridge(this.onWheel, "input:wheel");
925✔
292
        createBridge(this.onTouchStart, "input:touch-start");
925✔
293
        createBridge(this.onTouchMove, "input:touch-move");
925✔
294
        createBridge(this.onTouchEnd, "input:touch-end");
925✔
295
        createBridge(this.onKeyDown, "input:key-down");
925✔
296
        createBridge(this.onKeyUp, "input:key-up");
925✔
297

298
        // Special handling for keyboard shortcuts
299
        this.onKeyDown.add((info) => {
925✔
300
            if (!this.enabled) {
44✔
301
                return;
×
302
            }
×
303

304
            // Emit specific shortcut events
305
            if (info.ctrlKey && info.key === "z") {
44✔
306
                this.context.eventManager.emitGraphEvent("input:undo", {});
1✔
307
            } else if (info.ctrlKey && info.key === "y") {
44✔
308
                this.context.eventManager.emitGraphEvent("input:redo", {});
1✔
309
            } else if (info.ctrlKey && info.key === "a") {
42✔
310
                this.context.eventManager.emitGraphEvent("input:select-all", {});
1✔
311
            }
1✔
312
            // Add more shortcuts as needed
313
        });
925✔
314
    }
925✔
315

316
    /**
317
     * Serialize event data for recording
318
     * @param data - Event data to serialize
319
     * @returns Serialized event data as a plain object
320
     */
321
    private serializeEventData(data: unknown): Record<string, unknown> {
14✔
322
        // Handle Vector2 objects
323
        if (data && typeof data === "object" && "x" in data && "y" in data &&
533✔
324
            typeof data.x === "number" && typeof data.y === "number") {
533✔
325
            return {x: data.x, y: data.y};
72✔
326
        }
72✔
327

328
        // Handle arrays
329
        if (Array.isArray(data)) {
504!
330
            return {array: data.map((item) => this.serializeEventData(item))};
1✔
331
        }
1✔
332

333
        // Handle objects
334
        if (data && typeof data === "object") {
533✔
335
            const serialized: Record<string, unknown> = {};
66✔
336
            for (const key in data) {
66✔
337
                if (Object.prototype.hasOwnProperty.call(data, key)) {
394✔
338
                    serialized[key] = this.serializeEventData((data as Record<string, unknown>)[key]);
394✔
339
                }
394✔
340
            }
394✔
341
            return serialized;
66✔
342
        }
66✔
343

344
        // Primitives - wrap in object
345
        return {value: data};
394✔
346
    }
533✔
347

348
    /**
349
     * Run playback of recorded events
350
     */
351
    private async runPlayback(): Promise<void> {
14✔
352
        const mockSystem = this.inputSystem as MockDeviceInputSystem;
×
353

354
        while (this.playbackIndex < this.recordedEvents.length) {
×
355
            const event = this.recordedEvents[this.playbackIndex];
×
356
            const elapsed = Date.now() - this.playbackStartTime;
×
357
            const eventTime = event.timestamp - this.recordedEvents[0].timestamp;
×
358

359
            // Wait until it's time for this event
360
            if (elapsed < eventTime) {
×
361
                await new Promise((resolve) => setTimeout(resolve, eventTime - elapsed));
×
362
            }
×
363

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

398
            this.playbackIndex++;
×
399
        }
×
400

401
        this.context.eventManager.emitGraphEvent("input-playback-completed", {});
×
402
    }
×
403

404
    /**
405
     * Load playback file
406
     * @param filename - Path or URL to the playback file
407
     */
408
    private async loadPlaybackFile(filename: string): Promise<void> {
14✔
409
        try {
×
410
            const response = await fetch(filename);
×
411
            const data = await response.json();
×
412
            this.recordedEvents = data.events ?? [];
×
413
        } catch (error) {
×
414
            console.warn(`Failed to load playback file: ${filename}`, error);
×
415
        }
×
416
    }
×
417

418
    /**
419
     * Save recorded events (implementation depends on environment)
420
     */
421
    private saveRecordedEvents(): void {
14✔
422
        const data = {
×
423
            version: "1.0",
×
424
            timestamp: new Date().toISOString(),
×
425
            events: this.recordedEvents,
×
426
        };
×
427

428
        // In browser, download as file
429
        if (typeof window !== "undefined") {
×
430
            const blob = new Blob([JSON.stringify(data, null, 2)], {
×
431
                type: "application/json",
×
432
            });
×
433
            const url = URL.createObjectURL(blob);
×
434
            const a = document.createElement("a");
×
435
            a.href = url;
×
436
            a.download = `input-recording-${Date.now()}.json`;
×
437
            a.click();
×
438
            URL.revokeObjectURL(url);
×
439
        }
×
440
    }
×
441

442
    /**
443
     * Update configuration
444
     * @param config - Partial configuration to merge with existing config
445
     */
446
    updateConfig(config: Partial<InputManagerConfig>): void {
14✔
447
        Object.assign(this.config, config);
1✔
448

449
        // Handle specific config changes
450
        if ("touchEnabled" in config || "keyboardEnabled" in config) {
1!
451
            // These would affect input system behavior
452
            this.context.eventManager.emitGraphEvent("input-config-updated", config);
1✔
453
        }
1✔
454
    }
1✔
455

456
    /**
457
     * Enable pointer lock for FPS-style controls
458
     */
459
    async requestPointerLock(): Promise<void> {
14✔
460
        if (this.config.pointerLockEnabled) {
×
461
            try {
×
462
                await this.context.canvas.requestPointerLock();
×
463
                this.context.eventManager.emitGraphEvent("input-pointer-lock-changed", {
×
464
                    locked: true,
×
465
                });
×
466
            } catch (error) {
×
467
                console.warn("Failed to request pointer lock:", error);
×
468
            }
×
469
        }
×
470
    }
×
471

472
    /**
473
     * Exit pointer lock
474
     */
475
    exitPointerLock(): void {
14✔
476
        if (document.pointerLockElement === this.context.canvas) {
×
477
            document.exitPointerLock();
×
478
            this.context.eventManager.emitGraphEvent("input-pointer-lock-changed", {
×
479
                locked: false,
×
480
            });
×
481
        }
×
482
    }
×
483
}
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