@cplieger/web-terminal-ui 2.0.0 → 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/css/00-tokens.css +1 -0
- package/css/02-app.css +34 -10
- package/package.json +3 -3
- package/src/composition.ts +4 -26
- package/src/index.ts +3 -2
- package/src/input-placeholder.ts +29 -0
- package/src/mount.ts +204 -101
- package/src/predict.ts +13 -15
- package/src/status.ts +62 -19
package/css/00-tokens.css
CHANGED
package/css/02-app.css
CHANGED
|
@@ -48,11 +48,10 @@
|
|
|
48
48
|
client-side cap or a server-reported resume gap). A quiet, centered,
|
|
49
49
|
non-selectable divider so the user knows scrollback doesn't go further up. */
|
|
50
50
|
.term-trim-marker {
|
|
51
|
-
color: color-mix(in oklch, var(--text)
|
|
51
|
+
color: color-mix(in oklch, var(--text) 60%, transparent);
|
|
52
52
|
font-size: 0.6875rem;
|
|
53
53
|
text-align: center;
|
|
54
54
|
padding: 2px 0;
|
|
55
|
-
opacity: 0.7;
|
|
56
55
|
user-select: none;
|
|
57
56
|
font-family: var(--font-ui);
|
|
58
57
|
}
|
|
@@ -141,10 +140,11 @@
|
|
|
141
140
|
display: block;
|
|
142
141
|
}
|
|
143
142
|
|
|
144
|
-
/* Connection-state banner:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
/* Connection-state banner: bottom-center on touch (lifted above the
|
|
144
|
+
virtual keyboard via --kb-inset), top-center on desktop. Subtle
|
|
145
|
+
backdrop blur. Hidden until status.ts sets `.visible`. Distinct from
|
|
146
|
+
the `.pill` family because it wants to span the full width centrally
|
|
147
|
+
rather than dock to a corner. */
|
|
148
148
|
.conn-banner {
|
|
149
149
|
position: fixed;
|
|
150
150
|
bottom: var(--sp-3);
|
|
@@ -208,6 +208,18 @@
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/* Desktop (pointer: fine): move the banner to top-center so it no longer
|
|
212
|
+
shares the bottom-center anchor with the scroll-to-bottom FAB
|
|
213
|
+
(.kb-scroll-bottom). Sharing it let the banner (pointer-events:auto,
|
|
214
|
+
z-index:20) render over the FAB and intercept its click. Touch keeps
|
|
215
|
+
the bottom placement and the --kb-inset keyboard lift above. */
|
|
216
|
+
@media (pointer: fine) {
|
|
217
|
+
.conn-banner {
|
|
218
|
+
top: var(--sp-3);
|
|
219
|
+
bottom: auto;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
211
223
|
/* Clickable URLs */
|
|
212
224
|
.term-link {
|
|
213
225
|
color: inherit;
|
|
@@ -430,7 +442,7 @@
|
|
|
430
442
|
z-index: 10;
|
|
431
443
|
transition:
|
|
432
444
|
opacity var(--dur-standard) var(--ease-standard),
|
|
433
|
-
background-color
|
|
445
|
+
background-color var(--dur-micro) var(--ease-standard),
|
|
434
446
|
display var(--dur-standard) allow-discrete;
|
|
435
447
|
}
|
|
436
448
|
|
|
@@ -696,9 +708,9 @@
|
|
|
696
708
|
opacity: 0;
|
|
697
709
|
scale: 0.95;
|
|
698
710
|
transition:
|
|
699
|
-
opacity
|
|
700
|
-
scale
|
|
701
|
-
display
|
|
711
|
+
opacity var(--dur-exit) var(--ease-standard),
|
|
712
|
+
scale var(--dur-exit) var(--ease-standard),
|
|
713
|
+
display var(--dur-exit) allow-discrete;
|
|
702
714
|
}
|
|
703
715
|
|
|
704
716
|
.ctx-menu.visible {
|
|
@@ -728,3 +740,15 @@
|
|
|
728
740
|
.ctx-menu button:active {
|
|
729
741
|
background: var(--text-active);
|
|
730
742
|
}
|
|
743
|
+
|
|
744
|
+
/* Touch (long-press) menu: bump the rows to the 44px --touch-target
|
|
745
|
+
standard the rest of the touch UI uses (the key-toolbar grid is
|
|
746
|
+
repeat(4, var(--touch-target))). Desktop right-click keeps the
|
|
747
|
+
compact 24px menu. padding-block re-centres the label in the taller box. */
|
|
748
|
+
@media (pointer: coarse) {
|
|
749
|
+
.ctx-menu button {
|
|
750
|
+
min-height: var(--touch-target);
|
|
751
|
+
padding-block: var(--sp-2);
|
|
752
|
+
touch-action: manipulation;
|
|
753
|
+
}
|
|
754
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: textarea input model, mobile key toolbar, context menu, IME, predictive echo, viewport/keyboard-inset handling, and CSS. One mount() call.",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"fast-check": "4.8.0",
|
|
32
32
|
"happy-dom": "20.10.6",
|
|
33
33
|
"html-validate": "11.5.3",
|
|
34
|
-
"knip": "6.
|
|
35
|
-
"prettier": "3.
|
|
34
|
+
"knip": "6.22.0",
|
|
35
|
+
"prettier": "3.9.0",
|
|
36
36
|
"stylelint": "17.14.0",
|
|
37
37
|
"stylelint-config-standard": "40.0.0",
|
|
38
38
|
"typescript": "6.0.3",
|
package/src/composition.ts
CHANGED
|
@@ -27,14 +27,9 @@
|
|
|
27
27
|
// for the same reason.
|
|
28
28
|
|
|
29
29
|
import { keyboard } from "@cplieger/web-terminal-engine";
|
|
30
|
+
import { resetToPlaceholder } from "./input-placeholder.js";
|
|
30
31
|
const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
31
32
|
|
|
32
|
-
// Single-character invisible placeholder used by app.ts to keep the
|
|
33
|
-
// iOS soft keyboard's held-Backspace auto-repeat firing. Composition
|
|
34
|
-
// finalisation must restore the placeholder (and not empty the
|
|
35
|
-
// textarea) for the same reason.
|
|
36
|
-
const INPUT_PLACEHOLDER = "\u00A0";
|
|
37
|
-
|
|
38
33
|
let textarea: HTMLTextAreaElement;
|
|
39
34
|
let compositionView: HTMLElement;
|
|
40
35
|
let getCursorPx: () => { left: number; top: number; cellH: number };
|
|
@@ -43,7 +38,6 @@ let send: (bytes: string) => void;
|
|
|
43
38
|
let composing = false;
|
|
44
39
|
let sendingComposition = false;
|
|
45
40
|
let compositionStart = 0;
|
|
46
|
-
let compositionEnd = 0;
|
|
47
41
|
let compositionSuffix = "";
|
|
48
42
|
|
|
49
43
|
export function init(opts: {
|
|
@@ -75,7 +69,7 @@ function onStart(): void {
|
|
|
75
69
|
const start = textarea.selectionStart;
|
|
76
70
|
const end = textarea.selectionEnd;
|
|
77
71
|
compositionStart = Math.min(start, end);
|
|
78
|
-
compositionEnd = Math.max(start, end);
|
|
72
|
+
const compositionEnd = Math.max(start, end);
|
|
79
73
|
compositionSuffix = textarea.value.substring(compositionEnd);
|
|
80
74
|
compositionView.textContent = "";
|
|
81
75
|
compositionView.classList.add("active");
|
|
@@ -88,12 +82,6 @@ function onUpdate(ev: CompositionEvent): void {
|
|
|
88
82
|
// clipped at the start. Pattern from xterm.js.
|
|
89
83
|
compositionView.textContent = `\u200E${ev.data}\u200E`;
|
|
90
84
|
positionCompositionView();
|
|
91
|
-
// Schedule a microtask to refresh the end position after the
|
|
92
|
-
// textarea selection settles.
|
|
93
|
-
setTimeout(() => {
|
|
94
|
-
const end = textarea.selectionEnd;
|
|
95
|
-
compositionEnd = Math.max(compositionStart, end);
|
|
96
|
-
}, 0);
|
|
97
85
|
}
|
|
98
86
|
|
|
99
87
|
function onEnd(): void {
|
|
@@ -119,7 +107,7 @@ function onEnd(): void {
|
|
|
119
107
|
if (composed.length > 0) {
|
|
120
108
|
send(composed);
|
|
121
109
|
}
|
|
122
|
-
|
|
110
|
+
resetToPlaceholder(textarea);
|
|
123
111
|
}, 0);
|
|
124
112
|
}
|
|
125
113
|
|
|
@@ -140,17 +128,7 @@ function onPaste(ev: ClipboardEvent): void {
|
|
|
140
128
|
// Restore the placeholder so the subsequent `input` event (some
|
|
141
129
|
// browsers fire it after paste) and held-Backspace auto-repeat keep
|
|
142
130
|
// working.
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function resetTextareaToPlaceholder(): void {
|
|
147
|
-
textarea.value = INPUT_PLACEHOLDER;
|
|
148
|
-
try {
|
|
149
|
-
textarea.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
|
|
150
|
-
} catch {
|
|
151
|
-
// Some older WebKit builds throw on setSelectionRange against a
|
|
152
|
-
// visually-hidden textarea; ignore.
|
|
153
|
-
}
|
|
131
|
+
resetToPlaceholder(textarea);
|
|
154
132
|
}
|
|
155
133
|
|
|
156
134
|
/** Position the composition view (and the helper textarea, which
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @cplieger/web-terminal-ui — the reference touch-first browser UI built on
|
|
2
|
-
// the @cplieger/web-terminal-engine engine. The public surface is a single
|
|
3
|
-
// call
|
|
2
|
+
// the @cplieger/web-terminal-engine engine. The public surface is a single
|
|
3
|
+
// mount(root) call that builds the whole terminal subtree inside a
|
|
4
|
+
// host-provided element; scaffold/index.html + css/ are a reference page to copy.
|
|
4
5
|
//
|
|
5
6
|
// Consumers who want a different UI should depend on @cplieger/web-terminal-engine
|
|
6
7
|
// directly and wire the engine's render/scroll/connection/keyboard modules
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Single source of truth for the hidden-textarea placeholder shared by
|
|
2
|
+
// mount.ts and composition.ts.
|
|
3
|
+
//
|
|
4
|
+
// A single-character placeholder is kept in the hidden textarea so iOS soft
|
|
5
|
+
// keyboards have something to "delete" when the user holds Backspace. iOS only
|
|
6
|
+
// fires repeating `input` events with inputType="deleteContentBackward" when
|
|
7
|
+
// the textarea has content to delete; with a perpetually empty textarea,
|
|
8
|
+
// holding Backspace deletes one char and stops because iOS sees nothing more to
|
|
9
|
+
// remove. The placeholder itself is invisible (textarea has opacity:0) and is
|
|
10
|
+
// stripped out of every send. NBSP is chosen specifically so screen-reader
|
|
11
|
+
// announcement of the input state stays empty-ish rather than "space".
|
|
12
|
+
//
|
|
13
|
+
// The constant AND the reset routine must stay in lockstep across the two
|
|
14
|
+
// modules (mount.ts seeds + re-pads it; composition.ts restores it on
|
|
15
|
+
// compositionend / paste) or the iOS held-Backspace key-repeat silently breaks
|
|
16
|
+
// with no compile error — which is why they live here in one place.
|
|
17
|
+
export const INPUT_PLACEHOLDER = "\u00A0";
|
|
18
|
+
|
|
19
|
+
// Restore the placeholder and put the cursor at the end, so the next typed
|
|
20
|
+
// character appends after it rather than before.
|
|
21
|
+
export function resetToPlaceholder(textarea: HTMLTextAreaElement): void {
|
|
22
|
+
textarea.value = INPUT_PLACEHOLDER;
|
|
23
|
+
try {
|
|
24
|
+
textarea.setSelectionRange(INPUT_PLACEHOLDER.length, INPUT_PLACEHOLDER.length);
|
|
25
|
+
} catch {
|
|
26
|
+
// Some older WebKit builds throw on setSelectionRange against a
|
|
27
|
+
// visually-hidden textarea; ignore.
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/mount.ts
CHANGED
|
@@ -18,6 +18,7 @@ import * as viewport from "./viewport.js";
|
|
|
18
18
|
import * as composition from "./composition.js";
|
|
19
19
|
import * as status from "./status.js";
|
|
20
20
|
import * as predict from "./predict.js";
|
|
21
|
+
import { INPUT_PLACEHOLDER, resetToPlaceholder } from "./input-placeholder.js";
|
|
21
22
|
|
|
22
23
|
const { mapKeyboardEvent, bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
23
24
|
|
|
@@ -44,34 +45,22 @@ export interface TerminalUI {
|
|
|
44
45
|
const DEFAULT_WS_PATH = "/ws";
|
|
45
46
|
const DEFAULT_FONT_READY = '14px "MonaspiceNe NFM"';
|
|
46
47
|
|
|
47
|
-
// Single-character placeholder kept in the hidden textarea so iOS soft
|
|
48
|
-
// keyboards have something to "delete" when the user holds Backspace.
|
|
49
|
-
// iOS only fires repeating `input` events with
|
|
50
|
-
// inputType="deleteContentBackward" when the textarea has content to
|
|
51
|
-
// delete; with a perpetually empty textarea, holding Backspace deletes
|
|
52
|
-
// one char and stops because iOS sees nothing more to remove. The
|
|
53
|
-
// placeholder itself is invisible (textarea has opacity:0) and we strip
|
|
54
|
-
// it out of every send. NBSP chosen specifically so screen-reader
|
|
55
|
-
// announcement of the input state stays empty-ish rather than "space".
|
|
56
|
-
const INPUT_PLACEHOLDER = "\u00A0";
|
|
57
|
-
|
|
58
48
|
const TAP_MOVEMENT_PX = 10;
|
|
59
49
|
const LONG_PRESS_MS = 500;
|
|
60
50
|
const LONG_PRESS_MOVE_THRESHOLD_PX = 10;
|
|
61
51
|
|
|
62
52
|
const encoder = new TextEncoder();
|
|
63
53
|
|
|
64
|
-
// --- DOM refs (
|
|
54
|
+
// --- DOM refs (picked by class from the subtree mount() builds) ---
|
|
65
55
|
let outputEl!: HTMLElement;
|
|
66
56
|
let termWrap!: HTMLElement;
|
|
67
57
|
let input!: HTMLTextAreaElement;
|
|
68
|
-
let compositionViewEl!: HTMLElement;
|
|
69
58
|
let ctxMenu!: HTMLElement;
|
|
70
59
|
|
|
71
60
|
// --- Mutable UI state ---
|
|
72
61
|
// Sticky-Ctrl state + the C0 mapping table + the toolbar button wiring all
|
|
73
62
|
// live in the engine's keyboard.bindMobileToolbar; mount() binds it against
|
|
74
|
-
// the
|
|
63
|
+
// the built .key-toolbar element and keeps the controller so the input handler
|
|
75
64
|
// can route typed text through its applyStickyCtrl.
|
|
76
65
|
let toolbarCtrl!: keyboard.MobileToolbarController;
|
|
77
66
|
let fontsLoaded = false;
|
|
@@ -132,33 +121,83 @@ function pick(root: ParentNode, selector: string): HTMLElement {
|
|
|
132
121
|
}
|
|
133
122
|
|
|
134
123
|
function send(bytes: string): void {
|
|
135
|
-
// Suppress
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
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) {
|
|
139
134
|
return;
|
|
140
135
|
}
|
|
141
136
|
const buf = encoder.encode(bytes);
|
|
142
|
-
|
|
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.
|
|
143
141
|
if (!connection.sendBinary(buf)) {
|
|
144
|
-
|
|
142
|
+
return;
|
|
145
143
|
}
|
|
144
|
+
predict.applyInput(buf);
|
|
146
145
|
}
|
|
147
146
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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;
|
|
157
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
|
+
});
|
|
158
197
|
}
|
|
159
198
|
|
|
160
199
|
// Keydown handler — attached to the textarea (the single keyboard target,
|
|
161
|
-
// desktop and touch alike);
|
|
200
|
+
// desktop and touch alike); .term-output is display-only and never focused.
|
|
162
201
|
function handleKeydown(ev: KeyboardEvent): void {
|
|
163
202
|
// While composing (IME), let the browser pump composition events;
|
|
164
203
|
// keydown bytes during composition would duplicate the composed text.
|
|
@@ -166,6 +205,19 @@ function handleKeydown(ev: KeyboardEvent): void {
|
|
|
166
205
|
return;
|
|
167
206
|
}
|
|
168
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
|
+
|
|
169
221
|
// Ctrl+Shift+C / Ctrl+Shift+V — desktop clipboard shortcuts. Handled
|
|
170
222
|
// before the generic mapper because they take browser-side selection
|
|
171
223
|
// and clipboard, not server-bound key sequences.
|
|
@@ -173,22 +225,13 @@ function handleKeydown(ev: KeyboardEvent): void {
|
|
|
173
225
|
if (ev.code === "KeyC") {
|
|
174
226
|
const sel = window.getSelection()?.toString();
|
|
175
227
|
if (sel) {
|
|
176
|
-
|
|
177
|
-
/* ignore */
|
|
178
|
-
});
|
|
228
|
+
copyToClipboard(sel);
|
|
179
229
|
}
|
|
180
230
|
ev.preventDefault();
|
|
181
231
|
return;
|
|
182
232
|
}
|
|
183
233
|
if (ev.code === "KeyV") {
|
|
184
|
-
|
|
185
|
-
.readText()
|
|
186
|
-
.then((text) => {
|
|
187
|
-
send(bracketTextForPaste(prepareTextForTerminal(text)));
|
|
188
|
-
})
|
|
189
|
-
.catch(() => {
|
|
190
|
-
/* ignore */
|
|
191
|
-
});
|
|
234
|
+
pasteFromClipboard();
|
|
192
235
|
ev.preventDefault();
|
|
193
236
|
return;
|
|
194
237
|
}
|
|
@@ -237,8 +280,17 @@ function maybeSendFirstResize(): void {
|
|
|
237
280
|
}
|
|
238
281
|
|
|
239
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);
|
|
240
289
|
ctxMenu.classList.remove("visible");
|
|
241
|
-
ctxMenu.
|
|
290
|
+
ctxMenu.replaceChildren();
|
|
291
|
+
if (refocus) {
|
|
292
|
+
focusTerminal();
|
|
293
|
+
}
|
|
242
294
|
}
|
|
243
295
|
|
|
244
296
|
function showCtxMenu(x: number, y: number): void {
|
|
@@ -249,9 +301,7 @@ function showCtxMenu(x: number, y: number): void {
|
|
|
249
301
|
const copyBtn = document.createElement("button");
|
|
250
302
|
copyBtn.textContent = "Copy";
|
|
251
303
|
copyBtn.addEventListener("click", () => {
|
|
252
|
-
|
|
253
|
-
/* ignore */
|
|
254
|
-
});
|
|
304
|
+
copyToClipboard(sel);
|
|
255
305
|
hideCtxMenu();
|
|
256
306
|
});
|
|
257
307
|
ctxMenu.appendChild(copyBtn);
|
|
@@ -272,14 +322,7 @@ function showCtxMenu(x: number, y: number): void {
|
|
|
272
322
|
const pasteBtn = document.createElement("button");
|
|
273
323
|
pasteBtn.textContent = "Paste";
|
|
274
324
|
pasteBtn.addEventListener("click", () => {
|
|
275
|
-
|
|
276
|
-
.readText()
|
|
277
|
-
.then((text) => {
|
|
278
|
-
send(bracketTextForPaste(prepareTextForTerminal(text)));
|
|
279
|
-
})
|
|
280
|
-
.catch(() => {
|
|
281
|
-
/* ignore */
|
|
282
|
-
});
|
|
325
|
+
pasteFromClipboard();
|
|
283
326
|
hideCtxMenu();
|
|
284
327
|
});
|
|
285
328
|
ctxMenu.appendChild(pasteBtn);
|
|
@@ -317,7 +360,7 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
317
360
|
termWrap = pick(root, ".term");
|
|
318
361
|
outputEl = pick(root, ".term-output");
|
|
319
362
|
input = pick(root, ".term-input") as HTMLTextAreaElement;
|
|
320
|
-
compositionViewEl = pick(root, ".composition-view");
|
|
363
|
+
const compositionViewEl = pick(root, ".composition-view");
|
|
321
364
|
ctxMenu = pick(root, ".ctx-menu");
|
|
322
365
|
const banner = pick(root, ".conn-banner");
|
|
323
366
|
const toolbar = pick(root, ".key-toolbar");
|
|
@@ -331,23 +374,47 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
331
374
|
// may show reconnect state from here on) and fades out the consumer's
|
|
332
375
|
// loading overlay if one was passed.
|
|
333
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
|
+
}
|
|
334
407
|
function markReady(): void {
|
|
335
408
|
if (ready) {
|
|
336
409
|
return;
|
|
337
410
|
}
|
|
338
411
|
ready = true;
|
|
339
412
|
status.setLoaded();
|
|
340
|
-
|
|
341
|
-
if (ld) {
|
|
342
|
-
ld.classList.add("fade");
|
|
343
|
-
ld.addEventListener("transitionend", () => {
|
|
344
|
-
ld.remove();
|
|
345
|
-
});
|
|
346
|
-
}
|
|
413
|
+
dismissLoadingOverlay();
|
|
347
414
|
}
|
|
348
415
|
|
|
349
416
|
// --- Initialize layers ---
|
|
350
|
-
status.init({ banner });
|
|
417
|
+
status.init({ banner, onGiveUp: dismissLoadingOverlay });
|
|
351
418
|
|
|
352
419
|
render.init({
|
|
353
420
|
output: outputEl,
|
|
@@ -359,17 +426,13 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
359
426
|
// server cursor.
|
|
360
427
|
onCursorMove: () => {
|
|
361
428
|
composition.positionCompositionView();
|
|
362
|
-
|
|
363
|
-
render.setPredictedCursor(p.row, p.col, p.active);
|
|
429
|
+
pushPredictedCursor();
|
|
364
430
|
},
|
|
365
431
|
});
|
|
366
432
|
render.updateFontMetrics();
|
|
367
433
|
|
|
368
434
|
// predict redraws on every change to its predicted-cursor state.
|
|
369
|
-
predict.subscribe(
|
|
370
|
-
const p = predict.get();
|
|
371
|
-
render.setPredictedCursor(p.row, p.col, p.active);
|
|
372
|
-
});
|
|
435
|
+
predict.subscribe(pushPredictedCursor);
|
|
373
436
|
|
|
374
437
|
composition.init({
|
|
375
438
|
textarea: input,
|
|
@@ -395,6 +458,7 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
395
458
|
onMessage(msg) {
|
|
396
459
|
if (msg.type === "screen") {
|
|
397
460
|
render.handleScreen(msg);
|
|
461
|
+
firstFrameRendered = true;
|
|
398
462
|
if (fontsLoaded) {
|
|
399
463
|
markReady();
|
|
400
464
|
}
|
|
@@ -411,7 +475,7 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
411
475
|
status.open();
|
|
412
476
|
// Do NOT call render.resetScreen() here. resetScreen flips the
|
|
413
477
|
// firstScreen flag, which causes the next screen frame to wipe
|
|
414
|
-
// the entire
|
|
478
|
+
// the entire .term-output DOM (including all scrollback above
|
|
415
479
|
// the live viewport). On reconnect (e.g. iPad screen dim/wake)
|
|
416
480
|
// that destroys history the user could otherwise scroll up to.
|
|
417
481
|
// The server forces a full repaint via builder.Reset() on resume,
|
|
@@ -448,7 +512,7 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
448
512
|
});
|
|
449
513
|
|
|
450
514
|
// --- Input handling ---
|
|
451
|
-
|
|
515
|
+
resetToPlaceholder(input);
|
|
452
516
|
|
|
453
517
|
input.addEventListener("input", (e: Event) => {
|
|
454
518
|
// While IME composition is in progress, the textarea fires `input`
|
|
@@ -462,19 +526,15 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
462
526
|
const ev = e as InputEvent;
|
|
463
527
|
const inputType = ev.inputType;
|
|
464
528
|
|
|
465
|
-
if (
|
|
466
|
-
|
|
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
|
|
467
536
|
// key-repeat keeps firing (it needs content to delete).
|
|
468
|
-
|
|
469
|
-
return;
|
|
470
|
-
} else if (inputType === "deleteContentForward") {
|
|
471
|
-
resetInputPlaceholder();
|
|
472
|
-
return;
|
|
473
|
-
} else if (inputType === "deleteWordBackward") {
|
|
474
|
-
resetInputPlaceholder();
|
|
475
|
-
return;
|
|
476
|
-
} else if (inputType === "deleteWordForward") {
|
|
477
|
-
resetInputPlaceholder();
|
|
537
|
+
resetToPlaceholder(input);
|
|
478
538
|
return;
|
|
479
539
|
} else if (typeof ev.data === "string" && ev.data.length > 0) {
|
|
480
540
|
// insertText / insertFromPaste / insertReplacementText etc. The
|
|
@@ -486,21 +546,29 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
486
546
|
// space byte. (Native paste is bracketed in composition.ts's paste
|
|
487
547
|
// handler before this fires; the insertFromPaste data here is a
|
|
488
548
|
// fallback for browsers that don't raise a separate paste event.)
|
|
489
|
-
|
|
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
|
+
}
|
|
490
560
|
} else {
|
|
491
561
|
// Fallback: anything in the textarea past the placeholder is new
|
|
492
562
|
// content. Covers browsers that don't populate inputType / data
|
|
493
563
|
// (older WebKit).
|
|
494
564
|
const v = input.value;
|
|
495
565
|
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
496
|
-
|
|
497
|
-
toolbarCtrl.applyStickyCtrl(v.slice(INPUT_PLACEHOLDER.length).replace(/\u00A0/g, " ")),
|
|
498
|
-
);
|
|
566
|
+
sendTyped(v.slice(INPUT_PLACEHOLDER.length));
|
|
499
567
|
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
500
|
-
|
|
568
|
+
sendTyped(v);
|
|
501
569
|
}
|
|
502
570
|
}
|
|
503
|
-
|
|
571
|
+
resetToPlaceholder(input);
|
|
504
572
|
});
|
|
505
573
|
|
|
506
574
|
// Focus state on the terminal element, for CSS targeting (e.g. dimming
|
|
@@ -512,16 +580,16 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
512
580
|
// Restore the placeholder so the held-Backspace iOS path stays
|
|
513
581
|
// primed for the next focus. Also clears any leftover screen-reader
|
|
514
582
|
// text (xterm.js convention).
|
|
515
|
-
|
|
583
|
+
resetToPlaceholder(input);
|
|
516
584
|
termWrap.classList.remove("focus");
|
|
517
585
|
});
|
|
518
586
|
|
|
519
587
|
// The textarea is the single keyboard target (desktop and touch alike);
|
|
520
|
-
//
|
|
588
|
+
// .term-output is display-only and never focused.
|
|
521
589
|
input.addEventListener("keydown", handleKeydown);
|
|
522
590
|
|
|
523
591
|
// --- Focus strategy ---
|
|
524
|
-
// One element, one job.
|
|
592
|
+
// One element, one job. .term-output is display + native selection only
|
|
525
593
|
// and is NEVER focused; the textarea owns the keyboard, the local typing
|
|
526
594
|
// buffer, and IME. Because the editable element is never the scroll
|
|
527
595
|
// content, the first touch-drag scrolls instead of placing a caret and a
|
|
@@ -557,6 +625,11 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
557
625
|
if (e.pointerType !== "touch") {
|
|
558
626
|
return; // mouse/trackpad handled by the click listener below
|
|
559
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
|
+
}
|
|
560
633
|
const dx = Math.abs(e.clientX - pointerDownX);
|
|
561
634
|
const dy = Math.abs(e.clientY - pointerDownY);
|
|
562
635
|
if (dx > TAP_MOVEMENT_PX || dy > TAP_MOVEMENT_PX) {
|
|
@@ -568,15 +641,17 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
568
641
|
}
|
|
569
642
|
// Synchronous focus inside pointerup keeps us inside iOS's
|
|
570
643
|
// user-gesture window even when the streaming flush queue is busy.
|
|
571
|
-
|
|
644
|
+
focusTerminal();
|
|
572
645
|
},
|
|
573
646
|
{ passive: true },
|
|
574
647
|
);
|
|
575
648
|
termWrap.addEventListener("click", (e) => {
|
|
576
|
-
// Terminal links: URLs
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
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.
|
|
580
655
|
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(".term-link");
|
|
581
656
|
if (link) {
|
|
582
657
|
e.preventDefault();
|
|
@@ -623,15 +698,34 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
623
698
|
|
|
624
699
|
// Only wait for the Regular weight — it determines cell size.
|
|
625
700
|
// Bold/Italic load lazily when first used; style pop is barely noticeable.
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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 => {
|
|
630
706
|
fontsLoaded = true;
|
|
707
|
+
if (firstFrameRendered) {
|
|
708
|
+
markReady();
|
|
709
|
+
}
|
|
631
710
|
requestAnimationFrame(() => {
|
|
632
711
|
maybeSendFirstResize();
|
|
633
712
|
});
|
|
634
|
-
}
|
|
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
|
+
}
|
|
635
729
|
|
|
636
730
|
// Connect immediately — the WS open triggers maybeSendFirstResize.
|
|
637
731
|
render.updateFontMetrics();
|
|
@@ -753,6 +847,15 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
|
|
|
753
847
|
hideCtxMenu();
|
|
754
848
|
});
|
|
755
849
|
|
|
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();
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
|
|
756
859
|
// --- Mobile key toolbar ---
|
|
757
860
|
// The engine's keyboard.bindMobileToolbar wires the collapse toggle, the
|
|
758
861
|
// arrows (DECCKM-aware — SS3 under application-cursor mode, matching
|
package/src/predict.ts
CHANGED
|
@@ -48,6 +48,8 @@ export function setDimensions(c: number, r: number): void {
|
|
|
48
48
|
if (r > 0) {
|
|
49
49
|
rows = r;
|
|
50
50
|
}
|
|
51
|
+
const prevCol = predCol;
|
|
52
|
+
const prevRow = predRow;
|
|
51
53
|
// Clamp predicted position to new bounds so a resize mid-typing
|
|
52
54
|
// doesn't leave predCol/predRow past the edge.
|
|
53
55
|
if (predCol >= cols) {
|
|
@@ -56,6 +58,12 @@ export function setDimensions(c: number, r: number): void {
|
|
|
56
58
|
if (predRow >= rows) {
|
|
57
59
|
predRow = rows - 1;
|
|
58
60
|
}
|
|
61
|
+
// Notify subscribers when the clamp moved the predicted cursor, so the
|
|
62
|
+
// renderer's overlay doesn't show a stale (possibly off-screen) position
|
|
63
|
+
// until the next server frame arrives.
|
|
64
|
+
if (predCol !== prevCol || predRow !== prevRow) {
|
|
65
|
+
onChange?.();
|
|
66
|
+
}
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
/** Reset all prediction state. Called on server restart to avoid
|
|
@@ -66,9 +74,7 @@ export function reset(): void {
|
|
|
66
74
|
predActive = false;
|
|
67
75
|
predPendingWrap = false;
|
|
68
76
|
predFrozen = false;
|
|
69
|
-
|
|
70
|
-
onChange();
|
|
71
|
-
}
|
|
77
|
+
onChange?.();
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/** Reset prediction to the server's reported cursor and re-arm.
|
|
@@ -81,9 +87,7 @@ export function onScreenFrame(serverRow: number, serverCol: number, cursorHidden
|
|
|
81
87
|
predPendingWrap = false;
|
|
82
88
|
predFrozen = cursorHidden ?? false;
|
|
83
89
|
predActive = !predFrozen;
|
|
84
|
-
|
|
85
|
-
onChange();
|
|
86
|
-
}
|
|
90
|
+
onChange?.();
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
/** Apply locally-typed input bytes to the predicted cursor. Bails out
|
|
@@ -100,9 +104,7 @@ export function applyInput(bytes: Uint8Array): void {
|
|
|
100
104
|
if (b === 0x1b) {
|
|
101
105
|
// ESC — start of an escape sequence. Don't try to model it.
|
|
102
106
|
predActive = false;
|
|
103
|
-
|
|
104
|
-
onChange();
|
|
105
|
-
}
|
|
107
|
+
onChange?.();
|
|
106
108
|
return;
|
|
107
109
|
}
|
|
108
110
|
if (b === 0x08 || b === 0x7f) {
|
|
@@ -137,9 +139,7 @@ export function applyInput(bytes: Uint8Array): void {
|
|
|
137
139
|
if (b < 0x20) {
|
|
138
140
|
// Other C0 — probably interpreted by the application; bail.
|
|
139
141
|
predActive = false;
|
|
140
|
-
|
|
141
|
-
onChange();
|
|
142
|
-
}
|
|
142
|
+
onChange?.();
|
|
143
143
|
return;
|
|
144
144
|
}
|
|
145
145
|
// Printable ASCII or UTF-8 lead byte. Detect the codepoint length
|
|
@@ -169,9 +169,7 @@ export function applyInput(bytes: Uint8Array): void {
|
|
|
169
169
|
predCol++;
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
|
-
|
|
173
|
-
onChange();
|
|
174
|
-
}
|
|
172
|
+
onChange?.();
|
|
175
173
|
}
|
|
176
174
|
|
|
177
175
|
/** Current predicted cursor state. `active` is false when prediction
|
package/src/status.ts
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
1
|
-
// Connection-state banner. Shows "Reconnecting…" / "Offline" / "
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Connection-state banner. Shows "Reconnecting…" / "Offline" / "Server
|
|
2
|
+
// restarted" feedback so users on flaky links understand why typing is
|
|
3
|
+
// unresponsive. Hidden by default; appears only when the connection
|
|
4
|
+
// state diverges from "open" for more than a brief window (debounced to
|
|
5
|
+
// avoid flicker on healthy networks). A "slow connection" indicator is
|
|
6
|
+
// intentionally not implemented (see the state machine below).
|
|
6
7
|
//
|
|
7
|
-
// State machine:
|
|
8
|
-
// open
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
8
|
+
// State machine (matches the `State` union below):
|
|
9
|
+
// open → no banner.
|
|
10
|
+
// connecting → "Reconnecting…" (initial connect, before first open).
|
|
11
|
+
// reconnecting → "Reconnecting…" (after a close, during retry);
|
|
12
|
+
// escalates to offline after >3 consecutive failures.
|
|
13
|
+
// offline → "Offline".
|
|
14
|
+
// restarted → "Server restarted — recent input may have been lost".
|
|
15
|
+
// A "slow connection" indicator is intentionally not implemented: it would
|
|
16
|
+
// need an RTT signal the engine does not currently expose to consumers.
|
|
15
17
|
|
|
16
18
|
const CONNECTING_GRACE_MS = 600;
|
|
17
19
|
|
|
20
|
+
// Consecutive failed *initial* connection attempts (before the first screen
|
|
21
|
+
// frame, i.e. while `loaded` is still false) after which the "Offline" banner
|
|
22
|
+
// is allowed to render anyway. Below this we stay silent and defer to the
|
|
23
|
+
// consumer's loading overlay, so a merely-slow first connect raises no false
|
|
24
|
+
// alarm; this many failures in a row means the connection genuinely isn't
|
|
25
|
+
// coming up and an indefinite silent "Loading…" would be worse than "Offline".
|
|
26
|
+
// Aligned with the post-load escalation threshold (consecutiveFailures > 3).
|
|
27
|
+
const INITIAL_FAILURE_LIMIT = 4;
|
|
28
|
+
|
|
18
29
|
type State = "open" | "connecting" | "reconnecting" | "offline" | "restarted";
|
|
19
30
|
|
|
20
31
|
let banner: HTMLElement | null = null;
|
|
@@ -26,6 +37,13 @@ let consecutiveFailures = 0;
|
|
|
26
37
|
// reconnect/offline banner so the two don't stack — this replaces the old
|
|
27
38
|
// `document.getElementById("loading")` probe.
|
|
28
39
|
let loaded = false;
|
|
40
|
+
// Optional callback supplied by init(): invoked once the initial-connect
|
|
41
|
+
// failure limit is reached while still !loaded, so mount can fade its opaque
|
|
42
|
+
// loading overlay and let the "Offline" banner underneath become visible.
|
|
43
|
+
// mount always supplies this (its dismissLoadingOverlay self-guards when no
|
|
44
|
+
// overlay was passed), so onGiveUp is null only when init() is called without
|
|
45
|
+
// the callback (e.g. the status unit tests).
|
|
46
|
+
let onGiveUp: (() => void) | null = null;
|
|
29
47
|
|
|
30
48
|
/** Mark the connection as open. Hides the banner. */
|
|
31
49
|
export function open(): void {
|
|
@@ -51,9 +69,21 @@ export function reconnecting(): void {
|
|
|
51
69
|
/** Mark the connection as closed; shows banner after grace period. */
|
|
52
70
|
export function closed(): void {
|
|
53
71
|
consecutiveFailures++;
|
|
54
|
-
|
|
72
|
+
// Before the first frame, stay suppressed (the loading overlay owns the
|
|
73
|
+
// screen) UNTIL we've failed to connect INITIAL_FAILURE_LIMIT times in a
|
|
74
|
+
// row — at that point the connection clearly isn't coming up, so let the
|
|
75
|
+
// "Offline" banner through rather than leave a silent "Loading…" forever.
|
|
76
|
+
if (!loaded && consecutiveFailures < INITIAL_FAILURE_LIMIT) {
|
|
55
77
|
return;
|
|
56
78
|
}
|
|
79
|
+
// Past the initial-failure limit while the loading overlay is still up: tell
|
|
80
|
+
// mount to fade it so the "Offline" banner below becomes visible rather than
|
|
81
|
+
// staying occluded behind the opaque overlay (which markReady would otherwise
|
|
82
|
+
// never remove on a never-connecting socket). Idempotent, so firing on each
|
|
83
|
+
// subsequent !loaded close is safe.
|
|
84
|
+
if (!loaded) {
|
|
85
|
+
onGiveUp?.();
|
|
86
|
+
}
|
|
57
87
|
setState(consecutiveFailures > 3 ? "offline" : "reconnecting", CONNECTING_GRACE_MS);
|
|
58
88
|
}
|
|
59
89
|
|
|
@@ -116,15 +146,17 @@ function dismissToast(): void {
|
|
|
116
146
|
if (!banner) {
|
|
117
147
|
return;
|
|
118
148
|
}
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
149
|
+
// Restore the live connection state. applyState() hides the banner when
|
|
150
|
+
// "open" (identical to the old clear) and re-renders "Offline"/"Reconnecting…"
|
|
151
|
+
// when a toast was shown over a non-open status, so the status is never stranded.
|
|
152
|
+
if (banner.dataset["state"] === "toast") {
|
|
153
|
+
applyState();
|
|
123
154
|
}
|
|
124
155
|
}
|
|
125
156
|
|
|
126
|
-
export function init(opts: { banner: HTMLElement }): void {
|
|
157
|
+
export function init(opts: { banner: HTMLElement; onGiveUp?: () => void }): void {
|
|
127
158
|
banner = opts.banner;
|
|
159
|
+
onGiveUp = opts.onGiveUp ?? null;
|
|
128
160
|
applyState();
|
|
129
161
|
|
|
130
162
|
// Hover-pause: stop the auto-dismiss timer while the user hovers
|
|
@@ -160,6 +192,17 @@ function applyState(): void {
|
|
|
160
192
|
if (!banner) {
|
|
161
193
|
return;
|
|
162
194
|
}
|
|
195
|
+
// Stay fully suppressed until the initial load is over (matches the
|
|
196
|
+
// `loaded` guard in reconnecting()/closed()): the loading overlay, when
|
|
197
|
+
// present, owns the screen until then and a bare page has nothing to show.
|
|
198
|
+
// Exception: once INITIAL_FAILURE_LIMIT consecutive initial connects have
|
|
199
|
+
// failed, let "Offline" through even while !loaded so a never-connecting
|
|
200
|
+
// page doesn't sit on a silent "Loading…" indefinitely.
|
|
201
|
+
if (!loaded && !(state === "offline" && consecutiveFailures >= INITIAL_FAILURE_LIMIT)) {
|
|
202
|
+
banner.classList.remove("visible");
|
|
203
|
+
banner.textContent = "";
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
163
206
|
switch (state) {
|
|
164
207
|
case "open":
|
|
165
208
|
banner.classList.remove("visible");
|