@aurigax-ai/ghostty-web 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1116 @@
1
+ /**
2
+ * InputHandler - Converts browser keyboard events to terminal input
3
+ *
4
+ * Handles:
5
+ * - Keyboard event listening on a container element
6
+ * - Mapping KeyboardEvent.code to USB HID Key codes
7
+ * - Extracting modifier keys (Ctrl, Alt, Shift, Meta)
8
+ * - Encoding keys using Ghostty's KeyEncoder
9
+ * - Emitting data for Terminal to send to PTY
10
+ *
11
+ * Limitations:
12
+ * - Captures all keyboard input (preventDefault on everything)
13
+ */
14
+
15
+ import type { Ghostty } from './ghostty';
16
+ import type { KeyEncoder } from './ghostty';
17
+ import type { IKeyEvent } from './interfaces';
18
+ import { Key, KeyAction, KeyEncoderOption, Mods } from './types';
19
+
20
+ /**
21
+ * Map KeyboardEvent.code values to USB HID Key enum values
22
+ * Based on: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
23
+ */
24
+ const KEY_MAP: Record<string, Key> = {
25
+ // Letters
26
+ KeyA: Key.A,
27
+ KeyB: Key.B,
28
+ KeyC: Key.C,
29
+ KeyD: Key.D,
30
+ KeyE: Key.E,
31
+ KeyF: Key.F,
32
+ KeyG: Key.G,
33
+ KeyH: Key.H,
34
+ KeyI: Key.I,
35
+ KeyJ: Key.J,
36
+ KeyK: Key.K,
37
+ KeyL: Key.L,
38
+ KeyM: Key.M,
39
+ KeyN: Key.N,
40
+ KeyO: Key.O,
41
+ KeyP: Key.P,
42
+ KeyQ: Key.Q,
43
+ KeyR: Key.R,
44
+ KeyS: Key.S,
45
+ KeyT: Key.T,
46
+ KeyU: Key.U,
47
+ KeyV: Key.V,
48
+ KeyW: Key.W,
49
+ KeyX: Key.X,
50
+ KeyY: Key.Y,
51
+ KeyZ: Key.Z,
52
+
53
+ // Numbers
54
+ Digit1: Key.ONE,
55
+ Digit2: Key.TWO,
56
+ Digit3: Key.THREE,
57
+ Digit4: Key.FOUR,
58
+ Digit5: Key.FIVE,
59
+ Digit6: Key.SIX,
60
+ Digit7: Key.SEVEN,
61
+ Digit8: Key.EIGHT,
62
+ Digit9: Key.NINE,
63
+ Digit0: Key.ZERO,
64
+
65
+ // Special keys
66
+ Enter: Key.ENTER,
67
+ Escape: Key.ESCAPE,
68
+ Backspace: Key.BACKSPACE,
69
+ Tab: Key.TAB,
70
+ Space: Key.SPACE,
71
+
72
+ // Punctuation
73
+ Minus: Key.MINUS,
74
+ Equal: Key.EQUAL,
75
+ BracketLeft: Key.BRACKET_LEFT,
76
+ BracketRight: Key.BRACKET_RIGHT,
77
+ Backslash: Key.BACKSLASH,
78
+ Semicolon: Key.SEMICOLON,
79
+ Quote: Key.QUOTE,
80
+ Backquote: Key.GRAVE,
81
+ Comma: Key.COMMA,
82
+ Period: Key.PERIOD,
83
+ Slash: Key.SLASH,
84
+
85
+ // Function keys
86
+ CapsLock: Key.CAPS_LOCK,
87
+ F1: Key.F1,
88
+ F2: Key.F2,
89
+ F3: Key.F3,
90
+ F4: Key.F4,
91
+ F5: Key.F5,
92
+ F6: Key.F6,
93
+ F7: Key.F7,
94
+ F8: Key.F8,
95
+ F9: Key.F9,
96
+ F10: Key.F10,
97
+ F11: Key.F11,
98
+ F12: Key.F12,
99
+
100
+ // Special function keys
101
+ PrintScreen: Key.PRINT_SCREEN,
102
+ ScrollLock: Key.SCROLL_LOCK,
103
+ Pause: Key.PAUSE,
104
+ Insert: Key.INSERT,
105
+ Home: Key.HOME,
106
+ PageUp: Key.PAGE_UP,
107
+ Delete: Key.DELETE,
108
+ End: Key.END,
109
+ PageDown: Key.PAGE_DOWN,
110
+
111
+ // Arrow keys
112
+ ArrowRight: Key.RIGHT,
113
+ ArrowLeft: Key.LEFT,
114
+ ArrowDown: Key.DOWN,
115
+ ArrowUp: Key.UP,
116
+
117
+ // Keypad
118
+ NumLock: Key.NUM_LOCK,
119
+ NumpadDivide: Key.KP_DIVIDE,
120
+ NumpadMultiply: Key.KP_MULTIPLY,
121
+ NumpadSubtract: Key.KP_MINUS,
122
+ NumpadAdd: Key.KP_PLUS,
123
+ NumpadEnter: Key.KP_ENTER,
124
+ Numpad1: Key.KP_1,
125
+ Numpad2: Key.KP_2,
126
+ Numpad3: Key.KP_3,
127
+ Numpad4: Key.KP_4,
128
+ Numpad5: Key.KP_5,
129
+ Numpad6: Key.KP_6,
130
+ Numpad7: Key.KP_7,
131
+ Numpad8: Key.KP_8,
132
+ Numpad9: Key.KP_9,
133
+ Numpad0: Key.KP_0,
134
+ NumpadDecimal: Key.KP_PERIOD,
135
+
136
+ // International
137
+ IntlBackslash: Key.INTL_BACKSLASH,
138
+ ContextMenu: Key.CONTEXT_MENU,
139
+
140
+ // Additional function keys
141
+ F13: Key.F13,
142
+ F14: Key.F14,
143
+ F15: Key.F15,
144
+ F16: Key.F16,
145
+ F17: Key.F17,
146
+ F18: Key.F18,
147
+ F19: Key.F19,
148
+ F20: Key.F20,
149
+ F21: Key.F21,
150
+ F22: Key.F22,
151
+ F23: Key.F23,
152
+ F24: Key.F24,
153
+ };
154
+
155
+ /**
156
+ * InputHandler class
157
+ * Attaches keyboard event listeners to a container and converts
158
+ * keyboard events to terminal input data
159
+ */
160
+ /**
161
+ * Mouse tracking configuration
162
+ */
163
+ export interface MouseTrackingConfig {
164
+ /** Check if any mouse tracking mode is enabled */
165
+ hasMouseTracking: () => boolean;
166
+ /** Check if SGR extended mouse mode is enabled (mode 1006) */
167
+ hasSgrMouseMode: () => boolean;
168
+ /** Get cell dimensions for pixel to cell conversion */
169
+ getCellDimensions: () => { width: number; height: number };
170
+ /** Get canvas/container offset for accurate position calculation */
171
+ getCanvasOffset: () => { left: number; top: number };
172
+ }
173
+
174
+ export class InputHandler {
175
+ private encoder: KeyEncoder;
176
+ private container: HTMLElement;
177
+ private inputElement?: HTMLElement;
178
+ private onDataCallback: (data: string) => void;
179
+ private onKeyCallback?: (keyEvent: IKeyEvent) => void;
180
+ private customKeyEventHandler?: (event: KeyboardEvent) => boolean;
181
+ private getModeCallback?: (mode: number) => boolean;
182
+ private onCopyCallback?: () => boolean;
183
+ private mouseConfig?: MouseTrackingConfig;
184
+ private keydownListener: ((e: KeyboardEvent) => void) | null = null;
185
+ private keypressListener: ((e: KeyboardEvent) => void) | null = null;
186
+ private pasteListener: ((e: ClipboardEvent) => void) | null = null;
187
+ private beforeInputListener: ((e: InputEvent) => void) | null = null;
188
+ private containerBeforeInputListener: ((e: InputEvent) => void) | null = null;
189
+ private compositionStartListener: ((e: CompositionEvent) => void) | null = null;
190
+ private compositionUpdateListener: ((e: CompositionEvent) => void) | null = null;
191
+ private compositionEndListener: ((e: CompositionEvent) => void) | null = null;
192
+ private mousedownListener: ((e: MouseEvent) => void) | null = null;
193
+ private mouseupListener: ((e: MouseEvent) => void) | null = null;
194
+ private mousemoveListener: ((e: MouseEvent) => void) | null = null;
195
+ private wheelListener: ((e: WheelEvent) => void) | null = null;
196
+ private isComposing = false;
197
+ private isDisposed = false;
198
+ private mouseButtonsPressed = 0; // Track which buttons are pressed for motion reporting
199
+ private lastKeyDownData: string | null = null;
200
+ private lastKeyDownTime = 0;
201
+ private lastPasteData: string | null = null;
202
+ private lastPasteTime = 0;
203
+ private lastPasteSource: 'paste' | 'beforeinput' | null = null;
204
+ private lastCompositionData: string | null = null;
205
+ private lastCompositionTime = 0;
206
+ private lastBeforeInputData: string | null = null;
207
+ private lastBeforeInputTime = 0;
208
+ private static readonly BEFORE_INPUT_IGNORE_MS = 100;
209
+
210
+ /**
211
+ * Create a new InputHandler
212
+ * @param ghostty - Ghostty instance (for creating KeyEncoder)
213
+ * @param container - DOM element to attach listeners to
214
+ * @param onData - Callback for terminal data (escape sequences to send to PTY)
215
+ * @param onBell - Callback for bell/beep event
216
+ * @param onKey - Optional callback for raw key events
217
+ * @param customKeyEventHandler - Optional custom key event handler
218
+ * @param getMode - Optional callback to query terminal mode state (for application cursor mode)
219
+ * @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection)
220
+ * @param inputElement - Optional input element for beforeinput events
221
+ * @param mouseConfig - Optional mouse tracking configuration
222
+ */
223
+ constructor(
224
+ ghostty: Ghostty,
225
+ container: HTMLElement,
226
+ onData: (data: string) => void,
227
+ _onBell: () => void,
228
+ onKey?: (keyEvent: IKeyEvent) => void,
229
+ customKeyEventHandler?: (event: KeyboardEvent) => boolean,
230
+ getMode?: (mode: number) => boolean,
231
+ onCopy?: () => boolean,
232
+ inputElement?: HTMLElement,
233
+ mouseConfig?: MouseTrackingConfig
234
+ ) {
235
+ this.encoder = ghostty.createKeyEncoder();
236
+ this.container = container;
237
+ this.inputElement = inputElement;
238
+ this.onDataCallback = onData;
239
+ this.onKeyCallback = onKey;
240
+ this.customKeyEventHandler = customKeyEventHandler;
241
+ this.getModeCallback = getMode;
242
+ this.onCopyCallback = onCopy;
243
+ this.mouseConfig = mouseConfig;
244
+
245
+ // Attach event listeners
246
+ this.attach();
247
+ }
248
+
249
+ /**
250
+ * Set custom key event handler (for runtime updates)
251
+ */
252
+ setCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void {
253
+ this.customKeyEventHandler = handler;
254
+ }
255
+
256
+ /**
257
+ * Attach keyboard event listeners to container
258
+ */
259
+ private attach(): void {
260
+ // Make container focusable so it can receive keyboard events (browser only)
261
+ if (
262
+ typeof this.container.hasAttribute === 'function' &&
263
+ typeof this.container.setAttribute === 'function'
264
+ ) {
265
+ if (!this.container.hasAttribute('tabindex')) {
266
+ this.container.setAttribute('tabindex', '0');
267
+ }
268
+
269
+ // Add visual focus indication (only if style exists - for browser environments)
270
+ if (this.container.style) {
271
+ this.container.style.outline = 'none'; // Remove default outline
272
+ }
273
+ }
274
+
275
+ this.keydownListener = this.handleKeyDown.bind(this);
276
+ this.container.addEventListener('keydown', this.keydownListener);
277
+
278
+ this.pasteListener = this.handlePaste.bind(this);
279
+ this.container.addEventListener('paste', this.pasteListener);
280
+ if (this.inputElement && this.inputElement !== this.container) {
281
+ this.inputElement.addEventListener('paste', this.pasteListener);
282
+ }
283
+
284
+ if (this.inputElement) {
285
+ this.beforeInputListener = this.handleBeforeInput.bind(this);
286
+ this.inputElement.addEventListener('beforeinput', this.beforeInputListener);
287
+ }
288
+
289
+ if (this.inputElement !== this.container) {
290
+ this.containerBeforeInputListener = (event: InputEvent) => {
291
+ if (event.target === this.container) this.handleBeforeInput(event);
292
+ };
293
+ this.container.addEventListener('beforeinput', this.containerBeforeInputListener);
294
+ }
295
+
296
+ this.compositionStartListener = this.handleCompositionStart.bind(this);
297
+ this.container.addEventListener('compositionstart', this.compositionStartListener);
298
+
299
+ this.compositionUpdateListener = this.handleCompositionUpdate.bind(this);
300
+ this.container.addEventListener('compositionupdate', this.compositionUpdateListener);
301
+
302
+ this.compositionEndListener = this.handleCompositionEnd.bind(this);
303
+ this.container.addEventListener('compositionend', this.compositionEndListener);
304
+
305
+ // Mouse event listeners (for terminal mouse tracking)
306
+ this.mousedownListener = this.handleMouseDown.bind(this);
307
+ this.container.addEventListener('mousedown', this.mousedownListener);
308
+
309
+ this.mouseupListener = this.handleMouseUp.bind(this);
310
+ this.container.addEventListener('mouseup', this.mouseupListener);
311
+
312
+ this.mousemoveListener = this.handleMouseMove.bind(this);
313
+ this.container.addEventListener('mousemove', this.mousemoveListener);
314
+
315
+ this.wheelListener = this.handleWheel.bind(this);
316
+ this.container.addEventListener('wheel', this.wheelListener, { passive: false });
317
+ }
318
+
319
+ /**
320
+ * Map KeyboardEvent.code to USB HID Key enum value
321
+ * @param code - KeyboardEvent.code value
322
+ * @returns Key enum value or null if unmapped
323
+ */
324
+ private mapKeyCode(code: string): Key | null {
325
+ return KEY_MAP[code] ?? null;
326
+ }
327
+
328
+ /**
329
+ * Extract modifier flags from KeyboardEvent
330
+ * @param event - KeyboardEvent
331
+ * @returns Mods flags
332
+ */
333
+ private extractModifiers(event: KeyboardEvent): Mods {
334
+ let mods = Mods.NONE;
335
+
336
+ if (event.shiftKey) mods |= Mods.SHIFT;
337
+ if (event.ctrlKey) mods |= Mods.CTRL;
338
+ if (event.altKey) mods |= Mods.ALT;
339
+ if (event.metaKey) mods |= Mods.SUPER;
340
+
341
+ // Note: CapsLock and NumLock are not in KeyboardEvent modifiers
342
+ // They would need to be tracked separately if needed
343
+ // For now, we don't set CAPSLOCK or NUMLOCK flags
344
+
345
+ return mods;
346
+ }
347
+
348
+ /**
349
+ * Check if this is a printable character with no special modifiers
350
+ * @param event - KeyboardEvent
351
+ * @returns true if printable character
352
+ */
353
+ private isPrintableCharacter(event: KeyboardEvent): boolean {
354
+ // If Ctrl, Alt, or Meta (Cmd on Mac) is pressed, it's not a simple printable character
355
+ // Exception: AltGr (Ctrl+Alt on some keyboards) can produce printable characters
356
+ if (event.ctrlKey && !event.altKey) return false;
357
+ if (event.altKey && !event.ctrlKey) return false;
358
+ if (event.metaKey) return false; // Cmd key on Mac
359
+
360
+ // If key produces a single printable character
361
+ return event.key.length === 1;
362
+ }
363
+
364
+ /**
365
+ * Handle keydown event
366
+ * @param event - KeyboardEvent
367
+ */
368
+ private handleKeyDown(event: KeyboardEvent): void {
369
+ if (this.isDisposed) return;
370
+
371
+ // Ignore keydown events during composition
372
+ // Note: Some browsers send keyCode 229 for all keys during composition
373
+ if (this.isComposing || event.isComposing || event.keyCode === 229) {
374
+ return;
375
+ }
376
+
377
+ // Emit onKey event first (before any processing)
378
+ if (this.onKeyCallback) {
379
+ this.onKeyCallback({ key: event.key, domEvent: event });
380
+ }
381
+
382
+ // Check custom key event handler
383
+ if (this.customKeyEventHandler) {
384
+ const handled = this.customKeyEventHandler(event);
385
+ if (handled) {
386
+ // Custom handler consumed the event
387
+ event.preventDefault();
388
+ return;
389
+ }
390
+ }
391
+
392
+ // Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault)
393
+ if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') {
394
+ // Let the browser's native paste event fire
395
+ return;
396
+ }
397
+
398
+ // Handle Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt)
399
+ // Note: Ctrl+C on all platforms sends interrupt signal (0x03)
400
+ if (event.metaKey && event.code === 'KeyC') {
401
+ // Try to copy selection via callback
402
+ // If there's a selection and copy succeeds, prevent default
403
+ // If no selection, let it fall through (browser may have other text selected)
404
+ if (this.onCopyCallback && this.onCopyCallback()) {
405
+ event.preventDefault();
406
+ }
407
+ return;
408
+ }
409
+
410
+ // For printable characters without modifiers, send the character directly
411
+ // This handles: a-z, A-Z (with shift), 0-9, punctuation, etc.
412
+ if (this.isPrintableCharacter(event)) {
413
+ event.preventDefault();
414
+ this.onDataCallback(event.key);
415
+ this.recordKeyDownData(event.key);
416
+ return;
417
+ }
418
+
419
+ // Map the physical key code
420
+ const key = this.mapKeyCode(event.code);
421
+ if (key === null) {
422
+ // Unknown key - ignore it
423
+ return;
424
+ }
425
+
426
+ // Extract modifiers
427
+ const mods = this.extractModifiers(event);
428
+
429
+ // Handle simple special keys that produce standard sequences
430
+ if (mods === Mods.NONE || mods === Mods.SHIFT) {
431
+ let simpleOutput: string | null = null;
432
+
433
+ switch (key) {
434
+ case Key.ENTER:
435
+ simpleOutput = '\r'; // Carriage return
436
+ break;
437
+ case Key.TAB:
438
+ if (mods === Mods.SHIFT) {
439
+ simpleOutput = '\x1b[Z'; // Backtab
440
+ } else {
441
+ simpleOutput = '\t'; // Tab
442
+ }
443
+ break;
444
+ case Key.BACKSPACE:
445
+ simpleOutput = '\x7F'; // DEL (most terminals use 0x7F for backspace)
446
+ break;
447
+ case Key.ESCAPE:
448
+ simpleOutput = '\x1B'; // ESC
449
+ break;
450
+ // Arrow keys are handled by the encoder (respects application cursor mode)
451
+ // Navigation keys
452
+ case Key.HOME:
453
+ simpleOutput = '\x1B[H';
454
+ break;
455
+ case Key.END:
456
+ simpleOutput = '\x1B[F';
457
+ break;
458
+ case Key.INSERT:
459
+ simpleOutput = '\x1B[2~';
460
+ break;
461
+ case Key.DELETE:
462
+ simpleOutput = '\x1B[3~';
463
+ break;
464
+ case Key.PAGE_UP:
465
+ simpleOutput = '\x1B[5~';
466
+ break;
467
+ case Key.PAGE_DOWN:
468
+ simpleOutput = '\x1B[6~';
469
+ break;
470
+ // Function keys
471
+ case Key.F1:
472
+ simpleOutput = '\x1BOP';
473
+ break;
474
+ case Key.F2:
475
+ simpleOutput = '\x1BOQ';
476
+ break;
477
+ case Key.F3:
478
+ simpleOutput = '\x1BOR';
479
+ break;
480
+ case Key.F4:
481
+ simpleOutput = '\x1BOS';
482
+ break;
483
+ case Key.F5:
484
+ simpleOutput = '\x1B[15~';
485
+ break;
486
+ case Key.F6:
487
+ simpleOutput = '\x1B[17~';
488
+ break;
489
+ case Key.F7:
490
+ simpleOutput = '\x1B[18~';
491
+ break;
492
+ case Key.F8:
493
+ simpleOutput = '\x1B[19~';
494
+ break;
495
+ case Key.F9:
496
+ simpleOutput = '\x1B[20~';
497
+ break;
498
+ case Key.F10:
499
+ simpleOutput = '\x1B[21~';
500
+ break;
501
+ case Key.F11:
502
+ simpleOutput = '\x1B[23~';
503
+ break;
504
+ case Key.F12:
505
+ simpleOutput = '\x1B[24~';
506
+ break;
507
+ }
508
+
509
+ if (simpleOutput !== null) {
510
+ event.preventDefault();
511
+ this.onDataCallback(simpleOutput);
512
+ this.recordKeyDownData(simpleOutput);
513
+ return;
514
+ }
515
+ }
516
+
517
+ // Determine action (we only care about PRESS for now, not RELEASE or REPEAT)
518
+ const action = KeyAction.PRESS;
519
+
520
+ // For non-printable keys or keys with modifiers, encode using Ghostty
521
+ try {
522
+ // Sync encoder options with terminal mode state
523
+ // Mode 1 (DECCKM) controls whether arrow keys send CSI or SS3 sequences
524
+ if (this.getModeCallback) {
525
+ const appCursorMode = this.getModeCallback(1);
526
+ this.encoder.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, appCursorMode);
527
+ }
528
+
529
+ // For letter/number keys, even with modifiers, pass the base character
530
+ // This helps the encoder produce correct control sequences (e.g., Ctrl+A = 0x01)
531
+ // For special keys (Enter, Arrow keys, etc.), don't pass utf8
532
+ const utf8 =
533
+ event.key.length === 1 && event.key.charCodeAt(0) < 128
534
+ ? event.key.toLowerCase() // Use lowercase for consistency
535
+ : undefined;
536
+
537
+ const encoded = this.encoder.encode({
538
+ action,
539
+ key,
540
+ mods,
541
+ utf8,
542
+ });
543
+
544
+ // Convert Uint8Array to string
545
+ const decoder = new TextDecoder();
546
+ const data = decoder.decode(encoded);
547
+
548
+ // Prevent default browser behavior
549
+ event.preventDefault();
550
+ event.stopPropagation();
551
+
552
+ // Emit the data
553
+ if (data.length > 0) {
554
+ this.onDataCallback(data);
555
+ this.recordKeyDownData(data);
556
+ }
557
+ } catch (error) {
558
+ // Encoding failed - log but don't crash
559
+ console.warn('Failed to encode key:', event.code, error);
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Handle paste event from clipboard
565
+ * @param event - ClipboardEvent
566
+ */
567
+ private handlePaste(event: ClipboardEvent): void {
568
+ if (this.isDisposed) return;
569
+
570
+ // Prevent default paste behavior
571
+ event.preventDefault();
572
+ event.stopPropagation();
573
+
574
+ // Get clipboard data
575
+ const clipboardData = event.clipboardData;
576
+ if (!clipboardData) {
577
+ console.warn('No clipboard data available');
578
+ return;
579
+ }
580
+
581
+ // Get text from clipboard
582
+ const text = clipboardData.getData('text/plain');
583
+ if (!text) {
584
+ console.warn('No text in clipboard');
585
+ return;
586
+ }
587
+
588
+ if (this.shouldIgnorePasteEvent(text, 'paste')) {
589
+ return;
590
+ }
591
+
592
+ this.emitPasteData(text);
593
+ this.recordPasteData(text, 'paste');
594
+ }
595
+
596
+ /**
597
+ * Handle beforeinput event (mobile/IME input)
598
+ * @param event - InputEvent
599
+ */
600
+ private handleBeforeInput(event: InputEvent): void {
601
+ if (this.isDisposed) return;
602
+
603
+ if (this.isComposing || event.isComposing) {
604
+ return;
605
+ }
606
+
607
+ const inputType = event.inputType;
608
+ const data = event.data ?? '';
609
+ let output: string | null = null;
610
+
611
+ switch (inputType) {
612
+ case 'insertText':
613
+ case 'insertReplacementText':
614
+ output = data.length > 0 ? data.replace(/\n/g, '\r') : null;
615
+ break;
616
+ case 'insertLineBreak':
617
+ case 'insertParagraph':
618
+ output = '\r';
619
+ break;
620
+ case 'deleteContentBackward':
621
+ output = '\x7F';
622
+ break;
623
+ case 'deleteContentForward':
624
+ output = '\x1B[3~';
625
+ break;
626
+ case 'insertFromPaste':
627
+ if (!data) {
628
+ return;
629
+ }
630
+ if (this.shouldIgnorePasteEvent(data, 'beforeinput')) {
631
+ event.preventDefault();
632
+ event.stopPropagation();
633
+ return;
634
+ }
635
+ event.preventDefault();
636
+ event.stopPropagation();
637
+ this.emitPasteData(data);
638
+ this.recordPasteData(data, 'beforeinput');
639
+ return;
640
+ default:
641
+ return;
642
+ }
643
+
644
+ if (!output) {
645
+ return;
646
+ }
647
+
648
+ if (this.shouldIgnoreBeforeInput(output)) {
649
+ event.preventDefault();
650
+ event.stopPropagation();
651
+ return;
652
+ }
653
+
654
+ if (data && this.shouldIgnoreBeforeInputFromComposition(data)) {
655
+ event.preventDefault();
656
+ event.stopPropagation();
657
+ return;
658
+ }
659
+
660
+ event.preventDefault();
661
+ event.stopPropagation();
662
+ this.onDataCallback(output);
663
+ if (data) {
664
+ this.recordBeforeInputData(data);
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Handle compositionstart event
670
+ */
671
+ private handleCompositionStart(_event: CompositionEvent): void {
672
+ if (this.isDisposed) return;
673
+ this.isComposing = true;
674
+ }
675
+
676
+ /**
677
+ * Handle compositionupdate event
678
+ */
679
+ private handleCompositionUpdate(_event: CompositionEvent): void {
680
+ if (this.isDisposed) return;
681
+ // We could track the current composition string here if we wanted to
682
+ // display it in a custom way, but for now we rely on the browser's
683
+ // input method editor UI.
684
+ }
685
+
686
+ /**
687
+ * Handle compositionend event
688
+ */
689
+ private handleCompositionEnd(event: CompositionEvent): void {
690
+ if (this.isDisposed) return;
691
+ this.isComposing = false;
692
+
693
+ const data = event.data;
694
+ if (data && data.length > 0) {
695
+ if (this.shouldIgnoreCompositionEnd(data)) {
696
+ this.cleanupCompositionTextNodes();
697
+ return;
698
+ }
699
+ this.onDataCallback(data);
700
+ this.recordCompositionData(data);
701
+ }
702
+
703
+ this.cleanupCompositionTextNodes();
704
+ }
705
+
706
+ /**
707
+ * Cleanup text nodes in container after composition
708
+ */
709
+ private cleanupCompositionTextNodes(): void {
710
+ // Cleanup text nodes in container (fix for duplicate text display)
711
+ // When the container is contenteditable, the browser might insert text nodes
712
+ // upon composition end. We need to remove them to prevent duplicate display.
713
+ if (this.container && this.container.childNodes) {
714
+ for (let i = this.container.childNodes.length - 1; i >= 0; i--) {
715
+ const node = this.container.childNodes[i];
716
+ // Node.TEXT_NODE === 3
717
+ if (node.nodeType === 3) {
718
+ this.container.removeChild(node);
719
+ }
720
+ }
721
+ }
722
+ }
723
+
724
+ // ==========================================================================
725
+ // Mouse Event Handling (for terminal mouse tracking)
726
+ // ==========================================================================
727
+
728
+ /**
729
+ * Convert pixel coordinates to terminal cell coordinates
730
+ */
731
+ private pixelToCell(event: MouseEvent): { col: number; row: number } | null {
732
+ if (!this.mouseConfig) return null;
733
+
734
+ const dims = this.mouseConfig.getCellDimensions();
735
+ const offset = this.mouseConfig.getCanvasOffset();
736
+
737
+ if (dims.width <= 0 || dims.height <= 0) return null;
738
+
739
+ const x = event.clientX - offset.left;
740
+ const y = event.clientY - offset.top;
741
+
742
+ // Convert to 1-based cell coordinates (terminal uses 1-based)
743
+ const col = Math.floor(x / dims.width) + 1;
744
+ const row = Math.floor(y / dims.height) + 1;
745
+
746
+ // Clamp to valid range (at least 1)
747
+ return {
748
+ col: Math.max(1, col),
749
+ row: Math.max(1, row),
750
+ };
751
+ }
752
+
753
+ /**
754
+ * Get modifier flags for mouse event
755
+ */
756
+ private getMouseModifiers(event: MouseEvent): number {
757
+ let mods = 0;
758
+ if (event.shiftKey) mods |= 4;
759
+ if (event.metaKey) mods |= 8; // Meta (Cmd on Mac)
760
+ if (event.ctrlKey) mods |= 16;
761
+ return mods;
762
+ }
763
+
764
+ /**
765
+ * Encode mouse event as SGR sequence
766
+ * SGR format: \x1b[<Btn;Col;RowM (press/motion) or \x1b[<Btn;Col;Rowm (release)
767
+ */
768
+ private encodeMouseSGR(
769
+ button: number,
770
+ col: number,
771
+ row: number,
772
+ isRelease: boolean,
773
+ modifiers: number
774
+ ): string {
775
+ const btn = button + modifiers;
776
+ const suffix = isRelease ? 'm' : 'M';
777
+ return `\x1b[<${btn};${col};${row}${suffix}`;
778
+ }
779
+
780
+ /**
781
+ * Encode mouse event as X10/normal sequence (legacy format)
782
+ * Format: \x1b[M<Btn+32><Col+32><Row+32>
783
+ */
784
+ private encodeMouseX10(button: number, col: number, row: number, modifiers: number): string {
785
+ // X10 format adds 32 to all values and encodes as characters
786
+ // Button encoding: 0=left, 1=middle, 2=right, 3=release
787
+ const btn = button + modifiers + 32;
788
+ const colChar = String.fromCharCode(Math.min(col + 32, 255));
789
+ const rowChar = String.fromCharCode(Math.min(row + 32, 255));
790
+ return `\x1b[M${String.fromCharCode(btn)}${colChar}${rowChar}`;
791
+ }
792
+
793
+ /**
794
+ * Send mouse event to terminal
795
+ */
796
+ private sendMouseEvent(
797
+ button: number,
798
+ col: number,
799
+ row: number,
800
+ isRelease: boolean,
801
+ event: MouseEvent
802
+ ): void {
803
+ const modifiers = this.getMouseModifiers(event);
804
+
805
+ // Check if SGR extended mode is enabled (mode 1006)
806
+ const useSGR = this.mouseConfig?.hasSgrMouseMode?.() ?? true;
807
+
808
+ let sequence: string;
809
+ if (useSGR) {
810
+ sequence = this.encodeMouseSGR(button, col, row, isRelease, modifiers);
811
+ } else {
812
+ // X10/normal mode doesn't support release events directly
813
+ // Button 3 means release in X10 mode
814
+ const x10Button = isRelease ? 3 : button;
815
+ sequence = this.encodeMouseX10(x10Button, col, row, modifiers);
816
+ }
817
+
818
+ this.onDataCallback(sequence);
819
+ }
820
+
821
+ /**
822
+ * Handle mousedown event
823
+ */
824
+ private handleMouseDown(event: MouseEvent): void {
825
+ if (this.isDisposed) return;
826
+ if (!this.mouseConfig?.hasMouseTracking()) return;
827
+
828
+ const cell = this.pixelToCell(event);
829
+ if (!cell) return;
830
+
831
+ // Map browser button to terminal button
832
+ // event.button: 0=left, 1=middle, 2=right
833
+ // Terminal: 0=left, 1=middle, 2=right
834
+ const button = event.button;
835
+
836
+ // Track pressed buttons for motion events
837
+ this.mouseButtonsPressed |= 1 << button;
838
+
839
+ this.sendMouseEvent(button, cell.col, cell.row, false, event);
840
+
841
+ // Don't prevent default - let SelectionManager handle selection
842
+ // Only prevent if we actually handled the event
843
+ // event.preventDefault();
844
+ }
845
+
846
+ /**
847
+ * Handle mouseup event
848
+ */
849
+ private handleMouseUp(event: MouseEvent): void {
850
+ if (this.isDisposed) return;
851
+ if (!this.mouseConfig?.hasMouseTracking()) return;
852
+
853
+ const cell = this.pixelToCell(event);
854
+ if (!cell) return;
855
+
856
+ const button = event.button;
857
+
858
+ // Clear pressed button
859
+ this.mouseButtonsPressed &= ~(1 << button);
860
+
861
+ this.sendMouseEvent(button, cell.col, cell.row, true, event);
862
+ }
863
+
864
+ /**
865
+ * Handle mousemove event
866
+ */
867
+ private handleMouseMove(event: MouseEvent): void {
868
+ if (this.isDisposed) return;
869
+ if (!this.mouseConfig?.hasMouseTracking()) return;
870
+
871
+ // Check if button motion mode or any-event tracking is enabled
872
+ // Mode 1002 = button motion, Mode 1003 = any motion
873
+ const hasButtonMotion = this.getModeCallback?.(1002) ?? false;
874
+ const hasAnyMotion = this.getModeCallback?.(1003) ?? false;
875
+
876
+ if (!hasButtonMotion && !hasAnyMotion) return;
877
+
878
+ // In button motion mode, only report if a button is pressed
879
+ if (hasButtonMotion && !hasAnyMotion && this.mouseButtonsPressed === 0) return;
880
+
881
+ const cell = this.pixelToCell(event);
882
+ if (!cell) return;
883
+
884
+ // Determine which button to report (or 32 for motion with no button)
885
+ let button = 32; // Motion flag
886
+ if (this.mouseButtonsPressed & 1)
887
+ button += 0; // Left
888
+ else if (this.mouseButtonsPressed & 2)
889
+ button += 1; // Middle
890
+ else if (this.mouseButtonsPressed & 4) button += 2; // Right
891
+
892
+ this.sendMouseEvent(button, cell.col, cell.row, false, event);
893
+ }
894
+
895
+ /**
896
+ * Handle wheel event (scroll)
897
+ */
898
+ private handleWheel(event: WheelEvent): void {
899
+ if (this.isDisposed) return;
900
+ if (!this.mouseConfig?.hasMouseTracking()) return;
901
+
902
+ const cell = this.pixelToCell(event);
903
+ if (!cell) return;
904
+
905
+ // Wheel events: button 64 = scroll up, button 65 = scroll down
906
+ const button = event.deltaY < 0 ? 64 : 65;
907
+
908
+ this.sendMouseEvent(button, cell.col, cell.row, false, event);
909
+
910
+ // Prevent default scrolling when mouse tracking is active
911
+ event.preventDefault();
912
+ }
913
+
914
+ /**
915
+ * Emit paste data with bracketed paste support
916
+ */
917
+ private emitPasteData(text: string): void {
918
+ const hasBracketedPaste = this.getModeCallback?.(2004) ?? false;
919
+
920
+ if (hasBracketedPaste) {
921
+ this.onDataCallback('\x1b[200~' + text + '\x1b[201~');
922
+ } else {
923
+ this.onDataCallback(text);
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Record keydown data for beforeinput de-duplication
929
+ */
930
+ private recordKeyDownData(data: string): void {
931
+ this.lastKeyDownData = data;
932
+ this.lastKeyDownTime = this.getNow();
933
+ }
934
+
935
+ /**
936
+ * Record paste data for beforeinput de-duplication
937
+ */
938
+ private recordPasteData(data: string, source: 'paste' | 'beforeinput'): void {
939
+ this.lastPasteData = data;
940
+ this.lastPasteTime = this.getNow();
941
+ this.lastPasteSource = source;
942
+ }
943
+
944
+ /**
945
+ * Check if beforeinput should be ignored due to a recent keydown
946
+ */
947
+ private shouldIgnoreBeforeInput(data: string): boolean {
948
+ if (!this.lastKeyDownData) {
949
+ return false;
950
+ }
951
+ const now = this.getNow();
952
+ const isDuplicate =
953
+ now - this.lastKeyDownTime < InputHandler.BEFORE_INPUT_IGNORE_MS &&
954
+ this.lastKeyDownData === data;
955
+ this.lastKeyDownData = null;
956
+ return isDuplicate;
957
+ }
958
+
959
+ /**
960
+ * Check if beforeinput text should be ignored due to a recent composition end
961
+ */
962
+ private shouldIgnoreBeforeInputFromComposition(data: string): boolean {
963
+ if (!this.lastCompositionData) {
964
+ return false;
965
+ }
966
+ const now = this.getNow();
967
+ const isDuplicate =
968
+ now - this.lastCompositionTime < InputHandler.BEFORE_INPUT_IGNORE_MS &&
969
+ this.lastCompositionData === data;
970
+ if (isDuplicate) {
971
+ this.lastCompositionData = null;
972
+ }
973
+ return isDuplicate;
974
+ }
975
+
976
+ /**
977
+ * Check if composition end should be ignored due to a recent beforeinput text
978
+ */
979
+ private shouldIgnoreCompositionEnd(data: string): boolean {
980
+ if (!this.lastBeforeInputData) {
981
+ return false;
982
+ }
983
+ const now = this.getNow();
984
+ const isDuplicate =
985
+ now - this.lastBeforeInputTime < InputHandler.BEFORE_INPUT_IGNORE_MS &&
986
+ this.lastBeforeInputData === data;
987
+ if (isDuplicate) {
988
+ this.lastBeforeInputData = null;
989
+ }
990
+ return isDuplicate;
991
+ }
992
+
993
+ /**
994
+ * Record beforeinput text for composition de-duplication
995
+ */
996
+ private recordBeforeInputData(data: string): void {
997
+ this.lastBeforeInputData = data;
998
+ this.lastBeforeInputTime = this.getNow();
999
+ }
1000
+
1001
+ /**
1002
+ * Record composition end data for beforeinput de-duplication
1003
+ */
1004
+ private recordCompositionData(data: string): void {
1005
+ this.lastCompositionData = data;
1006
+ this.lastCompositionTime = this.getNow();
1007
+ }
1008
+
1009
+ /**
1010
+ * Check if paste should be ignored due to a recent paste event from another source
1011
+ */
1012
+ private shouldIgnorePasteEvent(data: string, source: 'paste' | 'beforeinput'): boolean {
1013
+ if (!this.lastPasteData) {
1014
+ return false;
1015
+ }
1016
+ if (this.lastPasteSource === source) {
1017
+ return false;
1018
+ }
1019
+ const now = this.getNow();
1020
+ const isDuplicate =
1021
+ now - this.lastPasteTime < InputHandler.BEFORE_INPUT_IGNORE_MS && this.lastPasteData === data;
1022
+ if (isDuplicate) {
1023
+ this.lastPasteData = null;
1024
+ this.lastPasteSource = null;
1025
+ }
1026
+ return isDuplicate;
1027
+ }
1028
+
1029
+ /**
1030
+ * Get current time in milliseconds
1031
+ */
1032
+ private getNow(): number {
1033
+ return typeof performance !== 'undefined' && typeof performance.now === 'function'
1034
+ ? performance.now()
1035
+ : Date.now();
1036
+ }
1037
+
1038
+ /**
1039
+ * Dispose the InputHandler and remove event listeners
1040
+ */
1041
+ dispose(): void {
1042
+ if (this.isDisposed) return;
1043
+
1044
+ if (this.keydownListener) {
1045
+ this.container.removeEventListener('keydown', this.keydownListener);
1046
+ this.keydownListener = null;
1047
+ }
1048
+
1049
+ if (this.keypressListener) {
1050
+ this.container.removeEventListener('keypress', this.keypressListener);
1051
+ this.keypressListener = null;
1052
+ }
1053
+
1054
+ if (this.pasteListener) {
1055
+ this.container.removeEventListener('paste', this.pasteListener);
1056
+ if (this.inputElement && this.inputElement !== this.container) {
1057
+ this.inputElement.removeEventListener('paste', this.pasteListener);
1058
+ }
1059
+ this.pasteListener = null;
1060
+ }
1061
+
1062
+ if (this.beforeInputListener && this.inputElement) {
1063
+ this.inputElement.removeEventListener('beforeinput', this.beforeInputListener);
1064
+ this.beforeInputListener = null;
1065
+ }
1066
+
1067
+ if (this.containerBeforeInputListener) {
1068
+ this.container.removeEventListener('beforeinput', this.containerBeforeInputListener);
1069
+ this.containerBeforeInputListener = null;
1070
+ }
1071
+
1072
+ if (this.compositionStartListener) {
1073
+ this.container.removeEventListener('compositionstart', this.compositionStartListener);
1074
+ this.compositionStartListener = null;
1075
+ }
1076
+
1077
+ if (this.compositionUpdateListener) {
1078
+ this.container.removeEventListener('compositionupdate', this.compositionUpdateListener);
1079
+ this.compositionUpdateListener = null;
1080
+ }
1081
+
1082
+ if (this.compositionEndListener) {
1083
+ this.container.removeEventListener('compositionend', this.compositionEndListener);
1084
+ this.compositionEndListener = null;
1085
+ }
1086
+
1087
+ if (this.mousedownListener) {
1088
+ this.container.removeEventListener('mousedown', this.mousedownListener);
1089
+ this.mousedownListener = null;
1090
+ }
1091
+
1092
+ if (this.mouseupListener) {
1093
+ this.container.removeEventListener('mouseup', this.mouseupListener);
1094
+ this.mouseupListener = null;
1095
+ }
1096
+
1097
+ if (this.mousemoveListener) {
1098
+ this.container.removeEventListener('mousemove', this.mousemoveListener);
1099
+ this.mousemoveListener = null;
1100
+ }
1101
+
1102
+ if (this.wheelListener) {
1103
+ this.container.removeEventListener('wheel', this.wheelListener);
1104
+ this.wheelListener = null;
1105
+ }
1106
+
1107
+ this.isDisposed = true;
1108
+ }
1109
+
1110
+ /**
1111
+ * Check if handler is disposed
1112
+ */
1113
+ isActive(): boolean {
1114
+ return !this.isDisposed;
1115
+ }
1116
+ }