@next_term/web 0.1.0-next.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.
Files changed (61) hide show
  1. package/dist/accessibility.d.ts +46 -0
  2. package/dist/accessibility.d.ts.map +1 -0
  3. package/dist/accessibility.js +196 -0
  4. package/dist/accessibility.js.map +1 -0
  5. package/dist/addon.d.ts.map +1 -0
  6. package/dist/addon.js +2 -0
  7. package/dist/addon.js.map +1 -0
  8. package/dist/addons/fit.d.ts.map +1 -0
  9. package/dist/addons/fit.js +40 -0
  10. package/dist/addons/fit.js.map +1 -0
  11. package/dist/addons/search.d.ts +56 -0
  12. package/dist/addons/search.d.ts.map +1 -0
  13. package/dist/addons/search.js +178 -0
  14. package/dist/addons/search.js.map +1 -0
  15. package/dist/addons/web-links.d.ts +30 -0
  16. package/dist/addons/web-links.d.ts.map +1 -0
  17. package/dist/addons/web-links.js +170 -0
  18. package/dist/addons/web-links.js.map +1 -0
  19. package/dist/fit.d.ts.map +1 -0
  20. package/dist/fit.js +14 -0
  21. package/dist/fit.js.map +1 -0
  22. package/dist/index.d.ts +24 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +14 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/input-handler.d.ts +185 -0
  27. package/dist/input-handler.d.ts.map +1 -0
  28. package/dist/input-handler.js +1197 -0
  29. package/dist/input-handler.js.map +1 -0
  30. package/dist/parser-worker.d.ts.map +1 -0
  31. package/dist/parser-worker.js +128 -0
  32. package/dist/parser-worker.js.map +1 -0
  33. package/dist/render-bridge.d.ts +56 -0
  34. package/dist/render-bridge.d.ts.map +1 -0
  35. package/dist/render-bridge.js +158 -0
  36. package/dist/render-bridge.js.map +1 -0
  37. package/dist/render-worker.d.ts +62 -0
  38. package/dist/render-worker.d.ts.map +1 -0
  39. package/dist/render-worker.js +720 -0
  40. package/dist/render-worker.js.map +1 -0
  41. package/dist/renderer.d.ts +86 -0
  42. package/dist/renderer.d.ts.map +1 -0
  43. package/dist/renderer.js +454 -0
  44. package/dist/renderer.js.map +1 -0
  45. package/dist/shared-context.d.ts +93 -0
  46. package/dist/shared-context.d.ts.map +1 -0
  47. package/dist/shared-context.js +561 -0
  48. package/dist/shared-context.js.map +1 -0
  49. package/dist/web-terminal.d.ts +152 -0
  50. package/dist/web-terminal.d.ts.map +1 -0
  51. package/dist/web-terminal.js +684 -0
  52. package/dist/web-terminal.js.map +1 -0
  53. package/dist/webgl-renderer.d.ts +146 -0
  54. package/dist/webgl-renderer.d.ts.map +1 -0
  55. package/dist/webgl-renderer.js +1047 -0
  56. package/dist/webgl-renderer.js.map +1 -0
  57. package/dist/worker-bridge.d.ts +51 -0
  58. package/dist/worker-bridge.d.ts.map +1 -0
  59. package/dist/worker-bridge.js +185 -0
  60. package/dist/worker-bridge.js.map +1 -0
  61. package/package.json +36 -0
