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