@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.
- package/dist/accessibility.d.ts +46 -0
- package/dist/accessibility.d.ts.map +1 -0
- package/dist/accessibility.js +196 -0
- package/dist/accessibility.js.map +1 -0
- package/dist/addon.d.ts.map +1 -0
- package/dist/addon.js +2 -0
- package/dist/addon.js.map +1 -0
- package/dist/addons/fit.d.ts.map +1 -0
- package/dist/addons/fit.js +40 -0
- package/dist/addons/fit.js.map +1 -0
- package/dist/addons/search.d.ts +56 -0
- package/dist/addons/search.d.ts.map +1 -0
- package/dist/addons/search.js +178 -0
- package/dist/addons/search.js.map +1 -0
- package/dist/addons/web-links.d.ts +30 -0
- package/dist/addons/web-links.d.ts.map +1 -0
- package/dist/addons/web-links.js +170 -0
- package/dist/addons/web-links.js.map +1 -0
- package/dist/fit.d.ts.map +1 -0
- package/dist/fit.js +14 -0
- package/dist/fit.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/input-handler.d.ts +185 -0
- package/dist/input-handler.d.ts.map +1 -0
- package/dist/input-handler.js +1197 -0
- package/dist/input-handler.js.map +1 -0
- package/dist/parser-worker.d.ts.map +1 -0
- package/dist/parser-worker.js +128 -0
- package/dist/parser-worker.js.map +1 -0
- package/dist/render-bridge.d.ts +56 -0
- package/dist/render-bridge.d.ts.map +1 -0
- package/dist/render-bridge.js +158 -0
- package/dist/render-bridge.js.map +1 -0
- package/dist/render-worker.d.ts +62 -0
- package/dist/render-worker.d.ts.map +1 -0
- package/dist/render-worker.js +720 -0
- package/dist/render-worker.js.map +1 -0
- package/dist/renderer.d.ts +86 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +454 -0
- package/dist/renderer.js.map +1 -0
- package/dist/shared-context.d.ts +93 -0
- package/dist/shared-context.d.ts.map +1 -0
- package/dist/shared-context.js +561 -0
- package/dist/shared-context.js.map +1 -0
- package/dist/web-terminal.d.ts +152 -0
- package/dist/web-terminal.d.ts.map +1 -0
- package/dist/web-terminal.js +684 -0
- package/dist/web-terminal.js.map +1 -0
- package/dist/webgl-renderer.d.ts +146 -0
- package/dist/webgl-renderer.d.ts.map +1 -0
- package/dist/webgl-renderer.js +1047 -0
- package/dist/webgl-renderer.js.map +1 -0
- package/dist/worker-bridge.d.ts +51 -0
- package/dist/worker-bridge.d.ts.map +1 -0
- package/dist/worker-bridge.js +185 -0
- package/dist/worker-bridge.js.map +1 -0
- 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
|