@@ -0,0 +1,1197 @@
1
+ /**
2
+ * Keyboard, mouse, and touch input handling for the web terminal.
3
+ *
4
+ * Uses a hidden <textarea> to capture keyboard input. This is essential
5
+ * for mobile browsers (iOS Safari, Android Chrome) where a plain div with
6
+ * tabindex="0" does NOT trigger the virtual keyboard. The textarea is
7
+ * positioned behind the terminal canvas so it's invisible but focusable.
8
+ *
9
+ * Touch gestures are delegated to the shared GestureHandler from
10
+ * @next_term/core, providing the same behavior on web and native:
11
+ * tap to focus, pan to scroll, long-press to select, pinch to zoom.
12
+ */
13
+ import { extractText, GestureHandler, GestureState } from "@next_term/core";
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+ const encoder = new TextEncoder();
18
+ function toBytes(s) {
19
+ return encoder.encode(s);
20
+ }
21
+ // ---------------------------------------------------------------------------
22
+ // Kitty alternate-key helpers (flag 4)
23
+ // ---------------------------------------------------------------------------
24
+ /** Returns the shifted codepoint for an ASCII character (0 if not applicable). */
25
+ function _kittyShiftedCp(cp) {
26
+ if (cp >= 97 && cp <= 122)
27
+ return cp - 32; // a-z → A-Z
28
+ if (cp >= 65 && cp <= 90)
29
+ return cp; // A-Z → same (already the shifted form)
30
+ // digits 0-9 → their shifted symbols: )!@#$%^&*(
31
+ if (cp >= 48 && cp <= 57)
32
+ return ")!@#$%^&*(".charCodeAt(cp - 48);
33
+ return 0;
34
+ }
35
+ /** Returns the base (unshifted) codepoint for an ASCII character (0 if not applicable). */
36
+ function _kittyBaseCp(cp) {
37
+ if (cp >= 65 && cp <= 90)
38
+ return cp + 32; // A-Z → a-z
39
+ if (cp >= 97 && cp <= 122)
40
+ return cp; // a-z → same (already the base form)
41
+ // shifted digit symbols → corresponding digit
42
+ switch (cp) {
43
+ case 33:
44
+ return 49; // ! → 1
45
+ case 64:
46
+ return 50; // @ → 2
47
+ case 35:
48
+ return 51; // # → 3
49
+ case 36:
50
+ return 52; // $ → 4
51
+ case 37:
52
+ return 53; // % → 5
53
+ case 94:
54
+ return 54; // ^ → 6
55
+ case 38:
56
+ return 55; // & → 7
57
+ case 42:
58
+ return 56; // * → 8
59
+ case 40:
60
+ return 57; // ( → 9
61
+ case 41:
62
+ return 48; // ) → 0
63
+ }
64
+ return 0;
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Touch constants
68
+ // ---------------------------------------------------------------------------
69
+ /** Milliseconds to wait before recognizing a long press. */
70
+ const LONG_PRESS_DELAY = 500;
71
+ /** Maximum pixel movement allowed during a tap. */
72
+ const TAP_THRESHOLD = 10;
73
+ /** Font size limits for pinch-to-zoom. */
74
+ const MIN_FONT_SIZE = 8;
75
+ const MAX_FONT_SIZE = 32;
76
+ // ---------------------------------------------------------------------------
77
+ // InputHandler
78
+ // ---------------------------------------------------------------------------
79
+ export class InputHandler {
80
+ container = null;
81
+ textarea = null;
82
+ onData;
83
+ onSelectionChange;
84
+ onScroll;
85
+ onFontSizeChange;
86
+ applicationCursorKeys;
87
+ // Bracketed paste mode — wraps pasted text in ESC[200~ ... ESC[201~
88
+ bracketedPasteMode = false;
89
+ // Mouse reporting
90
+ mouseProtocol = "none";
91
+ mouseEncoding = "default";
92
+ // Focus events
93
+ sendFocusEvents = false;
94
+ // Kitty keyboard protocol flags (mirror of VTParser.kittyFlags)
95
+ kittyFlags = 0;
96
+ cellWidth = 0;
97
+ cellHeight = 0;
98
+ // Current font size for pinch-to-zoom
99
+ currentFontSize = 14;
100
+ // Grid reference for text extraction
101
+ grid = null;
102
+ // Mouse / selection state
103
+ selecting = false;
104
+ selection = null;
105
+ // Shared gesture handler (from @next_term/core)
106
+ gestureHandler = null;
107
+ // Touch DOM state (bridges DOM TouchEvents to GestureHandler)
108
+ touchStartX = 0;
109
+ touchStartY = 0;
110
+ touchLastX = 0;
111
+ touchLastY = 0;
112
+ longPressTimer = null;
113
+ pinchStartDistance = 0;
114
+ pinchStartFontSize = 0;
115
+ isPinching = false;
116
+ // Swipe direction lock: once a swipe direction is determined, it stays locked
117
+ // for the gesture duration ('none' | 'horizontal' | 'vertical')
118
+ swipeDirection = "none";
119
+ // Horizontal swipe: accumulated pixel remainder for left/right arrow keys
120
+ hSwipeRemainder = 0;
121
+ // Whether an IME composition is in progress (CJK, etc.)
122
+ composing = false;
123
+ // Custom copy tooltip for iOS/mobile (native callout doesn't work with programmatic selection)
124
+ copyTooltip = null;
125
+ /** Text currently staged for copy. */
126
+ pendingCopyText = "";
127
+ // Bound listeners (so we can remove them)
128
+ boundKeyDown = null;
129
+ boundKeyUp = null;
130
+ boundInput = null;
131
+ boundCompositionStart = null;
132
+ boundCompositionEnd = null;
133
+ boundPaste = null;
134
+ boundMouseDown = null;
135
+ boundMouseMove = null;
136
+ boundMouseUp = null;
137
+ boundFocus = null;
138
+ boundBlur = null;
139
+ boundWheel = null;
140
+ boundTouchStart = null;
141
+ boundTouchMove = null;
142
+ boundTouchEnd = null;
143
+ boundTouchCancel = null;
144
+ constructor(options) {
145
+ this.onData = options.onData;
146
+ this.onSelectionChange = options.onSelectionChange ?? null;
147
+ this.onScroll = options.onScroll ?? null;
148
+ this.onFontSizeChange = options.onFontSizeChange ?? null;
149
+ this.applicationCursorKeys = options.applicationCursorKeys ?? false;
150
+ }
151
+ // -----------------------------------------------------------------------
152
+ // Lifecycle
153
+ // -----------------------------------------------------------------------
154
+ attach(container, cellWidth, cellHeight) {
155
+ this.container = container;
156
+ this.cellWidth = cellWidth;
157
+ this.cellHeight = cellHeight;
158
+ // Container setup — not focusable itself, the textarea handles focus
159
+ container.setAttribute("role", "terminal");
160
+ container.setAttribute("aria-label", "Terminal");
161
+ Object.assign(container.style, {
162
+ outline: "none",
163
+ cursor: "text",
164
+ position: "relative",
165
+ // Prevent default touch behaviors (pull-to-refresh, scroll bounce)
166
+ touchAction: "none",
167
+ });
168
+ // Create hidden textarea for keyboard input.
169
+ // iOS Safari (and Android Chrome) only show the virtual keyboard when
170
+ // an <input> or <textarea> element receives focus. We position the
171
+ // textarea behind the terminal canvas so it's invisible but still
172
+ // triggers the on-screen keyboard.
173
+ const ta = document.createElement("textarea");
174
+ ta.setAttribute("autocapitalize", "none");
175
+ ta.setAttribute("autocomplete", "off");
176
+ ta.setAttribute("autocorrect", "off");
177
+ ta.setAttribute("spellcheck", "false");
178
+ ta.setAttribute("tabindex", "0");
179
+ ta.setAttribute("aria-hidden", "true");
180
+ Object.assign(ta.style, {
181
+ position: "absolute",
182
+ left: "0",
183
+ top: "0",
184
+ width: "1px",
185
+ height: "1px",
186
+ opacity: "0",
187
+ padding: "0",
188
+ border: "none",
189
+ outline: "none",
190
+ resize: "none",
191
+ overflow: "hidden",
192
+ // iOS: prevent zoom on focus (font-size < 16px triggers auto-zoom)
193
+ fontSize: "16px",
194
+ // Keep it in the DOM flow but invisible
195
+ zIndex: "-1",
196
+ caretColor: "transparent",
197
+ });
198
+ container.appendChild(ta);
199
+ this.textarea = ta;
200
+ // Initialize shared gesture handler
201
+ this.gestureHandler = new GestureHandler(cellWidth, cellHeight, {
202
+ onScroll: (deltaRows) => {
203
+ if (this.onScroll) {
204
+ this.onScroll(deltaRows);
205
+ }
206
+ // No fallback — vertical swipe is for scrollback only.
207
+ // Horizontal swipe sends arrow keys (handled in handleTouchMove).
208
+ },
209
+ onTap: (_row, _col) => {
210
+ // Tap clears selection; focus is handled by touchstart
211
+ if (this.selection) {
212
+ this.clearSelection();
213
+ }
214
+ },
215
+ onDoubleTap: (_row, _col) => {
216
+ // TODO: word selection
217
+ },
218
+ onLongPress: (_row, _col) => {
219
+ // Selection is started by GestureHandler via onSelectionChange
220
+ },
221
+ onPinch: (scale) => {
222
+ const newSize = Math.round(Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, this.pinchStartFontSize * scale)));
223
+ if (newSize !== this.currentFontSize) {
224
+ this.currentFontSize = newSize;
225
+ this.onFontSizeChange?.(newSize);
226
+ }
227
+ },
228
+ onSelectionChange: (sel) => {
229
+ this.selection = sel;
230
+ this.onSelectionChange?.(sel);
231
+ // When selection is non-empty, put the text into the hidden textarea
232
+ // and select it so iOS Safari shows the native "Copy" callout menu.
233
+ if (sel && this.grid) {
234
+ if (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol) {
235
+ const text = extractText(this.grid, sel.startRow, sel.startCol, sel.endRow, sel.endCol);
236
+ if (text) {
237
+ this.showCopyTooltip(text);
238
+ }
239
+ }
240
+ }
241
+ else if (!sel) {
242
+ this.hideCopyTooltip();
243
+ }
244
+ },
245
+ });
246
+ // Keyboard — listen on the textarea
247
+ this.boundKeyDown = this.handleKeyDown.bind(this);
248
+ this.boundKeyUp = this.handleKeyUp.bind(this);
249
+ this.boundInput = this.handleInput.bind(this);
250
+ this.boundCompositionStart = this.handleCompositionStart.bind(this);
251
+ this.boundCompositionEnd = this.handleCompositionEnd.bind(this);
252
+ this.boundPaste = this.handlePaste.bind(this);
253
+ ta.addEventListener("keydown", this.boundKeyDown);
254
+ ta.addEventListener("keyup", this.boundKeyUp);
255
+ ta.addEventListener("input", this.boundInput);
256
+ ta.addEventListener("compositionstart", this.boundCompositionStart);
257
+ ta.addEventListener("compositionend", this.boundCompositionEnd);
258
+ ta.addEventListener("paste", this.boundPaste);
259
+ // Mouse
260
+ this.boundMouseDown = this.handleMouseDown.bind(this);
261
+ this.boundMouseMove = this.handleMouseMove.bind(this);
262
+ this.boundMouseUp = this.handleMouseUp.bind(this);
263
+ this.boundWheel = this.handleWheel.bind(this);
264
+ container.addEventListener("mousedown", this.boundMouseDown);
265
+ container.addEventListener("wheel", this.boundWheel, { passive: false });
266
+ document.addEventListener("mousemove", this.boundMouseMove);
267
+ document.addEventListener("mouseup", this.boundMouseUp);
268
+ // Touch — bridges DOM TouchEvents to the shared GestureHandler
269
+ this.boundTouchStart = this.handleTouchStart.bind(this);
270
+ this.boundTouchMove = this.handleTouchMove.bind(this);
271
+ this.boundTouchEnd = this.handleTouchEnd.bind(this);
272
+ this.boundTouchCancel = this.handleTouchCancel.bind(this);
273
+ container.addEventListener("touchstart", this.boundTouchStart, { passive: false });
274
+ container.addEventListener("touchmove", this.boundTouchMove, { passive: false });
275
+ container.addEventListener("touchend", this.boundTouchEnd, { passive: false });
276
+ container.addEventListener("touchcancel", this.boundTouchCancel);
277
+ // Focus/blur events for mode 1004 — on the textarea
278
+ this.boundFocus = this.handleFocus.bind(this);
279
+ this.boundBlur = this.handleBlur.bind(this);
280
+ ta.addEventListener("focus", this.boundFocus);
281
+ ta.addEventListener("blur", this.boundBlur);
282
+ // Clicking the container should focus the textarea
283
+ container.addEventListener("mousedown", (e) => {
284
+ // Don't steal focus if it's a right-click / context menu
285
+ if (e.button === 0) {
286
+ // Delay focus to after mousedown handler runs
287
+ setTimeout(() => ta.focus(), 0);
288
+ }
289
+ });
290
+ }
291
+ focus() {
292
+ this.textarea?.focus();
293
+ }
294
+ blur() {
295
+ this.textarea?.blur();
296
+ }
297
+ setApplicationCursorKeys(enabled) {
298
+ this.applicationCursorKeys = enabled;
299
+ }
300
+ setBracketedPasteMode(enabled) {
301
+ this.bracketedPasteMode = enabled;
302
+ }
303
+ setMouseProtocol(protocol) {
304
+ this.mouseProtocol = protocol;
305
+ }
306
+ setMouseEncoding(encoding) {
307
+ this.mouseEncoding = encoding;
308
+ }
309
+ setSendFocusEvents(enabled) {
310
+ this.sendFocusEvents = enabled;
311
+ }
312
+ setKittyFlags(flags) {
313
+ this.kittyFlags = flags;
314
+ }
315
+ updateCellSize(cellWidth, cellHeight) {
316
+ this.cellWidth = cellWidth;
317
+ this.cellHeight = cellHeight;
318
+ this.gestureHandler?.updateCellSize(cellWidth, cellHeight);
319
+ }
320
+ setFontSize(fontSize) {
321
+ this.currentFontSize = fontSize;
322
+ }
323
+ setGrid(grid) {
324
+ this.grid = grid;
325
+ }
326
+ getSelection() {
327
+ return this.selection;
328
+ }
329
+ clearSelection() {
330
+ this.selection = null;
331
+ this.gestureHandler?.clearSelection();
332
+ this.onSelectionChange?.(null);
333
+ this.hideCopyTooltip();
334
+ }
335
+ dispose() {
336
+ this.cancelLongPress();
337
+ // Textarea listeners
338
+ if (this.textarea && this.boundKeyDown) {
339
+ this.textarea.removeEventListener("keydown", this.boundKeyDown);
340
+ }
341
+ if (this.textarea && this.boundKeyUp) {
342
+ this.textarea.removeEventListener("keyup", this.boundKeyUp);
343
+ }
344
+ if (this.textarea && this.boundInput) {
345
+ this.textarea.removeEventListener("input", this.boundInput);
346
+ }
347
+ if (this.textarea && this.boundCompositionStart) {
348
+ this.textarea.removeEventListener("compositionstart", this.boundCompositionStart);
349
+ }
350
+ if (this.textarea && this.boundCompositionEnd) {
351
+ this.textarea.removeEventListener("compositionend", this.boundCompositionEnd);
352
+ }
353
+ if (this.textarea && this.boundPaste) {
354
+ this.textarea.removeEventListener("paste", this.boundPaste);
355
+ }
356
+ if (this.textarea && this.boundFocus) {
357
+ this.textarea.removeEventListener("focus", this.boundFocus);
358
+ }
359
+ if (this.textarea && this.boundBlur) {
360
+ this.textarea.removeEventListener("blur", this.boundBlur);
361
+ }
362
+ // Remove textarea from DOM
363
+ if (this.textarea?.parentNode) {
364
+ this.textarea.parentNode.removeChild(this.textarea);
365
+ }
366
+ // Container listeners
367
+ if (this.container && this.boundMouseDown) {
368
+ this.container.removeEventListener("mousedown", this.boundMouseDown);
369
+ }
370
+ if (this.container && this.boundWheel) {
371
+ this.container.removeEventListener("wheel", this.boundWheel);
372
+ }
373
+ if (this.container && this.boundTouchStart) {
374
+ this.container.removeEventListener("touchstart", this.boundTouchStart);
375
+ }
376
+ if (this.container && this.boundTouchMove) {
377
+ this.container.removeEventListener("touchmove", this.boundTouchMove);
378
+ }
379
+ if (this.container && this.boundTouchEnd) {
380
+ this.container.removeEventListener("touchend", this.boundTouchEnd);
381
+ }
382
+ if (this.container && this.boundTouchCancel) {
383
+ this.container.removeEventListener("touchcancel", this.boundTouchCancel);
384
+ }
385
+ // Document listeners
386
+ if (this.boundMouseMove) {
387
+ document.removeEventListener("mousemove", this.boundMouseMove);
388
+ }
389
+ if (this.boundMouseUp) {
390
+ document.removeEventListener("mouseup", this.boundMouseUp);
391
+ }
392
+ if (this.copyTooltip?.parentNode) {
393
+ this.copyTooltip.parentNode.removeChild(this.copyTooltip);
394
+ }
395
+ this.copyTooltip = null;
396
+ this.textarea = null;
397
+ this.container = null;
398
+ this.gestureHandler = null;
399
+ this.boundKeyDown = null;
400
+ this.boundKeyUp = null;
401
+ this.boundInput = null;
402
+ this.boundCompositionStart = null;
403
+ this.boundCompositionEnd = null;
404
+ this.boundPaste = null;
405
+ this.boundMouseDown = null;
406
+ this.boundMouseMove = null;
407
+ this.boundMouseUp = null;
408
+ this.boundWheel = null;
409
+ this.boundTouchStart = null;
410
+ this.boundTouchMove = null;
411
+ this.boundTouchEnd = null;
412
+ this.boundTouchCancel = null;
413
+ this.boundFocus = null;
414
+ this.boundBlur = null;
415
+ }
416
+ // -----------------------------------------------------------------------
417
+ // Keyboard handling
418
+ // -----------------------------------------------------------------------
419
+ handleKeyDown(e) {
420
+ // During IME composition, let the browser handle it
421
+ if (this.composing)
422
+ return;
423
+ // Ctrl+C or Cmd+C with active selection → copy to clipboard
424
+ if ((e.ctrlKey || e.metaKey) && e.key === "c" && this.selection && this.grid) {
425
+ e.preventDefault();
426
+ const text = extractText(this.grid, this.selection.startRow, this.selection.startCol, this.selection.endRow, this.selection.endCol);
427
+ if (typeof navigator !== "undefined" && navigator.clipboard) {
428
+ navigator.clipboard.writeText(text).catch(() => {
429
+ /* ignore */
430
+ });
431
+ }
432
+ this.clearSelection();
433
+ return;
434
+ }
435
+ // Cmd+V / Ctrl+V: read from clipboard and send to PTY
436
+ if ((e.ctrlKey || e.metaKey) && e.key === "v") {
437
+ e.preventDefault();
438
+ if (typeof navigator !== "undefined" && navigator.clipboard) {
439
+ navigator.clipboard
440
+ .readText()
441
+ .then((text) => {
442
+ if (text)
443
+ this.sendPastedText(text);
444
+ })
445
+ .catch(() => {
446
+ /* ignore */
447
+ });
448
+ }
449
+ return;
450
+ }
451
+ const seq = this.keyToSequence(e);
452
+ if (seq !== null) {
453
+ e.preventDefault();
454
+ this.onData(toBytes(seq));
455
+ // Clear the textarea so mobile input doesn't accumulate
456
+ if (this.textarea)
457
+ this.textarea.value = "";
458
+ }
459
+ }
460
+ handleKeyUp(e) {
461
+ if (this.composing)
462
+ return;
463
+ const seq = this.keyUpToSequence(e);
464
+ if (seq !== null) {
465
+ e.preventDefault();
466
+ this.onData(toBytes(seq));
467
+ }
468
+ }
469
+ /**
470
+ * Handle input events from the hidden textarea.
471
+ * On mobile browsers, the virtual keyboard fires `input` events rather
472
+ * than `keydown` for printable characters. We read the textarea value
473
+ * and send any new characters to the PTY.
474
+ */
475
+ handleInput(_e) {
476
+ // During IME composition, wait for compositionend
477
+ if (this.composing)
478
+ return;
479
+ if (!this.textarea)
480
+ return;
481
+ const data = this.textarea.value;
482
+ if (data) {
483
+ this.onData(toBytes(data));
484
+ this.textarea.value = "";
485
+ }
486
+ }
487
+ handleCompositionStart() {
488
+ this.composing = true;
489
+ }
490
+ handleCompositionEnd(e) {
491
+ this.composing = false;
492
+ // Send the composed text (CJK characters, accented letters, etc.)
493
+ if (e.data) {
494
+ this.onData(toBytes(e.data));
495
+ }
496
+ if (this.textarea)
497
+ this.textarea.value = "";
498
+ }
499
+ handlePaste(e) {
500
+ e.preventDefault();
501
+ const text = e.clipboardData?.getData("text");
502
+ if (text) {
503
+ this.sendPastedText(text);
504
+ }
505
+ }
506
+ /** Send pasted text, wrapping in bracketed paste sequences if enabled. */
507
+ sendPastedText(text) {
508
+ if (this.bracketedPasteMode) {
509
+ // Strip nested bracket-paste markers from the pasted content:
510
+ // ESC[201~ could prematurely terminate the paste region on the receiver;
511
+ // ESC[200~ would create a malformed nested bracket-paste sequence.
512
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is intentional here
513
+ const safe = text.replace(/\u001b\[20[01]~/g, "");
514
+ this.onData(toBytes(`\x1b[200~${safe}\x1b[201~`));
515
+ }
516
+ else {
517
+ this.onData(toBytes(text));
518
+ }
519
+ }
520
+ /**
521
+ * Convert a KeyboardEvent into the VT sequence string to send to the PTY,
522
+ * or null if the event should not be handled.
523
+ */
524
+ keyToSequence(e) {
525
+ const { key, ctrlKey, altKey, metaKey, shiftKey } = e;
526
+ // Meta key combos are browser shortcuts — let them through
527
+ if (metaKey)
528
+ return null;
529
+ // Kitty disambiguate mode (flag 1): use CSI u encoding for ambiguous keys
530
+ if (this.kittyFlags & 1) {
531
+ return this._keyToSequenceKitty(key, ctrlKey, altKey, shiftKey, e.repeat ? 2 : 1);
532
+ }
533
+ // Ctrl + single letter → control character
534
+ if (ctrlKey && !altKey && key.length === 1) {
535
+ const code = key.toUpperCase().charCodeAt(0);
536
+ if (code >= 0x40 && code <= 0x5f) {
537
+ return String.fromCharCode(code - 0x40);
538
+ }
539
+ }
540
+ // Alt + key → ESC prefix
541
+ if (altKey && !ctrlKey && key.length === 1) {
542
+ return `\x1b${key}`;
543
+ }
544
+ // Special keys
545
+ const appMode = this.applicationCursorKeys;
546
+ switch (key) {
547
+ case "Enter":
548
+ return "\r";
549
+ case "Backspace":
550
+ return ctrlKey ? "\x08" : "\x7f";
551
+ case "Tab":
552
+ return "\t";
553
+ case "Escape":
554
+ return "\x1b";
555
+ case "Delete":
556
+ return "\x1b[3~";
557
+ case "ArrowUp":
558
+ return appMode ? "\x1bOA" : "\x1b[A";
559
+ case "ArrowDown":
560
+ return appMode ? "\x1bOB" : "\x1b[B";
561
+ case "ArrowRight":
562
+ return appMode ? "\x1bOC" : "\x1b[C";
563
+ case "ArrowLeft":
564
+ return appMode ? "\x1bOD" : "\x1b[D";
565
+ case "Home":
566
+ return "\x1b[H";
567
+ case "End":
568
+ return "\x1b[F";
569
+ case "PageUp":
570
+ return "\x1b[5~";
571
+ case "PageDown":
572
+ return "\x1b[6~";
573
+ case "Insert":
574
+ return "\x1b[2~";
575
+ // Function keys
576
+ case "F1":
577
+ return "\x1bOP";
578
+ case "F2":
579
+ return "\x1bOQ";
580
+ case "F3":
581
+ return "\x1bOR";
582
+ case "F4":
583
+ return "\x1bOS";
584
+ case "F5":
585
+ return "\x1b[15~";
586
+ case "F6":
587
+ return "\x1b[17~";
588
+ case "F7":
589
+ return "\x1b[18~";
590
+ case "F8":
591
+ return "\x1b[19~";
592
+ case "F9":
593
+ return "\x1b[20~";
594
+ case "F10":
595
+ return "\x1b[21~";
596
+ case "F11":
597
+ return "\x1b[23~";
598
+ case "F12":
599
+ return "\x1b[24~";
600
+ }
601
+ // Modifier-only keys
602
+ if (key === "Shift" || key === "Control" || key === "Alt" || key === "Meta") {
603
+ return null;
604
+ }
605
+ // Printable character
606
+ if (key.length === 1) {
607
+ return key;
608
+ }
609
+ return null;
610
+ }
611
+ /**
612
+ * Produce the key-release sequence for a keyup event.
613
+ * Only meaningful when kittyFlags & 2 (report event types) is active.
614
+ * Returns null if event types are not enabled or the key has no kitty-encoded release.
615
+ */
616
+ keyUpToSequence(e) {
617
+ if (!(this.kittyFlags & 2))
618
+ return null;
619
+ if (e.metaKey)
620
+ return null;
621
+ return this._keyToSequenceKitty(e.key, e.ctrlKey, e.altKey, e.shiftKey, 3);
622
+ }
623
+ /**
624
+ * Kitty keyboard protocol key encoding (disambiguate mode, flag 1).
625
+ *
626
+ * Modifier bitmask (add 1 for the wire value):
627
+ * shift=1, alt=2, ctrl=4
628
+ *
629
+ * Encoding rules:
630
+ * - Escape → CSI 27 u (CSI 27;1:type u when flag 2)
631
+ * - Modifier + letter → CSI codepoint[:alt] ; modifier+1 u
632
+ * - Shift+Tab → CSI 9 ; 2 u
633
+ * - Modified cursor keys → CSI 1 ; modifier+1 <ABCDHF>
634
+ * - Modified tilde keys → CSI n ; modifier+1 ~
635
+ * - Modified ESC-O F1-F4 → CSI 1 ; modifier+1 <PQRS>
636
+ * - Unmodified keys → fall back to legacy encoding (no event type suffix)
637
+ *
638
+ * When kittyFlags & 2 (report event types), `:eventType` is appended to the
639
+ * modifier parameter of all enhanced sequences (not legacy fallbacks).
640
+ * When kittyFlags & 4 (report alternate keys), the key codepoint gains
641
+ * `:shifted[:base]` sub-parameters for single printable characters.
642
+ */
643
+ _keyToSequenceKitty(key, ctrlKey, altKey, shiftKey, eventType = 1) {
644
+ // Modifier value: 1 + bitmask(shift=1, alt=2, ctrl=4)
645
+ const mod = 1 + (shiftKey ? 1 : 0) + (altKey ? 2 : 0) + (ctrlKey ? 4 : 0);
646
+ const hasModifier = mod !== 1;
647
+ // Event type suffix for flag 2 (report event types): ":eventType" appended to modifier
648
+ const et = this.kittyFlags & 2 ? `:${eventType}` : "";
649
+ // Escape always gets CSI u encoding to remove ambiguity with ESC-prefix sequences
650
+ if (key === "Escape") {
651
+ return this.kittyFlags & 2 ? `\x1b[27;1:${eventType}u` : "\x1b[27u";
652
+ }
653
+ // Shift+Tab → CSI 9 ; 2 u
654
+ if (key === "Tab" && shiftKey) {
655
+ return `\x1b[9;${mod}${et}u`;
656
+ }
657
+ // Modifier + single printable character → CSI codepoint[:alt] ; mod u
658
+ // Flag 8 (report all keys as escape codes) also encodes unmodified/shift-only chars.
659
+ if (key.length === 1 && ((hasModifier && (ctrlKey || altKey)) || this.kittyFlags & 8)) {
660
+ return `\x1b[${key.charCodeAt(0)}${this._kittyAltParam(key)};${mod}${et}u`;
661
+ }
662
+ // Modified cursor keys → CSI 1 ; mod <letter>
663
+ if (hasModifier) {
664
+ switch (key) {
665
+ case "ArrowUp":
666
+ return `\x1b[1;${mod}${et}A`;
667
+ case "ArrowDown":
668
+ return `\x1b[1;${mod}${et}B`;
669
+ case "ArrowRight":
670
+ return `\x1b[1;${mod}${et}C`;
671
+ case "ArrowLeft":
672
+ return `\x1b[1;${mod}${et}D`;
673
+ case "Home":
674
+ return `\x1b[1;${mod}${et}H`;
675
+ case "End":
676
+ return `\x1b[1;${mod}${et}F`;
677
+ // Modified tilde-style keys: CSI n ; mod ~
678
+ case "Delete":
679
+ return `\x1b[3;${mod}${et}~`;
680
+ case "Insert":
681
+ return `\x1b[2;${mod}${et}~`;
682
+ case "PageUp":
683
+ return `\x1b[5;${mod}${et}~`;
684
+ case "PageDown":
685
+ return `\x1b[6;${mod}${et}~`;
686
+ // Modified F1-F4 (ESC-O style → CSI 1 ; mod <letter>)
687
+ case "F1":
688
+ return `\x1b[1;${mod}${et}P`;
689
+ case "F2":
690
+ return `\x1b[1;${mod}${et}Q`;
691
+ case "F3":
692
+ return `\x1b[1;${mod}${et}R`;
693
+ case "F4":
694
+ return `\x1b[1;${mod}${et}S`;
695
+ // Modified F5-F12 (tilde-style)
696
+ case "F5":
697
+ return `\x1b[15;${mod}${et}~`;
698
+ case "F6":
699
+ return `\x1b[17;${mod}${et}~`;
700
+ case "F7":
701
+ return `\x1b[18;${mod}${et}~`;
702
+ case "F8":
703
+ return `\x1b[19;${mod}${et}~`;
704
+ case "F9":
705
+ return `\x1b[20;${mod}${et}~`;
706
+ case "F10":
707
+ return `\x1b[21;${mod}${et}~`;
708
+ case "F11":
709
+ return `\x1b[23;${mod}${et}~`;
710
+ case "F12":
711
+ return `\x1b[24;${mod}${et}~`;
712
+ }
713
+ }
714
+ // Unmodified keys: fall through to legacy encoding.
715
+ // Flag 8 (report all keys as escape codes): functional keys that produce literal
716
+ // characters are encoded as CSI u before the legacy-only eventType=3 guard.
717
+ if (this.kittyFlags & 8) {
718
+ switch (key) {
719
+ case "Enter":
720
+ return `\x1b[13;1${et}u`;
721
+ case "Tab":
722
+ return `\x1b[9;1${et}u`;
723
+ case "Backspace":
724
+ return `\x1b[127;1${et}u`;
725
+ }
726
+ }
727
+ // Legacy sequences don't support release events — return null on keyup (eventType=3).
728
+ if (eventType === 3)
729
+ return null;
730
+ switch (key) {
731
+ case "Enter":
732
+ return "\r";
733
+ case "Backspace":
734
+ return "\x7f";
735
+ case "Tab":
736
+ return "\t";
737
+ case "Delete":
738
+ return "\x1b[3~";
739
+ case "ArrowUp":
740
+ return "\x1b[A";
741
+ case "ArrowDown":
742
+ return "\x1b[B";
743
+ case "ArrowRight":
744
+ return "\x1b[C";
745
+ case "ArrowLeft":
746
+ return "\x1b[D";
747
+ case "Home":
748
+ return "\x1b[H";
749
+ case "End":
750
+ return "\x1b[F";
751
+ case "PageUp":
752
+ return "\x1b[5~";
753
+ case "PageDown":
754
+ return "\x1b[6~";
755
+ case "Insert":
756
+ return "\x1b[2~";
757
+ case "F1":
758
+ return "\x1bOP";
759
+ case "F2":
760
+ return "\x1bOQ";
761
+ case "F3":
762
+ return "\x1bOR";
763
+ case "F4":
764
+ return "\x1bOS";
765
+ case "F5":
766
+ return "\x1b[15~";
767
+ case "F6":
768
+ return "\x1b[17~";
769
+ case "F7":
770
+ return "\x1b[18~";
771
+ case "F8":
772
+ return "\x1b[19~";
773
+ case "F9":
774
+ return "\x1b[20~";
775
+ case "F10":
776
+ return "\x1b[21~";
777
+ case "F11":
778
+ return "\x1b[23~";
779
+ case "F12":
780
+ return "\x1b[24~";
781
+ }
782
+ if (key === "Shift" || key === "Control" || key === "Alt" || key === "Meta") {
783
+ return null;
784
+ }
785
+ if (key.length === 1) {
786
+ return key;
787
+ }
788
+ return null;
789
+ }
790
+ /**
791
+ * Alternate key sub-parameters for flag 4 (report alternate keys).
792
+ *
793
+ * Returns the sub-parameter string to append to the Unicode codepoint in
794
+ * CSI u sequences: `:shifted`, `::base`, `:shifted:base`, or `""`.
795
+ * - shifted: codepoint when Shift is held on the same physical key
796
+ * - base: codepoint of the physical key without modifiers (US QWERTY)
797
+ * Sub-parameters equal to the main codepoint are omitted.
798
+ */
799
+ _kittyAltParam(key) {
800
+ if (!(this.kittyFlags & 4) || key.length !== 1)
801
+ return "";
802
+ const cp = key.charCodeAt(0);
803
+ const shifted = _kittyShiftedCp(cp);
804
+ const base = _kittyBaseCp(cp);
805
+ const alt1 = shifted !== 0 && shifted !== cp ? `${shifted}` : "";
806
+ const alt2 = base !== 0 && base !== cp ? `${base}` : "";
807
+ if (!alt1 && !alt2)
808
+ return "";
809
+ if (!alt2)
810
+ return `:${alt1}`;
811
+ return `:${alt1}:${alt2}`;
812
+ }
813
+ // -----------------------------------------------------------------------
814
+ // Focus / blur events (mode 1004)
815
+ // -----------------------------------------------------------------------
816
+ handleFocus() {
817
+ if (this.sendFocusEvents) {
818
+ this.onData(toBytes("\x1b[I"));
819
+ }
820
+ }
821
+ handleBlur() {
822
+ if (this.sendFocusEvents) {
823
+ this.onData(toBytes("\x1b[O"));
824
+ }
825
+ }
826
+ // -----------------------------------------------------------------------
827
+ // Mouse / selection handling
828
+ // -----------------------------------------------------------------------
829
+ getMouseCellPos(e) {
830
+ if (!this.container || this.cellWidth <= 0 || this.cellHeight <= 0)
831
+ return null;
832
+ const rect = this.container.getBoundingClientRect();
833
+ const x = e.clientX - rect.left;
834
+ const y = e.clientY - rect.top;
835
+ const col = Math.floor(x / this.cellWidth);
836
+ const row = Math.floor(y / this.cellHeight);
837
+ return { col: Math.max(0, col), row: Math.max(0, row) };
838
+ }
839
+ /**
840
+ * Encode a mouse event as a VT sequence.
841
+ * button: 0=left, 1=middle, 2=right, 3=release, 64=scrollUp, 65=scrollDown
842
+ */
843
+ encodeMouseEvent(button, col, row) {
844
+ if (this.mouseEncoding === "sgr") {
845
+ const final = button === 3 ? "m" : "M";
846
+ const btn = button === 3 ? 0 : button;
847
+ return `\x1b[<${btn};${col + 1};${row + 1}${final}`;
848
+ }
849
+ const cb = String.fromCharCode(button + 32);
850
+ const cx = String.fromCharCode(col + 1 + 32);
851
+ const cy = String.fromCharCode(row + 1 + 32);
852
+ return `\x1b[M${cb}${cx}${cy}`;
853
+ }
854
+ handleMouseDown(e) {
855
+ if (e.button !== 0)
856
+ return;
857
+ const pos = this.getMouseCellPos(e);
858
+ if (!pos)
859
+ return;
860
+ if (this.mouseProtocol !== "none") {
861
+ e.preventDefault();
862
+ this.onData(toBytes(this.encodeMouseEvent(0, pos.col, pos.row)));
863
+ this.focus();
864
+ return;
865
+ }
866
+ this.selecting = true;
867
+ this.selection = {
868
+ startRow: pos.row,
869
+ startCol: pos.col,
870
+ endRow: pos.row,
871
+ endCol: pos.col,
872
+ };
873
+ this.focus();
874
+ }
875
+ handleMouseMove(e) {
876
+ const pos = this.getMouseCellPos(e);
877
+ if (pos && this.mouseProtocol === "any") {
878
+ this.onData(toBytes(this.encodeMouseEvent(32 + 0, pos.col, pos.row)));
879
+ return;
880
+ }
881
+ if (pos && this.mouseProtocol === "drag" && e.buttons & 1) {
882
+ this.onData(toBytes(this.encodeMouseEvent(32 + 0, pos.col, pos.row)));
883
+ return;
884
+ }
885
+ if (!this.selecting || !this.selection)
886
+ return;
887
+ if (!pos)
888
+ return;
889
+ this.selection.endRow = pos.row;
890
+ this.selection.endCol = pos.col;
891
+ this.onSelectionChange?.(this.selection);
892
+ }
893
+ handleMouseUp(_e) {
894
+ if (this.mouseProtocol !== "none" && this.mouseProtocol !== "x10") {
895
+ const pos = this.getMouseCellPos(_e);
896
+ if (pos) {
897
+ this.onData(toBytes(this.encodeMouseEvent(3, pos.col, pos.row)));
898
+ }
899
+ return;
900
+ }
901
+ if (!this.selecting)
902
+ return;
903
+ this.selecting = false;
904
+ if (this.selection &&
905
+ this.selection.startRow === this.selection.endRow &&
906
+ this.selection.startCol === this.selection.endCol) {
907
+ this.selection = null;
908
+ this.onSelectionChange?.(null);
909
+ return;
910
+ }
911
+ if (this.selection && this.grid) {
912
+ const text = extractText(this.grid, this.selection.startRow, this.selection.startCol, this.selection.endRow, this.selection.endCol);
913
+ if (text) {
914
+ this.showCopyTooltip(text);
915
+ }
916
+ }
917
+ }
918
+ handleWheel(e) {
919
+ if (this.mouseProtocol !== "none") {
920
+ e.preventDefault();
921
+ const pos = this.getMouseCellPos(e);
922
+ if (!pos)
923
+ return;
924
+ const button = e.deltaY < 0 ? 64 : 65;
925
+ this.onData(toBytes(this.encodeMouseEvent(button, pos.col, pos.row)));
926
+ }
927
+ }
928
+ // -----------------------------------------------------------------------
929
+ // Touch → GestureHandler bridge
930
+ //
931
+ // DOM TouchEvents are translated into the platform-agnostic GestureHandler
932
+ // API (handlePan, handleTap, handleLongPress, handlePinch, extendSelection).
933
+ // This gives the web the same gesture behavior as React Native.
934
+ // -----------------------------------------------------------------------
935
+ cancelLongPress() {
936
+ if (this.longPressTimer !== null) {
937
+ clearTimeout(this.longPressTimer);
938
+ this.longPressTimer = null;
939
+ }
940
+ }
941
+ getPinchDistance(t1, t2) {
942
+ const dx = t1.clientX - t2.clientX;
943
+ const dy = t1.clientY - t2.clientY;
944
+ return Math.sqrt(dx * dx + dy * dy);
945
+ }
946
+ /** Convert a touch point to local pixel coordinates relative to container. */
947
+ touchToLocal(touch) {
948
+ if (!this.container)
949
+ return null;
950
+ const rect = this.container.getBoundingClientRect();
951
+ return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
952
+ }
953
+ handleTouchStart(e) {
954
+ // Don't focus here — wait for touchend to confirm it's a tap.
955
+ // Focusing on touchstart shows the keyboard before we know if
956
+ // the user intends to scroll, causing a scroll/keyboard race.
957
+ if (e.touches.length === 2) {
958
+ // Pinch start
959
+ e.preventDefault();
960
+ this.cancelLongPress();
961
+ this.isPinching = true;
962
+ this.pinchStartDistance = this.getPinchDistance(e.touches[0], e.touches[1]);
963
+ this.pinchStartFontSize = this.currentFontSize;
964
+ return;
965
+ }
966
+ if (e.touches.length !== 1)
967
+ return;
968
+ e.preventDefault();
969
+ const touch = e.touches[0];
970
+ this.touchStartX = touch.clientX;
971
+ this.touchStartY = touch.clientY;
972
+ this.touchLastX = touch.clientX;
973
+ this.touchLastY = touch.clientY;
974
+ this.isPinching = false;
975
+ this.swipeDirection = "none";
976
+ this.hSwipeRemainder = 0;
977
+ // Mouse reporting: translate touch to mouse press
978
+ if (this.mouseProtocol !== "none") {
979
+ const local = this.touchToLocal(touch);
980
+ if (local && this.gestureHandler) {
981
+ const pos = this.gestureHandler.pixelToCell(local.x, local.y);
982
+ this.onData(toBytes(this.encodeMouseEvent(0, pos.col, pos.row)));
983
+ }
984
+ return;
985
+ }
986
+ // Start long-press timer → delegates to GestureHandler.handleLongPress
987
+ this.cancelLongPress();
988
+ this.longPressTimer = setTimeout(() => {
989
+ this.longPressTimer = null;
990
+ const local = this.touchToLocal(touch);
991
+ if (local && this.gestureHandler) {
992
+ this.gestureHandler.handleLongPress(local.x, local.y);
993
+ }
994
+ }, LONG_PRESS_DELAY);
995
+ // Signal pan began
996
+ this.gestureHandler?.handlePan(0, 0, 0, GestureState.BEGAN);
997
+ }
998
+ handleTouchMove(e) {
999
+ if (this.isPinching && e.touches.length === 2) {
1000
+ // Pinch zoom — delegate to GestureHandler
1001
+ e.preventDefault();
1002
+ const currentDistance = this.getPinchDistance(e.touches[0], e.touches[1]);
1003
+ const scale = currentDistance / this.pinchStartDistance;
1004
+ this.gestureHandler?.handlePinch(scale, GestureState.ACTIVE);
1005
+ return;
1006
+ }
1007
+ if (e.touches.length !== 1)
1008
+ return;
1009
+ e.preventDefault();
1010
+ const touch = e.touches[0];
1011
+ const dx = Math.abs(touch.clientX - this.touchStartX);
1012
+ const dy = Math.abs(touch.clientY - this.touchStartY);
1013
+ // Cancel long press if moved beyond threshold
1014
+ if (dx > TAP_THRESHOLD || dy > TAP_THRESHOLD) {
1015
+ this.cancelLongPress();
1016
+ }
1017
+ // Mouse reporting: translate touch drag
1018
+ if (this.mouseProtocol !== "none") {
1019
+ if (this.mouseProtocol === "drag" || this.mouseProtocol === "any") {
1020
+ const local = this.touchToLocal(touch);
1021
+ if (local && this.gestureHandler) {
1022
+ const pos = this.gestureHandler.pixelToCell(local.x, local.y);
1023
+ this.onData(toBytes(this.encodeMouseEvent(32 + 0, pos.col, pos.row)));
1024
+ }
1025
+ }
1026
+ return;
1027
+ }
1028
+ // Selection drag — delegate to GestureHandler
1029
+ if (this.gestureHandler?.isSelectionActive) {
1030
+ const local = this.touchToLocal(touch);
1031
+ if (local) {
1032
+ this.gestureHandler.extendSelection(local.x, local.y);
1033
+ }
1034
+ return;
1035
+ }
1036
+ // Determine swipe direction on first significant movement.
1037
+ // Bias toward vertical (scroll) — require dx > 1.5 * dy for horizontal lock.
1038
+ // This prevents near-diagonal swipes from accidentally sending arrow keys.
1039
+ if (this.swipeDirection === "none" && (dx > TAP_THRESHOLD || dy > TAP_THRESHOLD)) {
1040
+ this.swipeDirection = dx > 1.5 * dy ? "horizontal" : "vertical";
1041
+ }
1042
+ if (this.swipeDirection === "horizontal") {
1043
+ // Horizontal swipe → send arrow keys for command-line navigation
1044
+ const deltaX = touch.clientX - this.touchLastX;
1045
+ this.touchLastX = touch.clientX;
1046
+ this.touchLastY = touch.clientY;
1047
+ const totalPixels = deltaX + this.hSwipeRemainder;
1048
+ const steps = Math.trunc(totalPixels / this.cellWidth);
1049
+ this.hSwipeRemainder = totalPixels - steps * this.cellWidth;
1050
+ if (steps !== 0) {
1051
+ const key = steps > 0 ? "\x1b[C" : "\x1b[D"; // right : left
1052
+ const count = Math.abs(steps);
1053
+ for (let i = 0; i < count; i++) {
1054
+ this.onData(toBytes(key));
1055
+ }
1056
+ }
1057
+ }
1058
+ else if (this.swipeDirection === "vertical") {
1059
+ // Vertical swipe → scroll terminal (scrollback buffer)
1060
+ const deltaY = touch.clientY - this.touchLastY;
1061
+ this.touchLastX = touch.clientX;
1062
+ this.touchLastY = touch.clientY;
1063
+ this.gestureHandler?.handlePan(touch.clientX - this.touchStartX, deltaY, 0, GestureState.ACTIVE);
1064
+ }
1065
+ else {
1066
+ // Direction not yet determined — just track position, don't act
1067
+ this.touchLastX = touch.clientX;
1068
+ this.touchLastY = touch.clientY;
1069
+ }
1070
+ }
1071
+ handleTouchEnd(e) {
1072
+ // Pinch ended but one finger remains
1073
+ if (this.isPinching) {
1074
+ this.isPinching = false;
1075
+ this.gestureHandler?.handlePinch(1, GestureState.END);
1076
+ if (e.touches.length === 1) {
1077
+ const touch = e.touches[0];
1078
+ this.touchStartX = touch.clientX;
1079
+ this.touchStartY = touch.clientY;
1080
+ this.touchLastY = touch.clientY;
1081
+ }
1082
+ return;
1083
+ }
1084
+ this.cancelLongPress();
1085
+ // Mouse reporting: send release
1086
+ if (this.mouseProtocol !== "none" && this.mouseProtocol !== "x10") {
1087
+ if (e.changedTouches.length > 0) {
1088
+ const touch = e.changedTouches[0];
1089
+ const local = this.touchToLocal(touch);
1090
+ if (local && this.gestureHandler) {
1091
+ const pos = this.gestureHandler.pixelToCell(local.x, local.y);
1092
+ this.onData(toBytes(this.encodeMouseEvent(3, pos.col, pos.row)));
1093
+ }
1094
+ }
1095
+ return;
1096
+ }
1097
+ // End pan gesture (with velocity for fling)
1098
+ if (e.changedTouches.length > 0) {
1099
+ const touch = e.changedTouches[0];
1100
+ const dx = Math.abs(touch.clientX - this.touchStartX);
1101
+ const dy = Math.abs(touch.clientY - this.touchStartY);
1102
+ if (dx < TAP_THRESHOLD && dy < TAP_THRESHOLD) {
1103
+ // Confirmed tap — focus to show keyboard, then delegate to GestureHandler
1104
+ this.focus();
1105
+ const local = this.touchToLocal(touch);
1106
+ if (local) {
1107
+ this.gestureHandler?.handleTap(local.x, local.y);
1108
+ }
1109
+ }
1110
+ else {
1111
+ // End of pan
1112
+ this.gestureHandler?.handlePan(0, 0, 0, GestureState.END);
1113
+ }
1114
+ }
1115
+ }
1116
+ handleTouchCancel(_e) {
1117
+ this.cancelLongPress();
1118
+ this.isPinching = false;
1119
+ this.gestureHandler?.handlePan(0, 0, 0, GestureState.CANCELLED);
1120
+ }
1121
+ /**
1122
+ * Show a floating "Copy" button near the selection so the user can tap
1123
+ * to copy. iOS Safari doesn't show its native callout for programmatic
1124
+ * selections, so we provide our own.
1125
+ */
1126
+ showCopyTooltip(text) {
1127
+ this.pendingCopyText = text;
1128
+ if (!this.container)
1129
+ return;
1130
+ // Position near the top-center of the selection
1131
+ const sel = this.selection;
1132
+ if (!sel)
1133
+ return;
1134
+ const minRow = Math.min(sel.startRow, sel.endRow);
1135
+ const midCol = Math.round((sel.startCol + sel.endCol) / 2);
1136
+ const topPx = minRow * this.cellHeight;
1137
+ const leftPx = midCol * this.cellWidth;
1138
+ if (!this.copyTooltip) {
1139
+ const tip = document.createElement("div");
1140
+ Object.assign(tip.style, {
1141
+ position: "absolute",
1142
+ zIndex: "100",
1143
+ display: "flex",
1144
+ gap: "2px",
1145
+ padding: "6px 16px",
1146
+ borderRadius: "8px",
1147
+ backgroundColor: "rgba(60, 60, 60, 0.95)",
1148
+ color: "#fff",
1149
+ fontSize: "14px",
1150
+ fontFamily: "-apple-system, BlinkMacSystemFont, sans-serif",
1151
+ cursor: "pointer",
1152
+ userSelect: "none",
1153
+ WebkitUserSelect: "none",
1154
+ boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
1155
+ whiteSpace: "nowrap",
1156
+ pointerEvents: "auto",
1157
+ transform: "translateX(-50%)",
1158
+ });
1159
+ tip.textContent = "Copy";
1160
+ tip.addEventListener("touchend", (e) => {
1161
+ e.preventDefault();
1162
+ e.stopPropagation();
1163
+ this.doCopy();
1164
+ });
1165
+ tip.addEventListener("click", (e) => {
1166
+ e.preventDefault();
1167
+ e.stopPropagation();
1168
+ this.doCopy();
1169
+ });
1170
+ this.container.appendChild(tip);
1171
+ this.copyTooltip = tip;
1172
+ }
1173
+ // Position above the selection, clamped within the container
1174
+ const tipTop = Math.max(0, topPx - 40);
1175
+ this.copyTooltip.style.top = `${tipTop}px`;
1176
+ this.copyTooltip.style.left = `${Math.max(30, leftPx)}px`;
1177
+ this.copyTooltip.style.display = "flex";
1178
+ }
1179
+ /** Copy pending text to clipboard and dismiss the tooltip. */
1180
+ doCopy() {
1181
+ if (this.pendingCopyText && typeof navigator !== "undefined" && navigator.clipboard) {
1182
+ navigator.clipboard.writeText(this.pendingCopyText).catch(() => {
1183
+ /* ignore */
1184
+ });
1185
+ }
1186
+ this.hideCopyTooltip();
1187
+ this.clearSelection();
1188
+ }
1189
+ /** Hide the copy tooltip. */
1190
+ hideCopyTooltip() {
1191
+ if (this.copyTooltip) {
1192
+ this.copyTooltip.style.display = "none";
1193
+ }
1194
+ this.pendingCopyText = "";
1195
+ }
1196
+ }
1197
+ //# sourceMappingURL=input-handler.js.map