@cplieger/web-terminal-engine 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +692 -0
- package/README.md +97 -0
- package/jsr.json +18 -0
- package/package.json +42 -0
- package/src/connection.ts +584 -0
- package/src/index.ts +10 -0
- package/src/keyboard.ts +630 -0
- package/src/modes.ts +95 -0
- package/src/mouse.ts +203 -0
- package/src/reconnect.ts +64 -0
- package/src/render.ts +823 -0
- package/src/scroll.ts +103 -0
- package/src/store.ts +389 -0
- package/src/types.ts +165 -0
- package/src/wire-binary.ts +238 -0
- package/src/wire.ts +36 -0
- package/src/wsurl.ts +19 -0
package/src/keyboard.ts
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
// Keyboard event → terminal byte sequence mapping.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors xterm.js's evaluateKeyboardEvent (src/common/input/Keyboard.ts):
|
|
4
|
+
// every browser KeyboardEvent maps to either a sequence to send to the
|
|
5
|
+
// PTY or a local action (page-up/down for local scrollback nav). The
|
|
6
|
+
// mapping is exhaustive over xterm.js's coverage so vim/readline/Ink/
|
|
7
|
+
// the host application get the keys they expect.
|
|
8
|
+
//
|
|
9
|
+
// Modifier encoding follows xterm.js / VT520 convention:
|
|
10
|
+
// 1=Shift, 2=Alt, 4=Ctrl, 8=Meta. Sum then +1 for CSI 1;{n}{letter}.
|
|
11
|
+
//
|
|
12
|
+
// Application cursor mode (DECCKM, CSI ?1h/l) is tracked client-side
|
|
13
|
+
// via modes.ts (announced by server in wireMsgModes). Server-side, the
|
|
14
|
+
// vt screen tracks both bracketed paste (?2004) and DECCKM (?1) and
|
|
15
|
+
// emits a modes frame whenever they change.
|
|
16
|
+
|
|
17
|
+
import { isApplicationCursor, isApplicationKeypad, isBracketedPaste } from "./modes.js";
|
|
18
|
+
|
|
19
|
+
/** Result of mapping a keyboard event. */
|
|
20
|
+
export type KeyboardResult =
|
|
21
|
+
| { kind: "send"; bytes: string }
|
|
22
|
+
| { kind: "scroll-up" } // Shift+PageUp — handled locally
|
|
23
|
+
| { kind: "scroll-down" } // Shift+PageDown — handled locally
|
|
24
|
+
| { kind: "ignore" }; // Modifier-only press, etc.
|
|
25
|
+
|
|
26
|
+
const ESC = "\x1b";
|
|
27
|
+
const DEL = "\x7f";
|
|
28
|
+
|
|
29
|
+
/** Compute the xterm modifier digit (used in CSI 1;{n}letter sequences). */
|
|
30
|
+
function modifiersDigit(ev: KeyboardEvent): number {
|
|
31
|
+
return (
|
|
32
|
+
1 + (ev.shiftKey ? 1 : 0) + (ev.altKey ? 2 : 0) + (ev.ctrlKey ? 4 : 0) + (ev.metaKey ? 8 : 0)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// -- Cursor / navigation keys -----------------------------------------------
|
|
37
|
+
// Letter is the xterm trailing letter (ABCDEFGHPQRS for arrows / Home /
|
|
38
|
+
// End / F1-F4 etc.). Without modifiers we send the bare CSI form; with
|
|
39
|
+
// modifiers we send CSI 1;{mod}{letter}. xterm.js Keyboard.ts pattern.
|
|
40
|
+
//
|
|
41
|
+
// Application cursor mode (DECCKM, CSI ?1) is plumbed via modes.ts.
|
|
42
|
+
// When the application has set DECCKM, the modifier-less form switches
|
|
43
|
+
// from CSI to SS3 (ESC O letter); modifier-bearing forms stay on CSI
|
|
44
|
+
// because they have no SS3 equivalent.
|
|
45
|
+
function csiLetter(letter: string, ev: KeyboardEvent): string {
|
|
46
|
+
const m = modifiersDigit(ev);
|
|
47
|
+
if (m === 1) {
|
|
48
|
+
if (
|
|
49
|
+
isApplicationCursor() &&
|
|
50
|
+
(letter === "A" ||
|
|
51
|
+
letter === "B" ||
|
|
52
|
+
letter === "C" ||
|
|
53
|
+
letter === "D" ||
|
|
54
|
+
letter === "H" ||
|
|
55
|
+
letter === "F")
|
|
56
|
+
) {
|
|
57
|
+
return `${ESC}O${letter}`;
|
|
58
|
+
}
|
|
59
|
+
return `${ESC}[${letter}`;
|
|
60
|
+
}
|
|
61
|
+
return `${ESC}[1;${m}${letter}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Tilde-form keys (Insert=2, Delete=3, PageUp=5, PageDown=6, F5+=15..24).
|
|
65
|
+
function csiTilde(num: number, ev: KeyboardEvent): string {
|
|
66
|
+
const m = modifiersDigit(ev);
|
|
67
|
+
return m === 1 ? `${ESC}[${num}~` : `${ESC}[${num};${m}~`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const FN_LETTER: Record<string, string | undefined> = {
|
|
71
|
+
F1: "P",
|
|
72
|
+
F2: "Q",
|
|
73
|
+
F3: "R",
|
|
74
|
+
F4: "S",
|
|
75
|
+
};
|
|
76
|
+
const FN_TILDE: Record<string, number | undefined> = {
|
|
77
|
+
F5: 15,
|
|
78
|
+
F6: 17,
|
|
79
|
+
F7: 18,
|
|
80
|
+
F8: 19,
|
|
81
|
+
F9: 20,
|
|
82
|
+
F10: 21,
|
|
83
|
+
F11: 23,
|
|
84
|
+
F12: 24,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const ARROW_LETTER: Record<string, string | undefined> = {
|
|
88
|
+
ArrowUp: "A",
|
|
89
|
+
ArrowDown: "B",
|
|
90
|
+
ArrowRight: "C",
|
|
91
|
+
ArrowLeft: "D",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Application keypad mode (DECKPAM) SS3 mappings. When the server has
|
|
95
|
+
// sent ESC = (DECKPAM), numeric keypad keys send ESC O <letter> instead
|
|
96
|
+
// of their normal character. Mapping from VT100 User Guide Table 3-8
|
|
97
|
+
// (ANSI mode, application keypad). xterm extensions for *, +, / included.
|
|
98
|
+
const KEYPAD_SS3: Record<string, string | undefined> = {
|
|
99
|
+
"0": "p",
|
|
100
|
+
"1": "q",
|
|
101
|
+
"2": "r",
|
|
102
|
+
"3": "s",
|
|
103
|
+
"4": "t",
|
|
104
|
+
"5": "u",
|
|
105
|
+
"6": "v",
|
|
106
|
+
"7": "w",
|
|
107
|
+
"8": "x",
|
|
108
|
+
"9": "y",
|
|
109
|
+
".": "n",
|
|
110
|
+
"-": "m",
|
|
111
|
+
"+": "k",
|
|
112
|
+
"*": "j",
|
|
113
|
+
"/": "o",
|
|
114
|
+
Enter: "M",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* mapKeyboardEvent converts a KeyboardEvent into the terminal action
|
|
119
|
+
* to take. Returns "ignore" when the event is purely a modifier press
|
|
120
|
+
* or when the browser should be allowed to handle it (e.g. browser
|
|
121
|
+
* shortcuts like Cmd+R).
|
|
122
|
+
*
|
|
123
|
+
* Caller is responsible for ev.preventDefault() when the result is
|
|
124
|
+
* "send" or "scroll-*"; we don't call it here so the function stays
|
|
125
|
+
* pure and testable.
|
|
126
|
+
*/
|
|
127
|
+
export function mapKeyboardEvent(ev: KeyboardEvent): KeyboardResult {
|
|
128
|
+
// Modifier-only presses (Shift, Ctrl, Alt, Meta) — no-op.
|
|
129
|
+
if (ev.key === "Shift" || ev.key === "Control" || ev.key === "Alt" || ev.key === "Meta") {
|
|
130
|
+
return { kind: "ignore" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Composition-in-progress: caller ignores keydowns while
|
|
134
|
+
// CompositionHelper.isComposing is true. We don't see that state
|
|
135
|
+
// here. The caller filters at its layer.
|
|
136
|
+
|
|
137
|
+
// Application keypad mode (DECKPAM): when active, numpad keys send
|
|
138
|
+
// SS3 sequences (ESC O <letter>) instead of their normal characters.
|
|
139
|
+
// We detect numpad keys via ev.code (Numpad0-9, NumpadDecimal,
|
|
140
|
+
// NumpadSubtract, NumpadAdd, NumpadMultiply, NumpadDivide, NumpadEnter).
|
|
141
|
+
if (
|
|
142
|
+
isApplicationKeypad() &&
|
|
143
|
+
!ev.ctrlKey &&
|
|
144
|
+
!ev.altKey &&
|
|
145
|
+
!ev.metaKey &&
|
|
146
|
+
ev.code.startsWith("Numpad")
|
|
147
|
+
) {
|
|
148
|
+
const ss3Key = numpadCodeToKey(ev.code);
|
|
149
|
+
if (ss3Key !== undefined) {
|
|
150
|
+
const ss3Char = KEYPAD_SS3[ss3Key];
|
|
151
|
+
if (ss3Char !== undefined) {
|
|
152
|
+
return { kind: "send", bytes: `${ESC}O${ss3Char}` };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Browser-reserved meta combos (Cmd+R, Cmd+T, Cmd+Q, Cmd+W on Mac;
|
|
158
|
+
// Ctrl+R, Ctrl+T, Ctrl+W with no Shift on others). Let the browser
|
|
159
|
+
// handle them. Heuristic: only take Ctrl+letter when Shift is NOT
|
|
160
|
+
// held AND the letter is one of the ones xterm consumes (a-z),
|
|
161
|
+
// skipping the few that browsers commonly reserve.
|
|
162
|
+
// (We deliberately pass Ctrl+R / Ctrl+T / Ctrl+W through to the PTY
|
|
163
|
+
// — the host application's Ctrl+T transcript view depends on it. Users on macOS
|
|
164
|
+
// who want browser-reserved combos can use Cmd instead.)
|
|
165
|
+
|
|
166
|
+
// Shift+PageUp / Shift+PageDown — local scrollback navigation
|
|
167
|
+
// (matches xterm.js KeyboardResultType.PAGE_UP/PAGE_DOWN).
|
|
168
|
+
if (ev.key === "PageUp" && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
|
|
169
|
+
return { kind: "scroll-up" };
|
|
170
|
+
}
|
|
171
|
+
if (ev.key === "PageDown" && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
|
|
172
|
+
return { kind: "scroll-down" };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Cursor keys — CSI form with optional modifiers (CSI 1;{m}{letter}).
|
|
176
|
+
const arrow = ARROW_LETTER[ev.key];
|
|
177
|
+
if (arrow !== undefined) {
|
|
178
|
+
return { kind: "send", bytes: csiLetter(arrow, ev) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Home / End — CSI {H,F} with optional modifiers.
|
|
182
|
+
if (ev.key === "Home") {
|
|
183
|
+
return { kind: "send", bytes: csiLetter("H", ev) };
|
|
184
|
+
}
|
|
185
|
+
if (ev.key === "End") {
|
|
186
|
+
return { kind: "send", bytes: csiLetter("F", ev) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Insert / Delete / PageUp / PageDown (no Shift) — CSI tilde forms.
|
|
190
|
+
if (ev.key === "Insert") {
|
|
191
|
+
return { kind: "send", bytes: csiTilde(2, ev) };
|
|
192
|
+
}
|
|
193
|
+
if (ev.key === "Delete") {
|
|
194
|
+
return { kind: "send", bytes: csiTilde(3, ev) };
|
|
195
|
+
}
|
|
196
|
+
if (ev.key === "PageUp") {
|
|
197
|
+
return { kind: "send", bytes: csiTilde(5, ev) };
|
|
198
|
+
}
|
|
199
|
+
if (ev.key === "PageDown") {
|
|
200
|
+
return { kind: "send", bytes: csiTilde(6, ev) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// F1-F4 — SS3 with optional modifier-CSI, like xterm.
|
|
204
|
+
const fnLetter = FN_LETTER[ev.key];
|
|
205
|
+
if (fnLetter !== undefined) {
|
|
206
|
+
const m = modifiersDigit(ev);
|
|
207
|
+
return {
|
|
208
|
+
kind: "send",
|
|
209
|
+
bytes: m === 1 ? `${ESC}O${fnLetter}` : `${ESC}[1;${m}${fnLetter}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// F5-F12 — CSI tilde form with modifiers.
|
|
213
|
+
const fnTilde = FN_TILDE[ev.key];
|
|
214
|
+
if (fnTilde !== undefined) {
|
|
215
|
+
return { kind: "send", bytes: csiTilde(fnTilde, ev) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Tab / Shift+Tab.
|
|
219
|
+
if (ev.key === "Tab") {
|
|
220
|
+
return { kind: "send", bytes: ev.shiftKey ? `${ESC}[Z` : "\t" };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Enter — \r. Alt+Enter prefixes ESC.
|
|
224
|
+
if (ev.key === "Enter") {
|
|
225
|
+
return { kind: "send", bytes: ev.altKey ? `${ESC}\r` : "\r" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Backspace — \x7f (DEL). Alt+Backspace = ESC + DEL (delete-prev-word
|
|
229
|
+
// in readline). Ctrl+Backspace = \b (^H).
|
|
230
|
+
if (ev.key === "Backspace") {
|
|
231
|
+
if (ev.altKey) {
|
|
232
|
+
return { kind: "send", bytes: ESC + DEL };
|
|
233
|
+
}
|
|
234
|
+
if (ev.ctrlKey) {
|
|
235
|
+
return { kind: "send", bytes: "\b" };
|
|
236
|
+
}
|
|
237
|
+
return { kind: "send", bytes: DEL };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Escape — ESC. Alt+Escape = ESC ESC (xterm.js convention).
|
|
241
|
+
if (ev.key === "Escape") {
|
|
242
|
+
return { kind: "send", bytes: ev.altKey ? `${ESC}${ESC}` : ESC };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Space — \x00 with Ctrl (per xterm). Alt+Space = ESC ' '.
|
|
246
|
+
if (ev.key === " ") {
|
|
247
|
+
if (ev.ctrlKey) {
|
|
248
|
+
return { kind: "send", bytes: "\x00" };
|
|
249
|
+
}
|
|
250
|
+
if (ev.altKey) {
|
|
251
|
+
return { kind: "send", bytes: ESC + " " };
|
|
252
|
+
}
|
|
253
|
+
return { kind: "ignore" }; // let `input` event handle plain space
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Single printable character with modifiers.
|
|
257
|
+
if (ev.key.length === 1) {
|
|
258
|
+
const ch = ev.key;
|
|
259
|
+
|
|
260
|
+
// Ctrl+printable → C0 control byte (a-z → \x01..\x1a, plus the
|
|
261
|
+
// C0 set @[\]^_? → \x00..\x1f, \x7f). Single branch via
|
|
262
|
+
// `ctrlByteFor`; same table also drives bindMobileToolbar's
|
|
263
|
+
// sticky-Ctrl applier.
|
|
264
|
+
if (ev.ctrlKey && !ev.altKey && !ev.metaKey) {
|
|
265
|
+
const c0 = ctrlByteFor(ch);
|
|
266
|
+
if (c0 !== null) {
|
|
267
|
+
return { kind: "send", bytes: c0 };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Alt+printable → ESC + char (meta prefix). Plain `input` event
|
|
271
|
+
// would still fire with the char, so we'd duplicate. Caller must
|
|
272
|
+
// preventDefault when we return "send" here to suppress `input`.
|
|
273
|
+
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
|
|
274
|
+
return { kind: "send", bytes: ESC + ch };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Everything else: defer to the `input` event. The browser will
|
|
279
|
+
// produce the printable character (including IME / dead-key
|
|
280
|
+
// composition output) via input, where we send it.
|
|
281
|
+
return { kind: "ignore" };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* ctrlByteFor returns the C0 control byte produced by Ctrl+`ch`, or
|
|
286
|
+
* `null` when the character has no Ctrl mapping. The full table:
|
|
287
|
+
*
|
|
288
|
+
* a-z (case-folded) → \x01..\x1a (Ctrl+A=SOH .. Ctrl+Z=SUB)
|
|
289
|
+
* ' ' (space) → \x00 (NUL — same as Ctrl+@)
|
|
290
|
+
* '@' → \x00 (NUL)
|
|
291
|
+
* '[' → \x1b (ESC)
|
|
292
|
+
* '\\' → \x1c (FS)
|
|
293
|
+
* ']' → \x1d (GS)
|
|
294
|
+
* '^' → \x1e (RS)
|
|
295
|
+
* '_' → \x1f (US)
|
|
296
|
+
* '?' → \x7f (DEL — also Ctrl+8 on US layouts via Shift+/)
|
|
297
|
+
*
|
|
298
|
+
* Anything else (multi-char strings, unmapped single chars) returns
|
|
299
|
+
* `null`. Used by `mapKeyboardEvent` for Ctrl+printable handling and
|
|
300
|
+
* by `bindMobileToolbar`'s sticky-Ctrl applier.
|
|
301
|
+
*/
|
|
302
|
+
export function ctrlByteFor(ch: string): string | null {
|
|
303
|
+
if (ch.length !== 1) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
// Letters a-z (case-folded) → \x01..\x1a.
|
|
307
|
+
const code = ch.toLowerCase().charCodeAt(0);
|
|
308
|
+
if (code >= 97 && code <= 122) {
|
|
309
|
+
return String.fromCharCode(code - 96);
|
|
310
|
+
}
|
|
311
|
+
switch (ch) {
|
|
312
|
+
case " ":
|
|
313
|
+
case "@":
|
|
314
|
+
return "\x00";
|
|
315
|
+
case "[":
|
|
316
|
+
return "\x1b";
|
|
317
|
+
case "\\":
|
|
318
|
+
return "\x1c";
|
|
319
|
+
case "]":
|
|
320
|
+
return "\x1d";
|
|
321
|
+
case "^":
|
|
322
|
+
return "\x1e";
|
|
323
|
+
case "_":
|
|
324
|
+
return "\x1f";
|
|
325
|
+
case "?":
|
|
326
|
+
return "\x7f";
|
|
327
|
+
default:
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// -- Application keypad helpers -----------------------------------------------
|
|
333
|
+
|
|
334
|
+
/** Map a KeyboardEvent.code starting with "Numpad" to the KEYPAD_SS3 lookup key. */
|
|
335
|
+
function numpadCodeToKey(code: string): string | undefined {
|
|
336
|
+
switch (code) {
|
|
337
|
+
case "Numpad0":
|
|
338
|
+
return "0";
|
|
339
|
+
case "Numpad1":
|
|
340
|
+
return "1";
|
|
341
|
+
case "Numpad2":
|
|
342
|
+
return "2";
|
|
343
|
+
case "Numpad3":
|
|
344
|
+
return "3";
|
|
345
|
+
case "Numpad4":
|
|
346
|
+
return "4";
|
|
347
|
+
case "Numpad5":
|
|
348
|
+
return "5";
|
|
349
|
+
case "Numpad6":
|
|
350
|
+
return "6";
|
|
351
|
+
case "Numpad7":
|
|
352
|
+
return "7";
|
|
353
|
+
case "Numpad8":
|
|
354
|
+
return "8";
|
|
355
|
+
case "Numpad9":
|
|
356
|
+
return "9";
|
|
357
|
+
case "NumpadDecimal":
|
|
358
|
+
return ".";
|
|
359
|
+
case "NumpadSubtract":
|
|
360
|
+
return "-";
|
|
361
|
+
case "NumpadAdd":
|
|
362
|
+
return "+";
|
|
363
|
+
case "NumpadMultiply":
|
|
364
|
+
return "*";
|
|
365
|
+
case "NumpadDivide":
|
|
366
|
+
return "/";
|
|
367
|
+
case "NumpadEnter":
|
|
368
|
+
return "Enter";
|
|
369
|
+
default:
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// -- Bracketed paste --------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* bracketTextForPaste wraps text in DEC 2004 bracketed-paste sentinels
|
|
378
|
+
* after sanitising any embedded ESC bytes to U+241B (visible escape
|
|
379
|
+
* symbol), but only when the application has currently enabled
|
|
380
|
+
* bracketed-paste mode (CSI ?2004h). When disabled, returns the text
|
|
381
|
+
* unchanged. The current mode state is owned by modes.ts, kept in
|
|
382
|
+
* sync by the server's wireMsgModes wire frame.
|
|
383
|
+
*
|
|
384
|
+
* The ESC sanitisation defends against an attacker-controlled paste
|
|
385
|
+
* containing \x1b[201~ that would prematurely close the paste region
|
|
386
|
+
* and let the rest be interpreted as a command — only relevant when
|
|
387
|
+
* we are bracketing.
|
|
388
|
+
*/
|
|
389
|
+
export function bracketTextForPaste(text: string): string {
|
|
390
|
+
if (!isBracketedPaste()) {
|
|
391
|
+
return text;
|
|
392
|
+
}
|
|
393
|
+
// eslint-disable-next-line no-control-regex -- intentional: sanitising ESC bytes in pasted text
|
|
394
|
+
const sanitised = text.replace(/\x1b/g, "\u241B");
|
|
395
|
+
return `\x1b[200~${sanitised}\x1b[201~`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Normalise CR/LF to a single CR before bracketing. xterm.js convention. */
|
|
399
|
+
export function prepareTextForTerminal(text: string): string {
|
|
400
|
+
return text.replace(/\r?\n/g, "\r");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// -- Mobile toolbar ---------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Default DOM ids for the on-screen mobile keyboard toolbar buttons,
|
|
407
|
+
* inside the toolbar container element passed to `bindMobileToolbar`.
|
|
408
|
+
* Override individual ids via `BindMobileToolbarOptions.ids`.
|
|
409
|
+
*/
|
|
410
|
+
export const DEFAULT_TOOLBAR_IDS = {
|
|
411
|
+
toggle: "kb-toggle",
|
|
412
|
+
ctrl: "kb-ctrl",
|
|
413
|
+
up: "kb-up",
|
|
414
|
+
down: "kb-down",
|
|
415
|
+
left: "kb-left",
|
|
416
|
+
right: "kb-right",
|
|
417
|
+
tab: "kb-tab",
|
|
418
|
+
enter: "kb-enter",
|
|
419
|
+
esc: "kb-esc",
|
|
420
|
+
} as const;
|
|
421
|
+
|
|
422
|
+
/** Per-button id overrides for `bindMobileToolbar`. */
|
|
423
|
+
export interface MobileToolbarIds {
|
|
424
|
+
readonly toggle?: string;
|
|
425
|
+
readonly ctrl?: string;
|
|
426
|
+
readonly up?: string;
|
|
427
|
+
readonly down?: string;
|
|
428
|
+
readonly left?: string;
|
|
429
|
+
readonly right?: string;
|
|
430
|
+
readonly tab?: string;
|
|
431
|
+
readonly enter?: string;
|
|
432
|
+
readonly esc?: string;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Options for `bindMobileToolbar`. */
|
|
436
|
+
export interface BindMobileToolbarOptions {
|
|
437
|
+
/**
|
|
438
|
+
* Toolbar container element. Buttons are looked up by id within this
|
|
439
|
+
* element via `querySelector('#<id>')`. The same element is the one
|
|
440
|
+
* `kb-toggle` adds/removes the `.collapsed` class on.
|
|
441
|
+
*/
|
|
442
|
+
readonly toolbar: HTMLElement;
|
|
443
|
+
/**
|
|
444
|
+
* Send sink for arrow keys, Tab, Enter, Esc — i.e. for everything
|
|
445
|
+
* the toolbar emits. Sticky-Ctrl input goes through `applyStickyCtrl`
|
|
446
|
+
* on the controller; the consumer is responsible for routing
|
|
447
|
+
* keyboard / paste / IME text through it before handing the result
|
|
448
|
+
* to the same `send`.
|
|
449
|
+
*/
|
|
450
|
+
readonly send: (bytes: string) => void;
|
|
451
|
+
/** Optional id overrides. Missing keys fall back to defaults. */
|
|
452
|
+
readonly ids?: MobileToolbarIds;
|
|
453
|
+
/**
|
|
454
|
+
* Fired whenever the sticky-Ctrl state changes (toolbar press,
|
|
455
|
+
* `setCtrlArmed`, or auto-disarm after applying a Ctrl byte).
|
|
456
|
+
*/
|
|
457
|
+
readonly onCtrlChange?: (armed: boolean) => void;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Returned from `bindMobileToolbar`; manages sticky-Ctrl state and tear-down. */
|
|
461
|
+
export interface MobileToolbarController {
|
|
462
|
+
/**
|
|
463
|
+
* Apply sticky-Ctrl to a piece of input text.
|
|
464
|
+
* - When NOT armed: returns `text` unchanged.
|
|
465
|
+
* - Armed AND `text.length === 1`: returns the matching Ctrl byte
|
|
466
|
+
* (`ctrlByteFor(text)`), or the original char when no mapping
|
|
467
|
+
* exists. Always disarms.
|
|
468
|
+
* - Armed AND longer (e.g. paste, IME commit): returns `text`
|
|
469
|
+
* unchanged and disarms — applying Ctrl to a multi-char string
|
|
470
|
+
* would garble it.
|
|
471
|
+
*/
|
|
472
|
+
applyStickyCtrl(text: string): string;
|
|
473
|
+
/** Programmatically arm/disarm Ctrl. Updates the toolbar button visuals + fires `onCtrlChange`. */
|
|
474
|
+
setCtrlArmed(on: boolean): void;
|
|
475
|
+
/** Whether sticky-Ctrl is currently armed. */
|
|
476
|
+
isCtrlArmed(): boolean;
|
|
477
|
+
/** Detach all event listeners and reset the toolbar to a non-armed state. Idempotent. */
|
|
478
|
+
dispose(): void;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Wire a mobile / touch toolbar of on-screen keyboard buttons (Ctrl,
|
|
483
|
+
* arrows, Tab, Enter, Esc, plus a collapse toggle) to a vterm send
|
|
484
|
+
* sink. This was duplicated across vibekit and vibecli — same wire
|
|
485
|
+
* sequences, same sticky-Ctrl semantics, same DECCKM nuance — so it
|
|
486
|
+
* lives here.
|
|
487
|
+
*
|
|
488
|
+
* Each button's `pointerdown` is intercepted with `preventDefault()`
|
|
489
|
+
* so the press never fires a focus change or scroll on the host page.
|
|
490
|
+
*
|
|
491
|
+
* Arrow keys consult `isApplicationCursor()` from `modes.ts` so apps
|
|
492
|
+
* that have set DECCKM (vim, less, fzf, htop, …) get SS3 sequences
|
|
493
|
+
* (`ESC O A..D`) and apps in the default mode get the bare CSI form
|
|
494
|
+
* (`ESC [ A..D`). This matches what `mapKeyboardEvent` does for
|
|
495
|
+
* physical-keyboard arrows.
|
|
496
|
+
*/
|
|
497
|
+
export function bindMobileToolbar(opts: BindMobileToolbarOptions): MobileToolbarController {
|
|
498
|
+
const ids = { ...DEFAULT_TOOLBAR_IDS, ...opts.ids };
|
|
499
|
+
let armed = false;
|
|
500
|
+
|
|
501
|
+
function findBtn(id: string): HTMLElement | null {
|
|
502
|
+
// IDs are kebab-case ASCII; no CSS escaping needed.
|
|
503
|
+
return opts.toolbar.querySelector<HTMLElement>(`#${id}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const toggleBtn = findBtn(ids.toggle);
|
|
507
|
+
const ctrlBtn = findBtn(ids.ctrl);
|
|
508
|
+
const upBtn = findBtn(ids.up);
|
|
509
|
+
const downBtn = findBtn(ids.down);
|
|
510
|
+
const leftBtn = findBtn(ids.left);
|
|
511
|
+
const rightBtn = findBtn(ids.right);
|
|
512
|
+
const tabBtn = findBtn(ids.tab);
|
|
513
|
+
const enterBtn = findBtn(ids.enter);
|
|
514
|
+
const escBtn = findBtn(ids.esc);
|
|
515
|
+
|
|
516
|
+
function setCtrlArmed(on: boolean): void {
|
|
517
|
+
if (armed === on) {
|
|
518
|
+
// Still update visuals defensively (e.g. after dispose was called
|
|
519
|
+
// and someone re-armed via setCtrlArmed) — but skip the change
|
|
520
|
+
// notification to keep onCtrlChange edge-triggered.
|
|
521
|
+
if (ctrlBtn) {
|
|
522
|
+
ctrlBtn.classList.toggle("armed", on);
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
armed = on;
|
|
527
|
+
if (ctrlBtn) {
|
|
528
|
+
ctrlBtn.classList.toggle("armed", on);
|
|
529
|
+
}
|
|
530
|
+
opts.onCtrlChange?.(on);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function arrowSeq(letter: "A" | "B" | "C" | "D"): string {
|
|
534
|
+
return isApplicationCursor() ? `${ESC}O${letter}` : `${ESC}[${letter}`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Listener registry so dispose() can detach them all.
|
|
538
|
+
const cleanups: (() => void)[] = [];
|
|
539
|
+
function on(el: HTMLElement | null, handler: (e: PointerEvent) => void): void {
|
|
540
|
+
if (!el) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const wrapped = (e: Event): void => {
|
|
544
|
+
handler(e as PointerEvent);
|
|
545
|
+
};
|
|
546
|
+
el.addEventListener("pointerdown", wrapped);
|
|
547
|
+
cleanups.push(() => {
|
|
548
|
+
el.removeEventListener("pointerdown", wrapped);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
on(toggleBtn, (e) => {
|
|
553
|
+
e.preventDefault();
|
|
554
|
+
// Show/hide the toolbar. Deliberately does NOT clear armed Ctrl —
|
|
555
|
+
// toggling visibility shouldn't lose state.
|
|
556
|
+
opts.toolbar.classList.toggle("collapsed");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
on(ctrlBtn, (e) => {
|
|
560
|
+
e.preventDefault();
|
|
561
|
+
setCtrlArmed(!armed);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
on(upBtn, (e) => {
|
|
565
|
+
e.preventDefault();
|
|
566
|
+
setCtrlArmed(false);
|
|
567
|
+
opts.send(arrowSeq("A"));
|
|
568
|
+
});
|
|
569
|
+
on(downBtn, (e) => {
|
|
570
|
+
e.preventDefault();
|
|
571
|
+
setCtrlArmed(false);
|
|
572
|
+
opts.send(arrowSeq("B"));
|
|
573
|
+
});
|
|
574
|
+
on(rightBtn, (e) => {
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
setCtrlArmed(false);
|
|
577
|
+
opts.send(arrowSeq("C"));
|
|
578
|
+
});
|
|
579
|
+
on(leftBtn, (e) => {
|
|
580
|
+
e.preventDefault();
|
|
581
|
+
setCtrlArmed(false);
|
|
582
|
+
opts.send(arrowSeq("D"));
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
on(tabBtn, (e) => {
|
|
586
|
+
e.preventDefault();
|
|
587
|
+
setCtrlArmed(false);
|
|
588
|
+
opts.send("\t");
|
|
589
|
+
});
|
|
590
|
+
on(enterBtn, (e) => {
|
|
591
|
+
e.preventDefault();
|
|
592
|
+
setCtrlArmed(false);
|
|
593
|
+
opts.send("\r");
|
|
594
|
+
});
|
|
595
|
+
on(escBtn, (e) => {
|
|
596
|
+
e.preventDefault();
|
|
597
|
+
setCtrlArmed(false);
|
|
598
|
+
opts.send(ESC);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
function applyStickyCtrl(text: string): string {
|
|
602
|
+
if (!armed) {
|
|
603
|
+
return text;
|
|
604
|
+
}
|
|
605
|
+
if (text.length === 1) {
|
|
606
|
+
const ctrl = ctrlByteFor(text);
|
|
607
|
+
setCtrlArmed(false);
|
|
608
|
+
return ctrl ?? text;
|
|
609
|
+
}
|
|
610
|
+
// Multi-char input (paste, IME commit) — applying Ctrl to a string
|
|
611
|
+
// would garble it. Disarm and pass through verbatim.
|
|
612
|
+
setCtrlArmed(false);
|
|
613
|
+
return text;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function dispose(): void {
|
|
617
|
+
setCtrlArmed(false);
|
|
618
|
+
for (const c of cleanups) {
|
|
619
|
+
c();
|
|
620
|
+
}
|
|
621
|
+
cleanups.length = 0;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
applyStickyCtrl,
|
|
626
|
+
setCtrlArmed,
|
|
627
|
+
isCtrlArmed: () => armed,
|
|
628
|
+
dispose,
|
|
629
|
+
};
|
|
630
|
+
}
|