@cplieger/web-terminal-ui 2.1.4 → 3.0.1
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/README.md +57 -32
- package/css/03-v3.css +590 -0
- package/css/MANIFEST +1 -0
- package/package.json +9 -5
- package/scaffold/index.html +8 -6
- package/src/composition.ts +15 -0
- package/src/features/activity-monitor.ts +64 -0
- package/src/features/animations.ts +29 -0
- package/src/features/clipboard.ts +98 -0
- package/src/features/connection-banner.ts +53 -0
- package/src/features/context-menu.ts +207 -0
- package/src/features/dom.ts +15 -0
- package/src/features/mobile-toolbar.ts +82 -0
- package/src/features/predictive-echo.ts +63 -0
- package/src/features/scroll-to-bottom.ts +49 -0
- package/src/features/tabs.ts +687 -0
- package/src/index.ts +30 -9
- package/src/kernel/a11y.ts +102 -0
- package/src/kernel/bus.ts +61 -0
- package/src/kernel/conn-state.ts +133 -0
- package/src/kernel/kernel.ts +688 -0
- package/src/kernel/regions.ts +119 -0
- package/src/kernel/types.ts +286 -0
- package/src/presets.ts +46 -0
- package/src/mount.ts +0 -903
- package/src/status.ts +0 -228
package/src/mount.ts
DELETED
|
@@ -1,903 +0,0 @@
|
|
|
1
|
-
// @cplieger/web-terminal-ui — reference touch-first browser terminal UI.
|
|
2
|
-
//
|
|
3
|
-
// mount(root) builds the entire terminal subtree inside a single host-provided
|
|
4
|
-
// container element and owns the touch-first input model: a display-only
|
|
5
|
-
// terminal output, a hidden <textarea> that owns the keyboard + IME + local
|
|
6
|
-
// typing buffer, a mobile key toolbar, a viewport-clamped context menu,
|
|
7
|
-
// predictive echo, and viewport/keyboard-inset handling. The consumer's HTML
|
|
8
|
-
// is just <head> + one empty root element (+ an optional loading overlay) +
|
|
9
|
-
// the importmap; mount creates everything else, so there is no shared
|
|
10
|
-
// element-id contract to keep in sync. A consumer that wants a different UI
|
|
11
|
-
// builds on the engine directly instead.
|
|
12
|
-
//
|
|
13
|
-
// Single-instance per page: the module holds the one terminal's DOM refs and
|
|
14
|
-
// state, matching a terminal-per-page model. Call mount() exactly once.
|
|
15
|
-
|
|
16
|
-
import { render, keyboard, scroll, connection } from "@cplieger/web-terminal-engine";
|
|
17
|
-
import * as viewport from "./viewport.js";
|
|
18
|
-
import * as composition from "./composition.js";
|
|
19
|
-
import * as status from "./status.js";
|
|
20
|
-
import * as predict from "./predict.js";
|
|
21
|
-
import { INPUT_PLACEHOLDER, resetToPlaceholder } from "./input-placeholder.js";
|
|
22
|
-
|
|
23
|
-
const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
24
|
-
|
|
25
|
-
export interface MountOptions {
|
|
26
|
-
/** WebSocket endpoint path the engine connects to. Default "/ws"
|
|
27
|
-
* (vibekit serves the shell at "/api/shell/ws", vibecli at "/ws"). */
|
|
28
|
-
wsPath?: string;
|
|
29
|
-
/** CSS font shorthand awaited before the first resize is sent, so the
|
|
30
|
-
* server is sized against the real web font's cell metrics rather than a
|
|
31
|
-
* fallback. Default '14px "MonaspiceNe NFM"'. */
|
|
32
|
-
fontReady?: string;
|
|
33
|
-
/** Optional pre-JS loading overlay element (lives in the served HTML so it
|
|
34
|
-
* paints before this module loads). mount fades it out and removes it once
|
|
35
|
-
* the first screen frame renders. The consumer owns the element; mount only
|
|
36
|
-
* dismisses it. */
|
|
37
|
-
loading?: HTMLElement;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface TerminalUI {
|
|
41
|
-
/** Focus the terminal input (opens the soft keyboard on touch). */
|
|
42
|
-
focus(): void;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const DEFAULT_WS_PATH = "/ws";
|
|
46
|
-
const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
|
|
47
|
-
|
|
48
|
-
const TAP_MOVEMENT_PX = 10;
|
|
49
|
-
const LONG_PRESS_MS = 500;
|
|
50
|
-
const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
|
|
51
|
-
|
|
52
|
-
const encoder = new TextEncoder();
|
|
53
|
-
|
|
54
|
-
// --- DOM refs (picked by class from the subtree mount() builds) ---
|
|
55
|
-
let outputEl!: HTMLElement;
|
|
56
|
-
let termWrap!: HTMLElement;
|
|
57
|
-
let input!: HTMLTextAreaElement;
|
|
58
|
-
let ctxMenu!: HTMLElement;
|
|
59
|
-
|
|
60
|
-
// --- Mutable UI state ---
|
|
61
|
-
// Sticky-Ctrl state + the C0 mapping table + the toolbar button wiring all
|
|
62
|
-
// live in the engine's keyboard.bindMobileToolbar; mount() binds it against
|
|
63
|
-
// the built .key-toolbar element and keeps the controller so the input handler
|
|
64
|
-
// can route typed text through its applyStickyCtrl.
|
|
65
|
-
let toolbarCtrl!: keyboard.MobileToolbarController;
|
|
66
|
-
let fontsLoaded = false;
|
|
67
|
-
let wsOpen = false;
|
|
68
|
-
let lastPointerType = "mouse";
|
|
69
|
-
let pointerDownX = 0;
|
|
70
|
-
let pointerDownY = 0;
|
|
71
|
-
let longPressTimer = 0;
|
|
72
|
-
let longPressOrigin = { x: 0, y: 0 };
|
|
73
|
-
|
|
74
|
-
// iOS Safari shows a system "Paste" permission toast every time
|
|
75
|
-
// navigator.clipboard.readText() is called — by design, and unavoidable
|
|
76
|
-
// from JavaScript. iOS users get a one-tap paste via the native
|
|
77
|
-
// long-press callout (which routes through the textarea's paste event
|
|
78
|
-
// handler in composition.ts without ever calling readText), so we omit
|
|
79
|
-
// the Paste button from our custom menu on iOS to steer them there.
|
|
80
|
-
let isIOS = false;
|
|
81
|
-
|
|
82
|
-
// The terminal subtree mount() builds inside the host-provided root. Static,
|
|
83
|
-
// trusted markup (no interpolation) parsed once via a <template>. Element refs
|
|
84
|
-
// are then picked out by class with scoped queries — no global getElementById,
|
|
85
|
-
// no element-id contract the consumer must reproduce. The only ids are on the
|
|
86
|
-
// kb-* toolbar buttons, which the engine's bindMobileToolbar resolves with a
|
|
87
|
-
// scoped `toolbar.querySelector('#id')` (not a document-global lookup). The
|
|
88
|
-
// predicted-cursor overlay is created and owned by the engine renderer; the
|
|
89
|
-
// loading overlay stays in the consumer's served HTML (it must paint before
|
|
90
|
-
// this module loads).
|
|
91
|
-
const TERMINAL_TEMPLATE = `
|
|
92
|
-
<div class="term">
|
|
93
|
-
<div class="term-output" role="log" aria-live="off" aria-roledescription="Terminal" aria-label="Terminal"></div>
|
|
94
|
-
<textarea class="term-input" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" aria-label="Terminal input" tabindex="-1"></textarea>
|
|
95
|
-
<div class="composition-view" aria-hidden="true"></div>
|
|
96
|
-
</div>
|
|
97
|
-
<div class="conn-banner" role="status" aria-live="polite"></div>
|
|
98
|
-
<div class="key-toolbar collapsed no-transition" aria-label="Navigation keys" role="toolbar">
|
|
99
|
-
<button type="button" id="kb-toggle" class="kb-toggle" aria-label="Toggle key toolbar"><svg class="icon-hamburger" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg><svg class="icon-close" viewBox="0 0 24 24"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>
|
|
100
|
-
<button type="button" id="kb-tab" class="kb-key kb-r1c1" aria-label="Tab">TAB</button>
|
|
101
|
-
<button type="button" id="kb-esc" class="kb-key kb-r1c2" aria-label="Escape">ESC</button>
|
|
102
|
-
<button type="button" id="kb-up" class="kb-key kb-r1c3" aria-label="Up"><svg viewBox="0 0 24 24"><polyline points="6 15 12 9 18 15"/></svg></button>
|
|
103
|
-
<button type="button" id="kb-enter" class="kb-key kb-r1c4" aria-label="Enter"><svg viewBox="0 0 24 24"><polyline points="9 10 4 15 9 20"/><polyline points="20 4 20 15 4 15"/></svg></button>
|
|
104
|
-
<button type="button" class="kb-scroll-bottom" aria-label="Scroll to bottom"><svg viewBox="0 0 24 24"><path fill="none" d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg></button>
|
|
105
|
-
<button type="button" id="kb-ctrl" class="kb-key kb-r2c1" aria-label="Sticky Ctrl modifier" aria-pressed="false">CTRL</button>
|
|
106
|
-
<button type="button" id="kb-left" class="kb-key kb-r2c2" aria-label="Left"><svg viewBox="0 0 24 24"><polyline points="15 6 9 12 15 18"/></svg></button>
|
|
107
|
-
<button type="button" id="kb-down" class="kb-key kb-r2c3" aria-label="Down"><svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></button>
|
|
108
|
-
<button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
|
|
109
|
-
</div>
|
|
110
|
-
<div class="ctx-menu"></div>`;
|
|
111
|
-
|
|
112
|
-
// Pick a freshly-built element out of the root by class. Throws if the
|
|
113
|
-
// template and the selector ever drift apart (a build-time invariant, not a
|
|
114
|
-
// host-scaffold dependency).
|
|
115
|
-
function pick(root: ParentNode, selector: string): HTMLElement {
|
|
116
|
-
const el = root.querySelector<HTMLElement>(selector);
|
|
117
|
-
if (!el) {
|
|
118
|
-
throw new Error(`web-terminal-ui: mount() failed to build element ${selector}`);
|
|
119
|
-
}
|
|
120
|
-
return el;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function send(bytes: string): void {
|
|
124
|
-
// Suppress a lone Backspace (DEL) ONLY at the true origin (row 0, col 0):
|
|
125
|
-
// there's nothing left to delete on an empty line, and this preserves the
|
|
126
|
-
// brake iOS's textarea provided (stops held-Backspace key-repeat when empty).
|
|
127
|
-
// The row check matters: predict.applyInput models a col-0 DEL on a WRAPPED
|
|
128
|
-
// continuation row (row > 0) by wrapping to the end of the previous row, so a
|
|
129
|
-
// continuation-row backspace must cross that wrap and reach the server rather
|
|
130
|
-
// than being braked here (braking it would make backspace silently stick
|
|
131
|
-
// mid-line on a wrapped command line).
|
|
132
|
-
const p = predict.get();
|
|
133
|
-
if (bytes === "\x7f" && p.col === 0 && p.row === 0 && p.active) {
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
const buf = encoder.encode(bytes);
|
|
137
|
-
// sendBinary buffers while disconnected (resume layer) and returns false ONLY when the
|
|
138
|
-
// 1 MiB outbox is full -- the engine has already surfaced that via onOutboxFull ->
|
|
139
|
-
// status.closed(). Advancing the prediction for a dropped byte would paint a phantom
|
|
140
|
-
// char that never reaches the server, so only advance on accepted input.
|
|
141
|
-
if (!connection.sendBinary(buf)) {
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
predict.applyInput(buf);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Normalize iOS's NBSP-for-space quirk, apply sticky-Ctrl, then send.
|
|
148
|
-
function sendTyped(text: string): void {
|
|
149
|
-
send(toolbarCtrl.applyStickyCtrl(text.replace(/\u00A0/g, " ")));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Read the current predicted-cursor state and push it to the renderer.
|
|
153
|
-
// Shared by render.init's onCursorMove (server cursor moved) and
|
|
154
|
-
// predict.subscribe (prediction changed) so both push identically.
|
|
155
|
-
function pushPredictedCursor(): void {
|
|
156
|
-
const p = predict.get();
|
|
157
|
-
render.setPredictedCursor(p.row, p.col, p.active);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Clipboard helpers. navigator.clipboard is UNDEFINED outside a secure context
|
|
161
|
-
// (plain-HTTP on a non-loopback host — a supported web-terminal-server
|
|
162
|
-
// deployment), where a property access such as navigator.clipboard.writeText
|
|
163
|
-
// throws a SYNCHRONOUS TypeError before any .then/.catch can guard it. Feature-
|
|
164
|
-
// detect first so Copy/Paste surface a "Clipboard unavailable" toast instead of
|
|
165
|
-
// throwing out of the handler (on Ctrl+Shift+C an uncaught throw would also skip
|
|
166
|
-
// preventDefault and let the browser's native devtools shortcut fire).
|
|
167
|
-
function copyToClipboard(text: string): void {
|
|
168
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- navigator.clipboard is undefined outside secure contexts (plain-HTTP host)
|
|
169
|
-
if (!navigator.clipboard) {
|
|
170
|
-
status.toast("Clipboard unavailable");
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
navigator.clipboard
|
|
174
|
-
.writeText(text)
|
|
175
|
-
.then(() => {
|
|
176
|
-
status.toast("Copied");
|
|
177
|
-
})
|
|
178
|
-
.catch(() => {
|
|
179
|
-
status.toast("Copy failed");
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function pasteFromClipboard(): void {
|
|
184
|
-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- navigator.clipboard is undefined outside secure contexts (plain-HTTP host)
|
|
185
|
-
if (!navigator.clipboard) {
|
|
186
|
-
status.toast("Clipboard unavailable");
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
navigator.clipboard
|
|
190
|
-
.readText()
|
|
191
|
-
.then((text) => {
|
|
192
|
-
send(bracketTextForPaste(prepareTextForTerminal(text)));
|
|
193
|
-
})
|
|
194
|
-
.catch(() => {
|
|
195
|
-
status.toast("Paste blocked");
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Keydown handler — attached to the textarea (the single keyboard target,
|
|
200
|
-
// desktop and touch alike); .term-output is display-only and never focused.
|
|
201
|
-
function handleKeydown(ev: KeyboardEvent): void {
|
|
202
|
-
// While composing (IME), let the browser pump composition events;
|
|
203
|
-
// keydown bytes during composition would duplicate the composed text.
|
|
204
|
-
if (composition.isComposing()) {
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// When the context menu is open, Escape dismisses it WITHOUT also sending an
|
|
209
|
-
// ESC byte to the PTY -- matching the no-side-effect outside-click dismiss
|
|
210
|
-
// (the document-level handler frames this as "parity"). This textarea keydown
|
|
211
|
-
// fires before that document-level handler bubbles, so without this guard
|
|
212
|
-
// mapKeyboardEvent would send ESC to the shell first (e.g. dropping vim out of
|
|
213
|
-
// insert mode) while the menu also closes. The document-level handler still
|
|
214
|
-
// covers the case where focus is on a menu button rather than the textarea.
|
|
215
|
-
if (ev.key === "Escape" && ctxMenu.classList.contains("visible")) {
|
|
216
|
-
ev.preventDefault();
|
|
217
|
-
hideCtxMenu();
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Ctrl+Shift+C / Ctrl+Shift+V — desktop clipboard shortcuts. Handled
|
|
222
|
-
// before the generic mapper because they take browser-side selection
|
|
223
|
-
// and clipboard, not server-bound key sequences.
|
|
224
|
-
if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
|
225
|
-
if (ev.code === "KeyC") {
|
|
226
|
-
const sel = window.getSelection()?.toString();
|
|
227
|
-
if (sel) {
|
|
228
|
-
copyToClipboard(sel);
|
|
229
|
-
}
|
|
230
|
-
ev.preventDefault();
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
if (ev.code === "KeyV") {
|
|
234
|
-
pasteFromClipboard();
|
|
235
|
-
ev.preventDefault();
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const result = mapKeyboardEvent(ev);
|
|
241
|
-
switch (result.kind) {
|
|
242
|
-
case "send":
|
|
243
|
-
ev.preventDefault();
|
|
244
|
-
send(result.bytes);
|
|
245
|
-
return;
|
|
246
|
-
case "scroll-up": {
|
|
247
|
-
ev.preventDefault();
|
|
248
|
-
const h = termWrap.clientHeight;
|
|
249
|
-
termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
case "scroll-down": {
|
|
253
|
-
ev.preventDefault();
|
|
254
|
-
const h = termWrap.clientHeight;
|
|
255
|
-
termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
case "ignore":
|
|
259
|
-
// Defer to the browser; the `input` listener will pick up any
|
|
260
|
-
// printable character produced by the keystroke.
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Focus the textarea (on page load and after visibility changes).
|
|
266
|
-
function focusTerminal(): void {
|
|
267
|
-
input.focus({ preventScroll: true });
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Send the first resize only when BOTH fonts are loaded AND the WS is
|
|
271
|
-
// open. Either can happen first depending on network/cache conditions.
|
|
272
|
-
function maybeSendFirstResize(): void {
|
|
273
|
-
if (!fontsLoaded || !wsOpen) {
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
render.updateFontMetrics();
|
|
277
|
-
connection.sendResize(); // sends only if size changed
|
|
278
|
-
const sz = render.computeSize();
|
|
279
|
-
predict.setDimensions(sz.cols, sz.rows);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function hideCtxMenu(): void {
|
|
283
|
-
// If focus is inside the menu (a keyboard user activated an item or pressed
|
|
284
|
-
// Escape on a focused button), return focus to the terminal input -- otherwise
|
|
285
|
-
// removing the focused button drops focus to <body> and the keyboard user can no
|
|
286
|
-
// longer type. On a pointer/outside-click dismiss focus is not in the menu, so
|
|
287
|
-
// this is a no-op and does not steal focus.
|
|
288
|
-
const refocus = ctxMenu.contains(document.activeElement);
|
|
289
|
-
ctxMenu.classList.remove("visible");
|
|
290
|
-
ctxMenu.replaceChildren();
|
|
291
|
-
if (refocus) {
|
|
292
|
-
focusTerminal();
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function showCtxMenu(x: number, y: number): void {
|
|
297
|
-
hideCtxMenu();
|
|
298
|
-
|
|
299
|
-
const sel = window.getSelection()?.toString();
|
|
300
|
-
if (sel) {
|
|
301
|
-
const copyBtn = document.createElement("button");
|
|
302
|
-
copyBtn.textContent = "Copy";
|
|
303
|
-
copyBtn.addEventListener("click", () => {
|
|
304
|
-
copyToClipboard(sel);
|
|
305
|
-
hideCtxMenu();
|
|
306
|
-
});
|
|
307
|
-
ctxMenu.appendChild(copyBtn);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const selectAllBtn = document.createElement("button");
|
|
311
|
-
selectAllBtn.textContent = "Select All";
|
|
312
|
-
selectAllBtn.addEventListener("click", () => {
|
|
313
|
-
const s = window.getSelection();
|
|
314
|
-
if (s) {
|
|
315
|
-
s.selectAllChildren(outputEl);
|
|
316
|
-
}
|
|
317
|
-
hideCtxMenu();
|
|
318
|
-
});
|
|
319
|
-
ctxMenu.appendChild(selectAllBtn);
|
|
320
|
-
|
|
321
|
-
if (!isIOS || lastPointerType !== "touch") {
|
|
322
|
-
const pasteBtn = document.createElement("button");
|
|
323
|
-
pasteBtn.textContent = "Paste";
|
|
324
|
-
pasteBtn.addEventListener("click", () => {
|
|
325
|
-
pasteFromClipboard();
|
|
326
|
-
hideCtxMenu();
|
|
327
|
-
});
|
|
328
|
-
ctxMenu.appendChild(pasteBtn);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Don't show an empty menu (iOS without selection has nothing to offer).
|
|
332
|
-
if (ctxMenu.childElementCount === 0) {
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Make it visible (so it has measurable dimensions) then clamp it
|
|
337
|
-
// inside the viewport, so it never opens off-screen near the right or
|
|
338
|
-
// bottom edge. position:fixed means x/y are already viewport
|
|
339
|
-
// coordinates. Setting left/top after adding the class is flash-free:
|
|
340
|
-
// it is all one synchronous task, so the browser paints only the
|
|
341
|
-
// clamped position.
|
|
342
|
-
ctxMenu.classList.add("visible");
|
|
343
|
-
const margin = 8;
|
|
344
|
-
const left = Math.max(margin, Math.min(x, window.innerWidth - ctxMenu.offsetWidth - margin));
|
|
345
|
-
const top = Math.max(margin, Math.min(y, window.innerHeight - ctxMenu.offsetHeight - margin));
|
|
346
|
-
ctxMenu.style.left = `${left}px`;
|
|
347
|
-
ctxMenu.style.top = `${top}px`;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
351
|
-
const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
|
|
352
|
-
const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
|
|
353
|
-
|
|
354
|
-
// --- Build the terminal subtree inside the host-provided root ---
|
|
355
|
-
const tpl = document.createElement("template");
|
|
356
|
-
tpl.innerHTML = TERMINAL_TEMPLATE;
|
|
357
|
-
root.replaceChildren(tpl.content);
|
|
358
|
-
|
|
359
|
-
// --- DOM refs (picked from the just-built subtree by class) ---
|
|
360
|
-
termWrap = pick(root, ".term");
|
|
361
|
-
outputEl = pick(root, ".term-output");
|
|
362
|
-
input = pick(root, ".term-input") as HTMLTextAreaElement;
|
|
363
|
-
const compositionViewEl = pick(root, ".composition-view");
|
|
364
|
-
ctxMenu = pick(root, ".ctx-menu");
|
|
365
|
-
const banner = pick(root, ".conn-banner");
|
|
366
|
-
const toolbar = pick(root, ".key-toolbar");
|
|
367
|
-
|
|
368
|
-
isIOS =
|
|
369
|
-
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
370
|
-
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|
371
|
-
|
|
372
|
-
// markReady fires once: when the first screen frame has rendered AND the web
|
|
373
|
-
// font is loaded. It tells the status banner the initial load is over (so it
|
|
374
|
-
// may show reconnect state from here on) and fades out the consumer's
|
|
375
|
-
// loading overlay if one was passed.
|
|
376
|
-
let ready = false;
|
|
377
|
-
let firstFrameRendered = false;
|
|
378
|
-
let overlayDismissed = false;
|
|
379
|
-
// Fade out and remove the consumer-owned loading overlay (if one was passed).
|
|
380
|
-
// Extracted from markReady() so status.ts can also drive it via its give-up
|
|
381
|
-
// signal: on a socket that never connects no first screen frame ever arrives,
|
|
382
|
-
// so markReady never runs and nothing else would remove the opaque
|
|
383
|
-
// z-index:200 overlay -- it would occlude the "Offline" banner forever.
|
|
384
|
-
// status fires its onGiveUp on EVERY post-limit unloaded close against an
|
|
385
|
-
// unbounded reconnect loop, so the overlayDismissed one-shot guard makes the
|
|
386
|
-
// fade/remove and its transitionend listener + fallback timer register exactly
|
|
387
|
-
// once; later calls early-return rather than piling listeners and timers onto
|
|
388
|
-
// the (soon-detached) overlay node.
|
|
389
|
-
function dismissLoadingOverlay(): void {
|
|
390
|
-
const ld = opts.loading;
|
|
391
|
-
if (!ld || overlayDismissed) {
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
overlayDismissed = true;
|
|
395
|
-
ld.classList.add("fade");
|
|
396
|
-
const removeOverlay = (): void => {
|
|
397
|
-
ld.remove();
|
|
398
|
-
};
|
|
399
|
-
// transitionend fires on the consumer-owned fade, but their overlay CSS may have
|
|
400
|
-
// no opacity transition (or it can be interrupted): then transitionend never fires
|
|
401
|
-
// and the now opacity:0 overlay -- still inset:0 / z-index:200 with no
|
|
402
|
-
// pointer-events:none in the reference scaffold -- keeps swallowing every tap and
|
|
403
|
-
// click over the terminal. Remove on a bounded fallback too.
|
|
404
|
-
ld.addEventListener("transitionend", removeOverlay, { once: true });
|
|
405
|
-
window.setTimeout(removeOverlay, 1500);
|
|
406
|
-
}
|
|
407
|
-
function markReady(): void {
|
|
408
|
-
if (ready) {
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
ready = true;
|
|
412
|
-
status.setLoaded();
|
|
413
|
-
dismissLoadingOverlay();
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// --- Initialize layers ---
|
|
417
|
-
status.init({ banner, onGiveUp: dismissLoadingOverlay });
|
|
418
|
-
|
|
419
|
-
render.init({
|
|
420
|
-
output: outputEl,
|
|
421
|
-
termWrap,
|
|
422
|
-
// Keep the helper textarea + IME composition view glued to the
|
|
423
|
-
// cursor on every render so iOS keyboard focus and IME candidate
|
|
424
|
-
// popups target the right area. Also re-render the predicted-cursor
|
|
425
|
-
// overlay so its position stays consistent with the just-drawn
|
|
426
|
-
// server cursor.
|
|
427
|
-
onCursorMove: () => {
|
|
428
|
-
composition.positionCompositionView();
|
|
429
|
-
pushPredictedCursor();
|
|
430
|
-
},
|
|
431
|
-
});
|
|
432
|
-
render.updateFontMetrics();
|
|
433
|
-
|
|
434
|
-
// predict redraws on every change to its predicted-cursor state.
|
|
435
|
-
predict.subscribe(pushPredictedCursor);
|
|
436
|
-
|
|
437
|
-
composition.init({
|
|
438
|
-
textarea: input,
|
|
439
|
-
compositionView: compositionViewEl,
|
|
440
|
-
getCursorPx: render.getCursorPx,
|
|
441
|
-
send,
|
|
442
|
-
});
|
|
443
|
-
|
|
444
|
-
scroll.init({
|
|
445
|
-
scrollEl: termWrap,
|
|
446
|
-
onUserScrollChange(scrolledUp) {
|
|
447
|
-
// Toggle .scrolled-up on the toolbar so CSS can show/hide the
|
|
448
|
-
// scroll-bottom button and grow the pill vertically.
|
|
449
|
-
toolbar.classList.toggle("scrolled-up", scrolledUp);
|
|
450
|
-
},
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
connection.init({
|
|
454
|
-
computeSize: render.computeSize,
|
|
455
|
-
getHaveThrough: render.getHighestIndex,
|
|
456
|
-
onResumeBounds: render.noteResumeBounds,
|
|
457
|
-
wsPath,
|
|
458
|
-
onMessage(msg) {
|
|
459
|
-
if (msg.type === "screen") {
|
|
460
|
-
render.handleScreen(msg);
|
|
461
|
-
firstFrameRendered = true;
|
|
462
|
-
if (fontsLoaded) {
|
|
463
|
-
markReady();
|
|
464
|
-
}
|
|
465
|
-
predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
|
|
466
|
-
} else if (msg.type === "scroll") {
|
|
467
|
-
render.handleScroll(msg);
|
|
468
|
-
} else if (msg.type === "title") {
|
|
469
|
-
document.title = msg.title;
|
|
470
|
-
} else if (msg.type === "modes") {
|
|
471
|
-
render.updateReverseVideo();
|
|
472
|
-
} else if (msg.type === "clipboard") {
|
|
473
|
-
// An app copied via OSC 52; mirror it to the system clipboard. This is
|
|
474
|
-
// an async push (not a user gesture), so Safari/Firefox may reject
|
|
475
|
-
// writeText — copyToClipboard surfaces that as a "Copy failed" toast.
|
|
476
|
-
copyToClipboard(msg.text);
|
|
477
|
-
}
|
|
478
|
-
},
|
|
479
|
-
onOpen() {
|
|
480
|
-
status.open();
|
|
481
|
-
// Do NOT call render.resetScreen() here. resetScreen flips the
|
|
482
|
-
// firstScreen flag, which causes the next screen frame to wipe
|
|
483
|
-
// the entire .term-output DOM (including all scrollback above
|
|
484
|
-
// the live viewport). On reconnect (e.g. iPad screen dim/wake)
|
|
485
|
-
// that destroys history the user could otherwise scroll up to.
|
|
486
|
-
// The server forces a full repaint via builder.Reset() on resume,
|
|
487
|
-
// which the client renders as row replacements within the live
|
|
488
|
-
// zone — scrollback DOM stays intact. The very first onOpen of
|
|
489
|
-
// page load still wipes correctly because firstScreen defaults
|
|
490
|
-
// to true at module load (only resetScrollback + resetScreen on
|
|
491
|
-
// onServerRestart explicitly trigger a full reset).
|
|
492
|
-
wsOpen = true;
|
|
493
|
-
maybeSendFirstResize();
|
|
494
|
-
},
|
|
495
|
-
onConnecting() {
|
|
496
|
-
status.reconnecting();
|
|
497
|
-
},
|
|
498
|
-
onClose() {
|
|
499
|
-
status.closed();
|
|
500
|
-
},
|
|
501
|
-
onOutboxFull() {
|
|
502
|
-
// The user kept typing through a long disconnect and we've
|
|
503
|
-
// capped the buffer. Surface visibly so they don't keep typing
|
|
504
|
-
// into the void.
|
|
505
|
-
status.closed();
|
|
506
|
-
},
|
|
507
|
-
onServerRestart() {
|
|
508
|
-
// Wipe the now-stale scrollback DOM and the live viewport so the
|
|
509
|
-
// user doesn't see ghost input from the previous server boot;
|
|
510
|
-
// the next screen frame from the fresh server populates the
|
|
511
|
-
// viewport. Banner explains why.
|
|
512
|
-
render.resetScrollback();
|
|
513
|
-
render.resetScreen();
|
|
514
|
-
predict.reset();
|
|
515
|
-
status.restarted();
|
|
516
|
-
},
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
// --- Input handling ---
|
|
520
|
-
resetToPlaceholder(input);
|
|
521
|
-
|
|
522
|
-
input.addEventListener("input", (e: Event) => {
|
|
523
|
-
// While IME composition is in progress, the textarea fires `input`
|
|
524
|
-
// events for each composing keystroke. composition.ts owns sending
|
|
525
|
-
// the final composed text in compositionend; we must NOT send the
|
|
526
|
-
// intermediate input value (it would duplicate the composition).
|
|
527
|
-
if (composition.isComposing()) {
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const ev = e as InputEvent;
|
|
532
|
-
const inputType = ev.inputType;
|
|
533
|
-
|
|
534
|
-
if (
|
|
535
|
-
inputType === "deleteContentBackward" ||
|
|
536
|
-
inputType === "deleteContentForward" ||
|
|
537
|
-
inputType === "deleteWordBackward" ||
|
|
538
|
-
inputType === "deleteWordForward"
|
|
539
|
-
) {
|
|
540
|
-
// Deletion is handled by keydown — just re-pad the placeholder so iOS
|
|
541
|
-
// key-repeat keeps firing (it needs content to delete).
|
|
542
|
-
resetToPlaceholder(input);
|
|
543
|
-
return;
|
|
544
|
-
} else if (typeof ev.data === "string" && ev.data.length > 0) {
|
|
545
|
-
// insertText / insertFromPaste / insertReplacementText etc. The
|
|
546
|
-
// `data` property carries exactly the new content; using it
|
|
547
|
-
// sidesteps having to diff against the placeholder.
|
|
548
|
-
//
|
|
549
|
-
// iOS Safari can deliver U+00A0 (NBSP) instead of U+0020 for the
|
|
550
|
-
// spacebar/autocorrect. Normalize so the shell receives an honest
|
|
551
|
-
// space byte. (Native paste is bracketed in composition.ts's paste
|
|
552
|
-
// handler before this fires; the insertFromPaste data here is a
|
|
553
|
-
// fallback for browsers that don't raise a separate paste event.)
|
|
554
|
-
if (inputType === "insertFromPaste") {
|
|
555
|
-
// Paste arriving through `input` without a preceding `paste` event
|
|
556
|
-
// (composition.ts's onPaste handles + preventDefaults the normal
|
|
557
|
-
// case). Bracket + sanitize it exactly like the Ctrl+Shift+V,
|
|
558
|
-
// context-menu Paste, and composition.onPaste paths so embedded
|
|
559
|
-
// newlines / control bytes arrive as an inert bracketed paste
|
|
560
|
-
// instead of executing as typed shell commands (paste-jacking).
|
|
561
|
-
send(bracketTextForPaste(prepareTextForTerminal(ev.data)));
|
|
562
|
-
} else {
|
|
563
|
-
sendTyped(ev.data);
|
|
564
|
-
}
|
|
565
|
-
} else {
|
|
566
|
-
// Fallback: anything in the textarea past the placeholder is new
|
|
567
|
-
// content. Covers browsers that don't populate inputType / data
|
|
568
|
-
// (older WebKit).
|
|
569
|
-
const v = input.value;
|
|
570
|
-
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
571
|
-
sendTyped(v.slice(INPUT_PLACEHOLDER.length));
|
|
572
|
-
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
573
|
-
sendTyped(v);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
resetToPlaceholder(input);
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
// Focus state on the terminal element, for CSS targeting (e.g. dimming
|
|
580
|
-
// the cursor when focus is elsewhere). Pattern from xterm.js.
|
|
581
|
-
input.addEventListener("focus", () => {
|
|
582
|
-
termWrap.classList.add("focus");
|
|
583
|
-
});
|
|
584
|
-
input.addEventListener("blur", () => {
|
|
585
|
-
// Restore the placeholder so the held-Backspace iOS path stays
|
|
586
|
-
// primed for the next focus. Also clears any leftover screen-reader
|
|
587
|
-
// text (xterm.js convention).
|
|
588
|
-
resetToPlaceholder(input);
|
|
589
|
-
termWrap.classList.remove("focus");
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
// The textarea is the single keyboard target (desktop and touch alike);
|
|
593
|
-
// .term-output is display-only and never focused.
|
|
594
|
-
input.addEventListener("keydown", handleKeydown);
|
|
595
|
-
|
|
596
|
-
// --- Focus strategy ---
|
|
597
|
-
// One element, one job. .term-output is display + native selection only
|
|
598
|
-
// and is NEVER focused; the textarea owns the keyboard, the local typing
|
|
599
|
-
// buffer, and IME. Because the editable element is never the scroll
|
|
600
|
-
// content, the first touch-drag scrolls instead of placing a caret and a
|
|
601
|
-
// tap on a sparse screen still lands on the full-viewport scroll surface;
|
|
602
|
-
// because the display is not re-rendered as editable, a selection
|
|
603
|
-
// survives a redraw. Typed text, paste, and IME all arrive through the
|
|
604
|
-
// textarea's own input / paste / composition listeners (the latter two
|
|
605
|
-
// live in composition.ts).
|
|
606
|
-
|
|
607
|
-
// Touch tap: focus the hidden textarea to trigger iOS keyboard.
|
|
608
|
-
// On iOS Safari, focusing a hidden textarea from a `click` handler
|
|
609
|
-
// only opens the virtual keyboard if the click event arrives within
|
|
610
|
-
// iOS's user-gesture window. During heavy DOM mutations (streaming),
|
|
611
|
-
// the click event timing slips past that window — symptom: "while the
|
|
612
|
-
// agent is thinking I can't open the keyboard; only when the turn ends
|
|
613
|
-
// does tap work." `pointerup` fires synchronously with the user's
|
|
614
|
-
// finger lift and captures the gesture before iOS layout work can defer
|
|
615
|
-
// it, so focus() lands while the gesture is still "live". We track the
|
|
616
|
-
// start position from pointerdown and only treat the pointerup as a tap
|
|
617
|
-
// (vs scroll) when total movement is sub-threshold.
|
|
618
|
-
termWrap.addEventListener(
|
|
619
|
-
"pointerdown",
|
|
620
|
-
(e) => {
|
|
621
|
-
lastPointerType = e.pointerType;
|
|
622
|
-
pointerDownX = e.clientX;
|
|
623
|
-
pointerDownY = e.clientY;
|
|
624
|
-
},
|
|
625
|
-
{ passive: true },
|
|
626
|
-
);
|
|
627
|
-
termWrap.addEventListener(
|
|
628
|
-
"pointerup",
|
|
629
|
-
(e) => {
|
|
630
|
-
if (e.pointerType !== "touch") {
|
|
631
|
-
return; // mouse/trackpad handled by the click listener below
|
|
632
|
-
}
|
|
633
|
-
// A tap on a terminal link is opened by the click listener below; don't
|
|
634
|
-
// also focus the textarea (which pops the iOS soft keyboard) on the way.
|
|
635
|
-
if ((e.target as HTMLElement).closest(".term-link")) {
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
const dx = Math.abs(e.clientX - pointerDownX);
|
|
639
|
-
const dy = Math.abs(e.clientY - pointerDownY);
|
|
640
|
-
if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
|
|
641
|
-
return; // user was scrolling, not tapping
|
|
642
|
-
}
|
|
643
|
-
const sel = window.getSelection();
|
|
644
|
-
if (sel && sel.toString().length > 0) {
|
|
645
|
-
return; // preserve selection (long-tap selected text)
|
|
646
|
-
}
|
|
647
|
-
// Synchronous focus inside pointerup keeps us inside iOS's
|
|
648
|
-
// user-gesture window even when the streaming flush queue is busy.
|
|
649
|
-
focusTerminal();
|
|
650
|
-
},
|
|
651
|
-
{ passive: true },
|
|
652
|
-
);
|
|
653
|
-
// After a touch tap, iOS/WebKit dispatches a synthetic `mousedown` (following
|
|
654
|
-
// the `pointerup` above). Its default action moves focus to the nearest
|
|
655
|
-
// focusable ancestor of the tap point — but the input-model contract keeps
|
|
656
|
-
// `.term-output` and `.term` deliberately non-focusable (display-only) and the
|
|
657
|
-
// hidden textarea is `pointer-events: none`, so the tap never lands on a
|
|
658
|
-
// focusable node. With nothing focusable under the tap, that default blurs the
|
|
659
|
-
// textarea we just focused in `pointerup`, so iOS immediately dismisses the
|
|
660
|
-
// soft keyboard the tap had only just opened — the "keyboard flashes open then
|
|
661
|
-
// instantly closes" symptom on touch. Cancelling the synthetic mousedown's
|
|
662
|
-
// default keeps focus on the textarea, so the keyboard stays up. This is the
|
|
663
|
-
// xterm.js focus-preservation pattern, scoped to touch: a real mouse mousedown
|
|
664
|
-
// must keep its default so desktop drag-to-select text still works, and iOS
|
|
665
|
-
// text selection is long-press-driven (not this tap mousedown), so it is
|
|
666
|
-
// unaffected. Not passive — preventDefault must take effect.
|
|
667
|
-
termWrap.addEventListener("mousedown", (e) => {
|
|
668
|
-
if (lastPointerType === "touch") {
|
|
669
|
-
e.preventDefault();
|
|
670
|
-
}
|
|
671
|
-
});
|
|
672
|
-
termWrap.addEventListener("click", (e) => {
|
|
673
|
-
// Terminal links: URLs rendered by the engine are wrapped in
|
|
674
|
-
// <a class="term-link" target="_blank" rel="noopener noreferrer">. The
|
|
675
|
-
// output is display-only (never contenteditable -- see the input-model
|
|
676
|
-
// contract), so intercept the click to open with explicit
|
|
677
|
-
// noopener/noreferrer and to keep the focus handler below from running
|
|
678
|
-
// on a link tap.
|
|
679
|
-
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
|
|
680
|
-
if (link) {
|
|
681
|
-
e.preventDefault();
|
|
682
|
-
window.open(link.href, "_blank", "noopener,noreferrer");
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
if (lastPointerType === "touch") {
|
|
687
|
-
// Touch path is handled by pointerup above. The click listener
|
|
688
|
-
// is kept so non-pointer-event environments (very old iOS
|
|
689
|
-
// builds without PointerEvent support) still get a focus hook.
|
|
690
|
-
return;
|
|
691
|
-
}
|
|
692
|
-
// Mouse/trackpad: a plain click focuses the textarea so the user can
|
|
693
|
-
// type. Never steal focus mid-selection though — a drag-select ends
|
|
694
|
-
// with a click, and grabbing focus would collapse the selection the
|
|
695
|
-
// user just made and still wants to copy.
|
|
696
|
-
const sel = window.getSelection();
|
|
697
|
-
if (sel && sel.toString().length > 0) {
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
focusTerminal();
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
// --- Viewport ---
|
|
704
|
-
// Centralized handling of iOS keyboard, window resize, and font-load
|
|
705
|
-
// reflows. Whenever the viewport settles, font metrics are remeasured
|
|
706
|
-
// and a resize is sent to the server. Snap-back-to-bottom (if the user
|
|
707
|
-
// was at the bottom before the transition) is handled inside viewport.ts.
|
|
708
|
-
viewport.init({
|
|
709
|
-
termWrap,
|
|
710
|
-
onSettled() {
|
|
711
|
-
render.updateFontMetrics();
|
|
712
|
-
// Only send resize if fonts are loaded — otherwise we'd send the
|
|
713
|
-
// wrong size (fallback font metrics) which causes the snap.
|
|
714
|
-
if (fontsLoaded) {
|
|
715
|
-
connection.sendResize();
|
|
716
|
-
}
|
|
717
|
-
composition.positionCompositionView();
|
|
718
|
-
const sz = render.computeSize();
|
|
719
|
-
predict.setDimensions(sz.cols, sz.rows);
|
|
720
|
-
},
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
// Only wait for the Regular weight — it determines cell size.
|
|
724
|
-
// Bold/Italic load lazily when first used; style pop is barely noticeable.
|
|
725
|
-
// A missing/slow font, or a malformed `fontReady` (FontFaceSet.load can throw
|
|
726
|
-
// synchronously on a bad shorthand), must NOT leave the terminal permanently
|
|
727
|
-
// unsized: degrade to fallback-font metrics so the first resize still fires
|
|
728
|
-
// and the loading overlay still dismisses.
|
|
729
|
-
const onFontSettled = (): void => {
|
|
730
|
-
fontsLoaded = true;
|
|
731
|
-
if (firstFrameRendered) {
|
|
732
|
-
markReady();
|
|
733
|
-
}
|
|
734
|
-
requestAnimationFrame(() => {
|
|
735
|
-
maybeSendFirstResize();
|
|
736
|
-
});
|
|
737
|
-
};
|
|
738
|
-
try {
|
|
739
|
-
void document.fonts
|
|
740
|
-
.load(fontReady)
|
|
741
|
-
.then(onFontSettled)
|
|
742
|
-
.catch((err: unknown) => {
|
|
743
|
-
console.warn(
|
|
744
|
-
`web-terminal-ui: web font ${fontReady} failed to load; using fallback metrics`,
|
|
745
|
-
err,
|
|
746
|
-
);
|
|
747
|
-
onFontSettled();
|
|
748
|
-
});
|
|
749
|
-
} catch (err) {
|
|
750
|
-
console.warn(`web-terminal-ui: invalid fontReady ${fontReady}; using fallback metrics`, err);
|
|
751
|
-
onFontSettled();
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// Connect immediately — the WS open triggers maybeSendFirstResize.
|
|
755
|
-
render.updateFontMetrics();
|
|
756
|
-
composition.positionCompositionView();
|
|
757
|
-
connection.connect();
|
|
758
|
-
focusTerminal();
|
|
759
|
-
|
|
760
|
-
document.addEventListener("visibilitychange", () => {
|
|
761
|
-
if (document.visibilityState === "visible") {
|
|
762
|
-
connection.reconnectNow();
|
|
763
|
-
focusTerminal();
|
|
764
|
-
}
|
|
765
|
-
});
|
|
766
|
-
window.addEventListener("pageshow", () => {
|
|
767
|
-
connection.reconnectNow();
|
|
768
|
-
focusTerminal();
|
|
769
|
-
});
|
|
770
|
-
// Network came back (cellular↔wifi handoff, tunnel reconnect): re-establish
|
|
771
|
-
// at once rather than waiting out the reconnect backoff. reconnectNow tears
|
|
772
|
-
// down any zombie socket first, and resume-by-index backfills what was missed.
|
|
773
|
-
window.addEventListener("online", () => {
|
|
774
|
-
connection.reconnectNow();
|
|
775
|
-
});
|
|
776
|
-
|
|
777
|
-
// --- Scroll-to-bottom (inside toolbar grid) ---
|
|
778
|
-
const scrollBtn = pick(root, ".kb-scroll-bottom");
|
|
779
|
-
// pointerdown (like the other toolbar keys) so touch devices enter
|
|
780
|
-
// :active and show press feedback; preventDefault keeps focus on the
|
|
781
|
-
// terminal. click is kept for keyboard activation of the desktop FAB.
|
|
782
|
-
// scrollToBottom() is idempotent, so the pointerdown+click pair on a
|
|
783
|
-
// mouse press is harmless.
|
|
784
|
-
scrollBtn.addEventListener("pointerdown", (e) => {
|
|
785
|
-
e.preventDefault();
|
|
786
|
-
scroll.scrollToBottom();
|
|
787
|
-
});
|
|
788
|
-
scrollBtn.addEventListener("click", () => {
|
|
789
|
-
scroll.scrollToBottom();
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
// --- Context menu ---
|
|
793
|
-
termWrap.addEventListener("contextmenu", (e: MouseEvent) => {
|
|
794
|
-
e.preventDefault();
|
|
795
|
-
showCtxMenu(e.clientX, e.clientY);
|
|
796
|
-
});
|
|
797
|
-
|
|
798
|
-
// Long-press to trigger the same menu on touch devices that don't
|
|
799
|
-
// already provide a native callout. iOS Safari shows its own
|
|
800
|
-
// long-press selection callout (Select / Copy / Paste) which fully
|
|
801
|
-
// covers the use case AND would race our custom menu (two stacked
|
|
802
|
-
// menus appear if the user long-presses an existing selection), so
|
|
803
|
-
// we skip our timer on iOS and let the native callout do its job.
|
|
804
|
-
// Android Chrome fires `contextmenu` on long-press release, but the
|
|
805
|
-
// timer also helps on browsers that don't, e.g. some Windows touch
|
|
806
|
-
// devices.
|
|
807
|
-
if (!isIOS) {
|
|
808
|
-
termWrap.addEventListener(
|
|
809
|
-
"touchstart",
|
|
810
|
-
(e: TouchEvent) => {
|
|
811
|
-
if (e.touches.length !== 1) {
|
|
812
|
-
if (longPressTimer) {
|
|
813
|
-
clearTimeout(longPressTimer);
|
|
814
|
-
longPressTimer = 0;
|
|
815
|
-
}
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- touches.length checked above
|
|
819
|
-
const t = e.touches[0]!;
|
|
820
|
-
longPressOrigin = { x: t.clientX, y: t.clientY };
|
|
821
|
-
longPressTimer = window.setTimeout(() => {
|
|
822
|
-
longPressTimer = 0;
|
|
823
|
-
showCtxMenu(longPressOrigin.x, longPressOrigin.y);
|
|
824
|
-
}, LONG_PRESS_MS);
|
|
825
|
-
},
|
|
826
|
-
{ passive: true },
|
|
827
|
-
);
|
|
828
|
-
|
|
829
|
-
termWrap.addEventListener(
|
|
830
|
-
"touchmove",
|
|
831
|
-
(e: TouchEvent) => {
|
|
832
|
-
if (!longPressTimer || e.touches.length !== 1) {
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- touches.length checked above
|
|
836
|
-
const t = e.touches[0]!;
|
|
837
|
-
const dx = t.clientX - longPressOrigin.x;
|
|
838
|
-
const dy = t.clientY - longPressOrigin.y;
|
|
839
|
-
if (dx * dx + dy * dy > LONG_PRESS_MOVE_THRESHOLD_PX * LONG_PRESS_MOVE_THRESHOLD_PX) {
|
|
840
|
-
clearTimeout(longPressTimer);
|
|
841
|
-
longPressTimer = 0;
|
|
842
|
-
}
|
|
843
|
-
},
|
|
844
|
-
{ passive: true },
|
|
845
|
-
);
|
|
846
|
-
|
|
847
|
-
termWrap.addEventListener(
|
|
848
|
-
"touchend",
|
|
849
|
-
() => {
|
|
850
|
-
if (longPressTimer) {
|
|
851
|
-
clearTimeout(longPressTimer);
|
|
852
|
-
longPressTimer = 0;
|
|
853
|
-
}
|
|
854
|
-
},
|
|
855
|
-
{ passive: true },
|
|
856
|
-
);
|
|
857
|
-
|
|
858
|
-
termWrap.addEventListener(
|
|
859
|
-
"touchcancel",
|
|
860
|
-
() => {
|
|
861
|
-
if (longPressTimer) {
|
|
862
|
-
clearTimeout(longPressTimer);
|
|
863
|
-
longPressTimer = 0;
|
|
864
|
-
}
|
|
865
|
-
},
|
|
866
|
-
{ passive: true },
|
|
867
|
-
);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
document.addEventListener("click", () => {
|
|
871
|
-
hideCtxMenu();
|
|
872
|
-
});
|
|
873
|
-
|
|
874
|
-
// Escape dismisses the context menu (keyboard parity with the outside-click
|
|
875
|
-
// dismiss; the menu can be opened via the keyboard Menu key, which fires
|
|
876
|
-
// `contextmenu`). Without this an AT/keyboard user has no keyboard way to close it.
|
|
877
|
-
document.addEventListener("keydown", (e) => {
|
|
878
|
-
if (e.key === "Escape" && ctxMenu.classList.contains("visible")) {
|
|
879
|
-
hideCtxMenu();
|
|
880
|
-
}
|
|
881
|
-
});
|
|
882
|
-
|
|
883
|
-
// --- Mobile key toolbar ---
|
|
884
|
-
// The engine's keyboard.bindMobileToolbar wires the collapse toggle, the
|
|
885
|
-
// arrows (DECCKM-aware — SS3 under application-cursor mode, matching
|
|
886
|
-
// mapKeyboardEvent), Tab / Enter / Esc, and the sticky-Ctrl button on the
|
|
887
|
-
// built toolbar. It returns the controller whose applyStickyCtrl the input
|
|
888
|
-
// handler above applies to typed text. The no-transition priming below
|
|
889
|
-
// stays local (a UI-specific concern).
|
|
890
|
-
toolbarCtrl = keyboard.bindMobileToolbar({ toolbar, send });
|
|
891
|
-
requestAnimationFrame(() =>
|
|
892
|
-
requestAnimationFrame(() => {
|
|
893
|
-
toolbar.classList.remove("no-transition");
|
|
894
|
-
}),
|
|
895
|
-
);
|
|
896
|
-
|
|
897
|
-
// --- Copy feedback toast ---
|
|
898
|
-
document.addEventListener("copy", () => {
|
|
899
|
-
status.toast("Copied");
|
|
900
|
-
});
|
|
901
|
-
|
|
902
|
-
return { focus: focusTerminal };
|
|
903
|
-
}
|