@cplieger/web-terminal-ui 1.0.1 → 2.1.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/README.md +66 -28
- package/css/00-tokens.css +1 -0
- package/css/02-app.css +34 -10
- package/package.json +5 -5
- package/scaffold/index.html +12 -25
- package/src/composition.ts +4 -26
- package/src/index.ts +3 -2
- package/src/input-placeholder.ts +29 -0
- package/src/mount.ts +303 -222
- package/src/predict.ts +13 -15
- package/src/status.ts +78 -23
package/src/mount.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// @cplieger/web-terminal-ui — reference touch-first browser terminal UI.
|
|
2
2
|
//
|
|
3
|
-
// mount()
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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.
|
|
11
12
|
//
|
|
12
13
|
// Single-instance per page: the module holds the one terminal's DOM refs and
|
|
13
14
|
// state, matching a terminal-per-page model. Call mount() exactly once.
|
|
@@ -17,6 +18,7 @@ import * as viewport from "./viewport.js";
|
|
|
17
18
|
import * as composition from "./composition.js";
|
|
18
19
|
import * as status from "./status.js";
|
|
19
20
|
import * as predict from "./predict.js";
|
|
21
|
+
import { INPUT_PLACEHOLDER, resetToPlaceholder } from "./input-placeholder.js";
|
|
20
22
|
|
|
21
23
|
const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
22
24
|
|
|
@@ -28,6 +30,11 @@ export interface MountOptions {
|
|
|
28
30
|
* server is sized against the real web font's cell metrics rather than a
|
|
29
31
|
* fallback. Default '14px "MonaspiceNe NFM"'. */
|
|
30
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;
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export interface TerminalUI {
|
|
@@ -38,33 +45,24 @@ export interface TerminalUI {
|
|
|
38
45
|
const DEFAULT_WS_PATH = "/ws";
|
|
39
46
|
const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
|
|
40
47
|
|
|
41
|
-
// Single-character placeholder kept in the hidden textarea so iOS soft
|
|
42
|
-
// keyboards have something to "delete" when the user holds Backspace.
|
|
43
|
-
// iOS only fires repeating `input` events with
|
|
44
|
-
// inputType="deleteContentBackward" when the textarea has content to
|
|
45
|
-
// delete; with a perpetually empty textarea, holding Backspace deletes
|
|
46
|
-
// one char and stops because iOS sees nothing more to remove. The
|
|
47
|
-
// placeholder itself is invisible (textarea has opacity:0) and we strip
|
|
48
|
-
// it out of every send. NBSP chosen specifically so screen-reader
|
|
49
|
-
// announcement of the input state stays empty-ish rather than "space".
|
|
50
|
-
const INPUT_PLACEHOLDER = "\u00A0";
|
|
51
|
-
|
|
52
48
|
const TAP_MOVEMENT_PX = 10;
|
|
53
49
|
const LONG_PRESS_MS = 500;
|
|
54
50
|
const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
|
|
55
51
|
|
|
56
52
|
const encoder = new TextEncoder();
|
|
57
53
|
|
|
58
|
-
// --- DOM refs (
|
|
54
|
+
// --- DOM refs (picked by class from the subtree mount() builds) ---
|
|
59
55
|
let outputEl!: HTMLElement;
|
|
60
56
|
let termWrap!: HTMLElement;
|
|
61
57
|
let input!: HTMLTextAreaElement;
|
|
62
|
-
let compositionViewEl!: HTMLElement;
|
|
63
58
|
let ctxMenu!: HTMLElement;
|
|
64
|
-
let ctrlBtn: HTMLElement | null = null;
|
|
65
59
|
|
|
66
60
|
// --- Mutable UI state ---
|
|
67
|
-
|
|
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;
|
|
68
66
|
let fontsLoaded = false;
|
|
69
67
|
let wsOpen = false;
|
|
70
68
|
let lastPointerType = "mouse";
|
|
@@ -81,97 +79,125 @@ let longPressOrigin = { x: 0, y: 0 };
|
|
|
81
79
|
// the Paste button from our custom menu on iOS to steer them there.
|
|
82
80
|
let isIOS = false;
|
|
83
81
|
|
|
84
|
-
|
|
85
|
-
|
|
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);
|
|
86
117
|
if (!el) {
|
|
87
|
-
throw new Error(`web-terminal-ui:
|
|
118
|
+
throw new Error(`web-terminal-ui: mount() failed to build element ${selector}`);
|
|
88
119
|
}
|
|
89
120
|
return el;
|
|
90
121
|
}
|
|
91
122
|
|
|
92
123
|
function send(bytes: string): void {
|
|
93
|
-
// Suppress
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
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) {
|
|
97
134
|
return;
|
|
98
135
|
}
|
|
99
136
|
const buf = encoder.encode(bytes);
|
|
100
|
-
|
|
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.
|
|
101
141
|
if (!connection.sendBinary(buf)) {
|
|
102
|
-
|
|
142
|
+
return;
|
|
103
143
|
}
|
|
144
|
+
predict.applyInput(buf);
|
|
104
145
|
}
|
|
105
146
|
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// unreachable on touch. The toolbar's Ctrl button arms a one-shot
|
|
110
|
-
// modifier: tap it, then tap a letter on the virtual keyboard and that
|
|
111
|
-
// keystroke is sent as its C0 control byte. Auto-disarms after one
|
|
112
|
-
// printable character.
|
|
113
|
-
function setCtrlArmed(on: boolean): void {
|
|
114
|
-
ctrlArmed = on;
|
|
115
|
-
ctrlBtn?.classList.toggle("armed", on);
|
|
116
|
-
ctrlBtn?.setAttribute("aria-pressed", on ? "true" : "false");
|
|
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, " ")));
|
|
117
150
|
}
|
|
118
151
|
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
switch (ch) {
|
|
127
|
-
case " ":
|
|
128
|
-
case "@":
|
|
129
|
-
return "\x00";
|
|
130
|
-
case "[":
|
|
131
|
-
return "\x1b";
|
|
132
|
-
case "\\":
|
|
133
|
-
return "\x1c";
|
|
134
|
-
case "]":
|
|
135
|
-
return "\x1d";
|
|
136
|
-
case "^":
|
|
137
|
-
return "\x1e";
|
|
138
|
-
case "_":
|
|
139
|
-
return "\x1f";
|
|
140
|
-
case "?":
|
|
141
|
-
return "\x7f";
|
|
142
|
-
default:
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
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);
|
|
145
158
|
}
|
|
146
159
|
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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;
|
|
157
172
|
}
|
|
158
|
-
|
|
173
|
+
navigator.clipboard
|
|
174
|
+
.writeText(text)
|
|
175
|
+
.then(() => {
|
|
176
|
+
status.toast("Copied");
|
|
177
|
+
})
|
|
178
|
+
.catch(() => {
|
|
179
|
+
status.toast("Copy failed");
|
|
180
|
+
});
|
|
159
181
|
}
|
|
160
182
|
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
input.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
|
|
167
|
-
} catch {
|
|
168
|
-
// Ignore browsers that throw on setSelectionRange against a hidden
|
|
169
|
-
// input (some older WebKit builds).
|
|
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;
|
|
170
188
|
}
|
|
189
|
+
navigator.clipboard
|
|
190
|
+
.readText()
|
|
191
|
+
.then((text) => {
|
|
192
|
+
send(bracketTextForPaste(prepareTextForTerminal(text)));
|
|
193
|
+
})
|
|
194
|
+
.catch(() => {
|
|
195
|
+
status.toast("Paste blocked");
|
|
196
|
+
});
|
|
171
197
|
}
|
|
172
198
|
|
|
173
199
|
// Keydown handler — attached to the textarea (the single keyboard target,
|
|
174
|
-
// desktop and touch alike);
|
|
200
|
+
// desktop and touch alike); .term-output is display-only and never focused.
|
|
175
201
|
function handleKeydown(ev: KeyboardEvent): void {
|
|
176
202
|
// While composing (IME), let the browser pump composition events;
|
|
177
203
|
// keydown bytes during composition would duplicate the composed text.
|
|
@@ -179,6 +205,19 @@ function handleKeydown(ev: KeyboardEvent): void {
|
|
|
179
205
|
return;
|
|
180
206
|
}
|
|
181
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
|
+
|
|
182
221
|
// Ctrl+Shift+C / Ctrl+Shift+V — desktop clipboard shortcuts. Handled
|
|
183
222
|
// before the generic mapper because they take browser-side selection
|
|
184
223
|
// and clipboard, not server-bound key sequences.
|
|
@@ -186,22 +225,13 @@ function handleKeydown(ev: KeyboardEvent): void {
|
|
|
186
225
|
if (ev.code === "KeyC") {
|
|
187
226
|
const sel = window.getSelection()?.toString();
|
|
188
227
|
if (sel) {
|
|
189
|
-
|
|
190
|
-
/* ignore */
|
|
191
|
-
});
|
|
228
|
+
copyToClipboard(sel);
|
|
192
229
|
}
|
|
193
230
|
ev.preventDefault();
|
|
194
231
|
return;
|
|
195
232
|
}
|
|
196
233
|
if (ev.code === "KeyV") {
|
|
197
|
-
|
|
198
|
-
.readText()
|
|
199
|
-
.then((text) => {
|
|
200
|
-
send(bracketTextForPaste(prepareTextForTerminal(text)));
|
|
201
|
-
})
|
|
202
|
-
.catch(() => {
|
|
203
|
-
/* ignore */
|
|
204
|
-
});
|
|
234
|
+
pasteFromClipboard();
|
|
205
235
|
ev.preventDefault();
|
|
206
236
|
return;
|
|
207
237
|
}
|
|
@@ -250,8 +280,17 @@ function maybeSendFirstResize(): void {
|
|
|
250
280
|
}
|
|
251
281
|
|
|
252
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);
|
|
253
289
|
ctxMenu.classList.remove("visible");
|
|
254
|
-
ctxMenu.
|
|
290
|
+
ctxMenu.replaceChildren();
|
|
291
|
+
if (refocus) {
|
|
292
|
+
focusTerminal();
|
|
293
|
+
}
|
|
255
294
|
}
|
|
256
295
|
|
|
257
296
|
function showCtxMenu(x: number, y: number): void {
|
|
@@ -262,9 +301,7 @@ function showCtxMenu(x: number, y: number): void {
|
|
|
262
301
|
const copyBtn = document.createElement("button");
|
|
263
302
|
copyBtn.textContent = "Copy";
|
|
264
303
|
copyBtn.addEventListener("click", () => {
|
|
265
|
-
|
|
266
|
-
/* ignore */
|
|
267
|
-
});
|
|
304
|
+
copyToClipboard(sel);
|
|
268
305
|
hideCtxMenu();
|
|
269
306
|
});
|
|
270
307
|
ctxMenu.appendChild(copyBtn);
|
|
@@ -285,14 +322,7 @@ function showCtxMenu(x: number, y: number): void {
|
|
|
285
322
|
const pasteBtn = document.createElement("button");
|
|
286
323
|
pasteBtn.textContent = "Paste";
|
|
287
324
|
pasteBtn.addEventListener("click", () => {
|
|
288
|
-
|
|
289
|
-
.readText()
|
|
290
|
-
.then((text) => {
|
|
291
|
-
send(bracketTextForPaste(prepareTextForTerminal(text)));
|
|
292
|
-
})
|
|
293
|
-
.catch(() => {
|
|
294
|
-
/* ignore */
|
|
295
|
-
});
|
|
325
|
+
pasteFromClipboard();
|
|
296
326
|
hideCtxMenu();
|
|
297
327
|
});
|
|
298
328
|
ctxMenu.appendChild(pasteBtn);
|
|
@@ -317,24 +347,74 @@ function showCtxMenu(x: number, y: number): void {
|
|
|
317
347
|
ctxMenu.style.top = `${top}px`;
|
|
318
348
|
}
|
|
319
349
|
|
|
320
|
-
export function mount(opts: MountOptions = {}): TerminalUI {
|
|
350
|
+
export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
321
351
|
const wsPath = opts.wsPath ?? DEFAULT_WS_PATH;
|
|
322
352
|
const fontReady = opts.fontReady ?? DEFAULT_FONT_READY;
|
|
323
353
|
|
|
324
|
-
// ---
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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");
|
|
331
367
|
|
|
332
368
|
isIOS =
|
|
333
369
|
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
334
370
|
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
|
335
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
|
+
|
|
336
416
|
// --- Initialize layers ---
|
|
337
|
-
status.init();
|
|
417
|
+
status.init({ banner, onGiveUp: dismissLoadingOverlay });
|
|
338
418
|
|
|
339
419
|
render.init({
|
|
340
420
|
output: outputEl,
|
|
@@ -346,17 +426,13 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
346
426
|
// server cursor.
|
|
347
427
|
onCursorMove: () => {
|
|
348
428
|
composition.positionCompositionView();
|
|
349
|
-
|
|
350
|
-
render.setPredictedCursor(p.row, p.col, p.active);
|
|
429
|
+
pushPredictedCursor();
|
|
351
430
|
},
|
|
352
431
|
});
|
|
353
432
|
render.updateFontMetrics();
|
|
354
433
|
|
|
355
434
|
// predict redraws on every change to its predicted-cursor state.
|
|
356
|
-
predict.subscribe(
|
|
357
|
-
const p = predict.get();
|
|
358
|
-
render.setPredictedCursor(p.row, p.col, p.active);
|
|
359
|
-
});
|
|
435
|
+
predict.subscribe(pushPredictedCursor);
|
|
360
436
|
|
|
361
437
|
composition.init({
|
|
362
438
|
textarea: input,
|
|
@@ -370,10 +446,7 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
370
446
|
onUserScrollChange(scrolledUp) {
|
|
371
447
|
// Toggle .scrolled-up on the toolbar so CSS can show/hide the
|
|
372
448
|
// scroll-bottom button and grow the pill vertically.
|
|
373
|
-
|
|
374
|
-
if (toolbar) {
|
|
375
|
-
toolbar.classList.toggle("scrolled-up", scrolledUp);
|
|
376
|
-
}
|
|
449
|
+
toolbar.classList.toggle("scrolled-up", scrolledUp);
|
|
377
450
|
},
|
|
378
451
|
});
|
|
379
452
|
|
|
@@ -385,14 +458,9 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
385
458
|
onMessage(msg) {
|
|
386
459
|
if (msg.type === "screen") {
|
|
387
460
|
render.handleScreen(msg);
|
|
461
|
+
firstFrameRendered = true;
|
|
388
462
|
if (fontsLoaded) {
|
|
389
|
-
|
|
390
|
-
if (ld) {
|
|
391
|
-
ld.classList.add("fade");
|
|
392
|
-
ld.addEventListener("transitionend", () => {
|
|
393
|
-
ld.remove();
|
|
394
|
-
});
|
|
395
|
-
}
|
|
463
|
+
markReady();
|
|
396
464
|
}
|
|
397
465
|
predict.onScreenFrame(msg.cursor[0], msg.cursor[1], msg.cursorHidden);
|
|
398
466
|
} else if (msg.type === "scroll") {
|
|
@@ -407,7 +475,7 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
407
475
|
status.open();
|
|
408
476
|
// Do NOT call render.resetScreen() here. resetScreen flips the
|
|
409
477
|
// firstScreen flag, which causes the next screen frame to wipe
|
|
410
|
-
// the entire
|
|
478
|
+
// the entire .term-output DOM (including all scrollback above
|
|
411
479
|
// the live viewport). On reconnect (e.g. iPad screen dim/wake)
|
|
412
480
|
// that destroys history the user could otherwise scroll up to.
|
|
413
481
|
// The server forces a full repaint via builder.Reset() on resume,
|
|
@@ -444,7 +512,7 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
444
512
|
});
|
|
445
513
|
|
|
446
514
|
// --- Input handling ---
|
|
447
|
-
|
|
515
|
+
resetToPlaceholder(input);
|
|
448
516
|
|
|
449
517
|
input.addEventListener("input", (e: Event) => {
|
|
450
518
|
// While IME composition is in progress, the textarea fires `input`
|
|
@@ -458,19 +526,15 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
458
526
|
const ev = e as InputEvent;
|
|
459
527
|
const inputType = ev.inputType;
|
|
460
528
|
|
|
461
|
-
if (
|
|
462
|
-
|
|
529
|
+
if (
|
|
530
|
+
inputType === "deleteContentBackward" ||
|
|
531
|
+
inputType === "deleteContentForward" ||
|
|
532
|
+
inputType === "deleteWordBackward" ||
|
|
533
|
+
inputType === "deleteWordForward"
|
|
534
|
+
) {
|
|
535
|
+
// Deletion is handled by keydown — just re-pad the placeholder so iOS
|
|
463
536
|
// key-repeat keeps firing (it needs content to delete).
|
|
464
|
-
|
|
465
|
-
return;
|
|
466
|
-
} else if (inputType === "deleteContentForward") {
|
|
467
|
-
resetInputPlaceholder();
|
|
468
|
-
return;
|
|
469
|
-
} else if (inputType === "deleteWordBackward") {
|
|
470
|
-
resetInputPlaceholder();
|
|
471
|
-
return;
|
|
472
|
-
} else if (inputType === "deleteWordForward") {
|
|
473
|
-
resetInputPlaceholder();
|
|
537
|
+
resetToPlaceholder(input);
|
|
474
538
|
return;
|
|
475
539
|
} else if (typeof ev.data === "string" && ev.data.length > 0) {
|
|
476
540
|
// insertText / insertFromPaste / insertReplacementText etc. The
|
|
@@ -482,19 +546,29 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
482
546
|
// space byte. (Native paste is bracketed in composition.ts's paste
|
|
483
547
|
// handler before this fires; the insertFromPaste data here is a
|
|
484
548
|
// fallback for browsers that don't raise a separate paste event.)
|
|
485
|
-
|
|
549
|
+
if (inputType === "insertFromPaste") {
|
|
550
|
+
// Paste arriving through `input` without a preceding `paste` event
|
|
551
|
+
// (composition.ts's onPaste handles + preventDefaults the normal
|
|
552
|
+
// case). Bracket + sanitize it exactly like the Ctrl+Shift+V,
|
|
553
|
+
// context-menu Paste, and composition.onPaste paths so embedded
|
|
554
|
+
// newlines / control bytes arrive as an inert bracketed paste
|
|
555
|
+
// instead of executing as typed shell commands (paste-jacking).
|
|
556
|
+
send(bracketTextForPaste(prepareTextForTerminal(ev.data)));
|
|
557
|
+
} else {
|
|
558
|
+
sendTyped(ev.data);
|
|
559
|
+
}
|
|
486
560
|
} else {
|
|
487
561
|
// Fallback: anything in the textarea past the placeholder is new
|
|
488
562
|
// content. Covers browsers that don't populate inputType / data
|
|
489
563
|
// (older WebKit).
|
|
490
564
|
const v = input.value;
|
|
491
565
|
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
492
|
-
|
|
566
|
+
sendTyped(v.slice(INPUT_PLACEHOLDER.length));
|
|
493
567
|
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
494
|
-
|
|
568
|
+
sendTyped(v);
|
|
495
569
|
}
|
|
496
570
|
}
|
|
497
|
-
|
|
571
|
+
resetToPlaceholder(input);
|
|
498
572
|
});
|
|
499
573
|
|
|
500
574
|
// Focus state on the terminal element, for CSS targeting (e.g. dimming
|
|
@@ -506,16 +580,16 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
506
580
|
// Restore the placeholder so the held-Backspace iOS path stays
|
|
507
581
|
// primed for the next focus. Also clears any leftover screen-reader
|
|
508
582
|
// text (xterm.js convention).
|
|
509
|
-
|
|
583
|
+
resetToPlaceholder(input);
|
|
510
584
|
termWrap.classList.remove("focus");
|
|
511
585
|
});
|
|
512
586
|
|
|
513
587
|
// The textarea is the single keyboard target (desktop and touch alike);
|
|
514
|
-
//
|
|
588
|
+
// .term-output is display-only and never focused.
|
|
515
589
|
input.addEventListener("keydown", handleKeydown);
|
|
516
590
|
|
|
517
591
|
// --- Focus strategy ---
|
|
518
|
-
// One element, one job.
|
|
592
|
+
// One element, one job. .term-output is display + native selection only
|
|
519
593
|
// and is NEVER focused; the textarea owns the keyboard, the local typing
|
|
520
594
|
// buffer, and IME. Because the editable element is never the scroll
|
|
521
595
|
// content, the first touch-drag scrolls instead of placing a caret and a
|
|
@@ -551,6 +625,11 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
551
625
|
if (e.pointerType !== "touch") {
|
|
552
626
|
return; // mouse/trackpad handled by the click listener below
|
|
553
627
|
}
|
|
628
|
+
// A tap on a terminal link is opened by the click listener below; don't
|
|
629
|
+
// also focus the textarea (which pops the iOS soft keyboard) on the way.
|
|
630
|
+
if ((e.target as HTMLElement).closest(".term-link")) {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
554
633
|
const dx = Math.abs(e.clientX - pointerDownX);
|
|
555
634
|
const dy = Math.abs(e.clientY - pointerDownY);
|
|
556
635
|
if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
|
|
@@ -562,15 +641,17 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
562
641
|
}
|
|
563
642
|
// Synchronous focus inside pointerup keeps us inside iOS's
|
|
564
643
|
// user-gesture window even when the streaming flush queue is busy.
|
|
565
|
-
|
|
644
|
+
focusTerminal();
|
|
566
645
|
},
|
|
567
646
|
{ passive: true },
|
|
568
647
|
);
|
|
569
648
|
termWrap.addEventListener("click", (e) => {
|
|
570
|
-
// Terminal links: URLs
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
649
|
+
// Terminal links: URLs rendered by the engine are wrapped in
|
|
650
|
+
// <a class="term-link" target="_blank" rel="noopener noreferrer">. The
|
|
651
|
+
// output is display-only (never contenteditable -- see the input-model
|
|
652
|
+
// contract), so intercept the click to open with explicit
|
|
653
|
+
// noopener/noreferrer and to keep the focus handler below from running
|
|
654
|
+
// on a link tap.
|
|
574
655
|
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
|
|
575
656
|
if (link) {
|
|
576
657
|
e.preventDefault();
|
|
@@ -617,15 +698,34 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
617
698
|
|
|
618
699
|
// Only wait for the Regular weight — it determines cell size.
|
|
619
700
|
// Bold/Italic load lazily when first used; style pop is barely noticeable.
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
701
|
+
// A missing/slow font, or a malformed `fontReady` (FontFaceSet.load can throw
|
|
702
|
+
// synchronously on a bad shorthand), must NOT leave the terminal permanently
|
|
703
|
+
// unsized: degrade to fallback-font metrics so the first resize still fires
|
|
704
|
+
// and the loading overlay still dismisses.
|
|
705
|
+
const onFontSettled = (): void => {
|
|
624
706
|
fontsLoaded = true;
|
|
707
|
+
if (firstFrameRendered) {
|
|
708
|
+
markReady();
|
|
709
|
+
}
|
|
625
710
|
requestAnimationFrame(() => {
|
|
626
711
|
maybeSendFirstResize();
|
|
627
712
|
});
|
|
628
|
-
}
|
|
713
|
+
};
|
|
714
|
+
try {
|
|
715
|
+
void document.fonts
|
|
716
|
+
.load(fontReady)
|
|
717
|
+
.then(onFontSettled)
|
|
718
|
+
.catch((err: unknown) => {
|
|
719
|
+
console.warn(
|
|
720
|
+
`web-terminal-ui: web font ${fontReady} failed to load; using fallback metrics`,
|
|
721
|
+
err,
|
|
722
|
+
);
|
|
723
|
+
onFontSettled();
|
|
724
|
+
});
|
|
725
|
+
} catch (err) {
|
|
726
|
+
console.warn(`web-terminal-ui: invalid fontReady ${fontReady}; using fallback metrics`, err);
|
|
727
|
+
onFontSettled();
|
|
728
|
+
}
|
|
629
729
|
|
|
630
730
|
// Connect immediately — the WS open triggers maybeSendFirstResize.
|
|
631
731
|
render.updateFontMetrics();
|
|
@@ -651,21 +751,19 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
651
751
|
});
|
|
652
752
|
|
|
653
753
|
// --- Scroll-to-bottom (inside toolbar grid) ---
|
|
654
|
-
const scrollBtn =
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
});
|
|
668
|
-
}
|
|
754
|
+
const scrollBtn = pick(root, ".kb-scroll-bottom");
|
|
755
|
+
// pointerdown (like the other toolbar keys) so touch devices enter
|
|
756
|
+
// :active and show press feedback; preventDefault keeps focus on the
|
|
757
|
+
// terminal. click is kept for keyboard activation of the desktop FAB.
|
|
758
|
+
// scrollToBottom() is idempotent, so the pointerdown+click pair on a
|
|
759
|
+
// mouse press is harmless.
|
|
760
|
+
scrollBtn.addEventListener("pointerdown", (e) => {
|
|
761
|
+
e.preventDefault();
|
|
762
|
+
scroll.scrollToBottom();
|
|
763
|
+
});
|
|
764
|
+
scrollBtn.addEventListener("click", () => {
|
|
765
|
+
scroll.scrollToBottom();
|
|
766
|
+
});
|
|
669
767
|
|
|
670
768
|
// --- Context menu ---
|
|
671
769
|
termWrap.addEventListener("contextmenu", (e: MouseEvent) => {
|
|
@@ -749,45 +847,28 @@ export function mount(opts: MountOptions = {}): TerminalUI {
|
|
|
749
847
|
hideCtxMenu();
|
|
750
848
|
});
|
|
751
849
|
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
}),
|
|
759
|
-
);
|
|
760
|
-
const toggleBtn = document.getElementById("kb-toggle");
|
|
761
|
-
toggleBtn?.addEventListener("pointerdown", (e) => {
|
|
762
|
-
e.preventDefault();
|
|
763
|
-
keyToolbar.classList.toggle("collapsed");
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
const keyMap: Record<string, string> = {
|
|
767
|
-
"kb-up": "\x1b[A",
|
|
768
|
-
"kb-down": "\x1b[B",
|
|
769
|
-
"kb-left": "\x1b[D",
|
|
770
|
-
"kb-right": "\x1b[C",
|
|
771
|
-
"kb-esc": "\x1b",
|
|
772
|
-
"kb-tab": "\t",
|
|
773
|
-
"kb-enter": "\r",
|
|
774
|
-
};
|
|
775
|
-
|
|
776
|
-
for (const [id, seq] of Object.entries(keyMap)) {
|
|
777
|
-
document.getElementById(id)?.addEventListener("pointerdown", (e) => {
|
|
778
|
-
e.preventDefault();
|
|
779
|
-
setCtrlArmed(false);
|
|
780
|
-
send(seq);
|
|
781
|
-
});
|
|
850
|
+
// Escape dismisses the context menu (keyboard parity with the outside-click
|
|
851
|
+
// dismiss; the menu can be opened via the keyboard Menu key, which fires
|
|
852
|
+
// `contextmenu`). Without this an AT/keyboard user has no keyboard way to close it.
|
|
853
|
+
document.addEventListener("keydown", (e) => {
|
|
854
|
+
if (e.key === "Escape" && ctxMenu.classList.contains("visible")) {
|
|
855
|
+
hideCtxMenu();
|
|
782
856
|
}
|
|
857
|
+
});
|
|
783
858
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
859
|
+
// --- Mobile key toolbar ---
|
|
860
|
+
// The engine's keyboard.bindMobileToolbar wires the collapse toggle, the
|
|
861
|
+
// arrows (DECCKM-aware — SS3 under application-cursor mode, matching
|
|
862
|
+
// mapKeyboardEvent), Tab / Enter / Esc, and the sticky-Ctrl button on the
|
|
863
|
+
// built toolbar. It returns the controller whose applyStickyCtrl the input
|
|
864
|
+
// handler above applies to typed text. The no-transition priming below
|
|
865
|
+
// stays local (a UI-specific concern).
|
|
866
|
+
toolbarCtrl = keyboard.bindMobileToolbar({ toolbar, send });
|
|
867
|
+
requestAnimationFrame(() =>
|
|
868
|
+
requestAnimationFrame(() => {
|
|
869
|
+
toolbar.classList.remove("no-transition");
|
|
870
|
+
}),
|
|
871
|
+
);
|
|
791
872
|
|
|
792
873
|
// --- Copy feedback toast ---
|
|
793
874
|
document.addEventListener("copy", () => {
|