@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.
@@ -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
+ }