@cplieger/web-terminal-ui 7.0.5 → 7.0.7
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 +13 -2
- package/css/10-primitives.css +50 -4
- package/package.json +9 -9
- package/src/composition.ts +80 -71
- package/src/kernel/kernel.ts +96 -38
package/README.md
CHANGED
|
@@ -358,8 +358,19 @@ completed feature and core listener, clears the broken subtree, and shows the
|
|
|
358
358
|
panel. If `createTerminal` itself throws (`phase: "kernel-init"`: an
|
|
359
359
|
unresolvable mount selector, a preset that throws, an invalid feature list, a DOM
|
|
360
360
|
invariant), it shows the same panel and then rethrows, so a caller with its own
|
|
361
|
-
error handling still sees the error.
|
|
362
|
-
|
|
361
|
+
error handling still sees the error.
|
|
362
|
+
|
|
363
|
+
The panel is a native `<dialog>`. In `viewport` layout it opens with
|
|
364
|
+
`showModal()`, so the rest of your document is inert while it is up; in
|
|
365
|
+
`container` layout it opens non-modally, so the application around an embedded
|
|
366
|
+
terminal keeps working and keeps its focusables. Escape does not dismiss it in
|
|
367
|
+
either mode: the terminal is already gone, so there would be nothing behind it
|
|
368
|
+
and no way back. Reload is the recovery.
|
|
369
|
+
|
|
370
|
+
One visible consequence in `viewport` layout: a modal dialog is in the browser's
|
|
371
|
+
top layer, above every `z-index` in the document, so the panel now paints over
|
|
372
|
+
your loading overlay instead of under it. It appears at once and the overlay's
|
|
373
|
+
fade-out finishes behind it.
|
|
363
374
|
|
|
364
375
|
**This is why `target` takes a selector and `features` takes a function.** Both
|
|
365
376
|
are resolved INSIDE that boundary. Written the other way round
|
package/css/10-primitives.css
CHANGED
|
@@ -133,13 +133,26 @@
|
|
|
133
133
|
Feature setup is part of the kernel composition contract. If it fails, the
|
|
134
134
|
kernel removes the half-live terminal and fills only its own layout boundary
|
|
135
135
|
with this recovery surface. In container mode that leaves the host app
|
|
136
|
-
outside the terminal fully visible and interactive.
|
|
136
|
+
outside the terminal fully visible and interactive.
|
|
137
|
+
|
|
138
|
+
The surface is a native <dialog>: showModal() in wt-viewport, the bare `open`
|
|
139
|
+
attribute in wt-container. Modality is the only difference between them. */
|
|
137
140
|
:where(.wt-root) .wt-fatal {
|
|
138
141
|
position: absolute;
|
|
139
142
|
inset: 0;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
+
/* The size resets are what let the insets stretch the panel: a <dialog> is
|
|
144
|
+
fit-content with the UA sheet's own `margin: auto`, and :modal additionally
|
|
145
|
+
caps both axes at calc(100% - 38px). margin, border-radius and box-shadow
|
|
146
|
+
answer an EMBEDDER's own `dialog { ... }` base rule rather than the UA sheet —
|
|
147
|
+
margin is the member that moves the box, the other two are cosmetic. */
|
|
148
|
+
margin: 0;
|
|
149
|
+
border: none;
|
|
150
|
+
border-radius: 0;
|
|
151
|
+
box-shadow: none;
|
|
152
|
+
inline-size: auto;
|
|
153
|
+
block-size: auto;
|
|
154
|
+
max-inline-size: none;
|
|
155
|
+
max-block-size: none;
|
|
143
156
|
overflow: auto;
|
|
144
157
|
padding: calc(var(--safe-top) + var(--sp-3)) var(--sp-3) calc(var(--safe-bottom) + var(--sp-3));
|
|
145
158
|
background: var(--bg);
|
|
@@ -148,6 +161,39 @@
|
|
|
148
161
|
text-align: center;
|
|
149
162
|
}
|
|
150
163
|
|
|
164
|
+
/* Scoped to [open]: an author `display` beats the UA's `dialog { display: none }`
|
|
165
|
+
at any specificity, so declared unconditionally a CLOSED panel would still
|
|
166
|
+
paint its full-surface background over the root. */
|
|
167
|
+
:where(.wt-root) .wt-fatal[open] {
|
|
168
|
+
display: grid;
|
|
169
|
+
place-items: center;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* The base rule's `position: absolute` serves the container half (it resolves
|
|
173
|
+
against .wt-root.wt-container, the embedder's box); this gives the viewport back
|
|
174
|
+
to the modal half. A top-layer absolute resolves against the initial containing
|
|
175
|
+
block — viewport-SIZED but canvas-anchored, so equivalent to fixed only at
|
|
176
|
+
scrollY 0 — and Blink does not lock document scrolling under a modal dialog.
|
|
177
|
+
Pinned by fatal-panel.test.ts's scrolled-document test. */
|
|
178
|
+
:where(.wt-root) .wt-fatal:modal {
|
|
179
|
+
position: fixed;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* The stacking order the top layer supplies for free, so it is declared only for
|
|
183
|
+
the half that is not in it. .wt-root.wt-container is the EMBEDDER's element and
|
|
184
|
+
forms no stacking context, so the panel competes in the host's. */
|
|
185
|
+
:where(.wt-root) .wt-fatal:not(:modal) {
|
|
186
|
+
z-index: 100;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* The panel IS the dim — it fills its whole surface in --bg — so the backdrop
|
|
190
|
+
agrees with it instead of adding a second, differently coloured layer. The UA's
|
|
191
|
+
rgba(0, 0, 0, 0.1) would show as a lighter seam if the panel were ever smaller
|
|
192
|
+
than the viewport. */
|
|
193
|
+
:where(.wt-root) .wt-fatal::backdrop {
|
|
194
|
+
background: var(--bg);
|
|
195
|
+
}
|
|
196
|
+
|
|
151
197
|
:where(.wt-root) .wt-fatal-card {
|
|
152
198
|
inline-size: min(100%, 28rem);
|
|
153
199
|
padding: clamp(1.25rem, 5vi, 2rem);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -52,20 +52,20 @@
|
|
|
52
52
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
53
53
|
"@types/node": "26.4.1",
|
|
54
54
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
55
|
-
"@vitest/browser": "
|
|
56
|
-
"@vitest/browser-playwright": "
|
|
57
|
-
"@vitest/coverage-v8": "
|
|
58
|
-
"eslint": "10.
|
|
55
|
+
"@vitest/browser": "5.0.0",
|
|
56
|
+
"@vitest/browser-playwright": "5.0.0",
|
|
57
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
58
|
+
"eslint": "10.10.0",
|
|
59
59
|
"fast-check": "4.9.0",
|
|
60
|
-
"html-validate": "11.
|
|
60
|
+
"html-validate": "11.14.0",
|
|
61
61
|
"knip": "6.34.0",
|
|
62
|
-
"playwright": "1.
|
|
62
|
+
"playwright": "1.63.0",
|
|
63
63
|
"prettier": "3.9.6",
|
|
64
|
-
"stylelint": "17.
|
|
64
|
+
"stylelint": "17.15.0",
|
|
65
65
|
"stylelint-config-standard": "40.0.0",
|
|
66
66
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
67
67
|
"typescript-eslint": "8.69.0",
|
|
68
|
-
"vitest": "
|
|
68
|
+
"vitest": "5.0.0"
|
|
69
69
|
},
|
|
70
70
|
"publishConfig": {
|
|
71
71
|
"access": "public"
|
package/src/composition.ts
CHANGED
|
@@ -39,30 +39,38 @@ let sendingComposition = false;
|
|
|
39
39
|
let compositionStart = 0;
|
|
40
40
|
let compositionSuffix = "";
|
|
41
41
|
|
|
42
|
-
// A
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
42
|
+
// A composition that never ENDS used to stop the terminal accepting input at
|
|
43
|
+
// all. `composing` gates both the kernel's `input` and `keydown` listeners, and
|
|
44
|
+
// nothing cleared it: no blur handler, no timer. So a keyboard that opened a
|
|
45
|
+
// composition and never closed it left every subsequent keystroke, Enter
|
|
46
|
+
// included, dropped on the floor — reported on Android Chrome with SwiftKey as
|
|
47
|
+
// "typing does not work", recoverable only by tapping a word suggestion, which
|
|
48
|
+
// is what finally fired compositionend.
|
|
49
49
|
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// the
|
|
50
|
+
// Not a SwiftKey quirk to special-case. Chromium has a filed defect for
|
|
51
|
+
// compositionend going missing (crbug 446714223, in EditContext rather than a
|
|
52
|
+
// textarea, so it is adjacent evidence and not proof of this exact path), and
|
|
53
|
+
// CodeMirror synthesises the event outright for Safari dead keys, which is a
|
|
54
|
+
// working implementation treating the same absence as real.
|
|
54
55
|
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
// The fix is a STALENESS READ rather than a timer. A composition the user is
|
|
57
|
+
// typing into keeps firing compositionupdate, so its clock keeps refreshing; an
|
|
58
|
+
// abandoned one stops and ages out, releasing the gate. UI Events promises one
|
|
59
|
+
// or more compositionupdate events and one whenever the DOM is updated as part
|
|
60
|
+
// of the composition, not strictly one per key, so this is a heuristic about
|
|
61
|
+
// activity rather than a guaranteed per-keystroke signal. Deliberately NOT
|
|
62
|
+
// refreshed by `input` events: in this failure the user IS typing and those
|
|
63
|
+
// events ARE arriving, so refreshing there would defeat the mechanism.
|
|
64
|
+
//
|
|
65
|
+
// Five seconds is the number Slate's android-input-manager uses for its own
|
|
66
|
+
// composition-gone-quiet bound (a DOM flush decision, not an input gate, so the
|
|
67
|
+
// value is borrowed and the job is not the same). It is generous because the
|
|
68
|
+
// residual risk runs the other way: a composition paused longer than this and
|
|
69
|
+
// then resumed can duplicate its first character. That trade is deliberate —
|
|
70
|
+
// the failure being replaced loses ALL input until an unrelated event fires.
|
|
71
|
+
const COMPOSITION_IDLE_MS = 5000;
|
|
62
72
|
|
|
63
|
-
let
|
|
64
|
-
let directTailAt = 0;
|
|
65
|
-
let compositionStartedAt: number | null = null;
|
|
73
|
+
let lastCompositionActivity = 0;
|
|
66
74
|
|
|
67
75
|
export function init(opts: {
|
|
68
76
|
textarea: HTMLTextAreaElement;
|
|
@@ -85,61 +93,61 @@ export function init(opts: {
|
|
|
85
93
|
textarea.addEventListener("paste", onPaste);
|
|
86
94
|
}
|
|
87
95
|
|
|
88
|
-
/** True while an IME composition is in progress
|
|
89
|
-
*
|
|
90
|
-
*
|
|
96
|
+
/** True while an IME composition is in progress, RECONCILED first: a composition
|
|
97
|
+
* whose clock has not been refreshed for COMPOSITION_IDLE_MS is expired here,
|
|
98
|
+
* clearing the latch, the clock and the overlay, so a keyboard that never fires
|
|
99
|
+
* compositionend cannot strand the terminal or leave a stale panel on screen.
|
|
100
|
+
* A late compositionend still delivers its text — `onEnd` snapshots what it
|
|
101
|
+
* needs and does not require `composing` to have stayed true.
|
|
102
|
+
*
|
|
103
|
+
* `sendingComposition` is exempt: that window is bounded by a setTimeout(0)
|
|
104
|
+
* this module owns. */
|
|
91
105
|
export function isComposing(): boolean {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
/** Record text the kernel put on the wire with no composition running, so a
|
|
96
|
-
* keyboard that announces its composition only at commit time cannot have the
|
|
97
|
-
* same keystrokes delivered twice (see the ECHO_* note above). Typed text
|
|
98
|
-
* only — a paste is never re-committed by a keyboard. */
|
|
99
|
-
export function noteDirectSend(text: string): void {
|
|
100
|
-
if (text === "") {
|
|
101
|
-
return;
|
|
106
|
+
if (sendingComposition) {
|
|
107
|
+
return true;
|
|
102
108
|
}
|
|
103
|
-
|
|
104
|
-
if (now - directTailAt > ECHO_WINDOW_MS) {
|
|
105
|
-
directTail = "";
|
|
106
|
-
}
|
|
107
|
-
directTail = (directTail + text).slice(-ECHO_TAIL_MAX);
|
|
108
|
-
directTailAt = now;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Whether this commit merely repeats keystrokes already delivered literally. */
|
|
112
|
-
function repeatsDirectSends(composed: string, startedAt: number | null, endedAt: number): boolean {
|
|
113
|
-
if (directTail === "") {
|
|
109
|
+
if (!composing) {
|
|
114
110
|
return false;
|
|
115
111
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (startedAt !== null && endedAt - startedAt > ECHO_MAX_COMPOSE_MS) {
|
|
119
|
-
return false;
|
|
112
|
+
if (Date.now() - lastCompositionActivity <= COMPOSITION_IDLE_MS) {
|
|
113
|
+
return true;
|
|
120
114
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
115
|
+
expireComposition();
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The RAW latch: is a composition open at all, regardless of whether it has
|
|
120
|
+
* gone quiet. Read before the reconciling `isComposing()` by the keydown guard,
|
|
121
|
+
* because the keyCode 229 signal only means "an IME claimed this key" while a
|
|
122
|
+
* composition is genuinely open — on Android it is the value for nearly every
|
|
123
|
+
* soft-keyboard key. */
|
|
124
|
+
export function isCompositionOpen(): boolean {
|
|
125
|
+
return composing || sendingComposition;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Drop a composition that has gone quiet: the latch, the clock and the visible
|
|
129
|
+
* overlay together. Without the overlay half the gate would say no composition
|
|
130
|
+
* while `.composition-view.active` still said there was one, leaving a stale
|
|
131
|
+
* panel over the terminal for as long as the missing compositionend never
|
|
132
|
+
* arrived. Does NOT touch the textarea: a late compositionend still reads it. */
|
|
133
|
+
function expireComposition(): void {
|
|
134
|
+
composing = false;
|
|
135
|
+
lastCompositionActivity = 0;
|
|
136
|
+
compositionView.textContent = "";
|
|
137
|
+
compositionView.classList.remove("active");
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
/** Abort any in-flight composition without sending, and clear the textarea.
|
|
132
|
-
* The kernel calls this on a tab switch (detach): committing
|
|
133
|
-
* text to either the outgoing or the incoming session is
|
|
134
|
-
* it (design 5.1, "end any composition ... or cancel").
|
|
135
|
-
* sendingComposition also neutralizes a just-fired compositionend
|
|
136
|
-
* deferred send is still pending on the microtask/timeout queue, so it
|
|
137
|
-
* land on whoever is active after the switch. */
|
|
141
|
+
* The kernel calls this on a tab switch (detach) and on blur: committing
|
|
142
|
+
* half-composed IME text to either the outgoing or the incoming session is
|
|
143
|
+
* wrong, so we discard it (design 5.1, "end any composition ... or cancel").
|
|
144
|
+
* Clearing sendingComposition also neutralizes a just-fired compositionend
|
|
145
|
+
* whose deferred send is still pending on the microtask/timeout queue, so it
|
|
146
|
+
* cannot land on whoever is active after the switch. */
|
|
138
147
|
export function cancelComposition(): void {
|
|
139
148
|
composing = false;
|
|
140
149
|
sendingComposition = false;
|
|
141
|
-
|
|
142
|
-
directTail = "";
|
|
150
|
+
lastCompositionActivity = 0;
|
|
143
151
|
compositionView.textContent = "";
|
|
144
152
|
compositionView.classList.remove("active");
|
|
145
153
|
resetToPlaceholder(textarea);
|
|
@@ -167,7 +175,7 @@ export function teardown(): void {
|
|
|
167
175
|
|
|
168
176
|
function onStart(): void {
|
|
169
177
|
composing = true;
|
|
170
|
-
|
|
178
|
+
lastCompositionActivity = Date.now();
|
|
171
179
|
const start = textarea.selectionStart;
|
|
172
180
|
const end = textarea.selectionEnd;
|
|
173
181
|
compositionStart = Math.min(start, end);
|
|
@@ -179,6 +187,10 @@ function onStart(): void {
|
|
|
179
187
|
}
|
|
180
188
|
|
|
181
189
|
function onUpdate(ev: CompositionEvent): void {
|
|
190
|
+
// Every keystroke of a live composition lands here, which is what keeps its
|
|
191
|
+
// staleness clock fresh (see COMPOSITION_IDLE_MS). An abandoned composition
|
|
192
|
+
// stops firing this and ages out of the gate.
|
|
193
|
+
lastCompositionActivity = Date.now();
|
|
182
194
|
// \u200E (LTR mark) wrappers + CSS direction:rtl on the view make
|
|
183
195
|
// long compositions show their trailing edge instead of being
|
|
184
196
|
// clipped at the start. Pattern from xterm.js.
|
|
@@ -189,15 +201,13 @@ function onUpdate(ev: CompositionEvent): void {
|
|
|
189
201
|
function onEnd(): void {
|
|
190
202
|
compositionView.classList.remove("active");
|
|
191
203
|
composing = false;
|
|
204
|
+
lastCompositionActivity = 0;
|
|
192
205
|
// The compositionend event fires before the textarea reflects the
|
|
193
206
|
// final value on most browsers (Chromium especially). Defer one tick
|
|
194
207
|
// and read the textarea then; xterm.js's pattern.
|
|
195
208
|
sendingComposition = true;
|
|
196
209
|
const startSnapshot = compositionStart;
|
|
197
210
|
const suffixSnapshot = compositionSuffix;
|
|
198
|
-
const startedAt = compositionStartedAt;
|
|
199
|
-
const endedAt = Date.now();
|
|
200
|
-
compositionStartedAt = null;
|
|
201
211
|
setTimeout(() => {
|
|
202
212
|
if (!sendingComposition) {
|
|
203
213
|
return;
|
|
@@ -211,10 +221,9 @@ function onEnd(): void {
|
|
|
211
221
|
const composed = value
|
|
212
222
|
.substring(startSnapshot, Math.max(startSnapshot, valueEnd))
|
|
213
223
|
.replace(/\u00A0/g, " ");
|
|
214
|
-
if (composed.length > 0
|
|
224
|
+
if (composed.length > 0) {
|
|
215
225
|
send(composed);
|
|
216
226
|
}
|
|
217
|
-
directTail = "";
|
|
218
227
|
resetToPlaceholder(textarea);
|
|
219
228
|
}, 0);
|
|
220
229
|
}
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -120,6 +120,16 @@ function normalizeTypedText(text: string): string {
|
|
|
120
120
|
return text.replace(/\u00A0/g, " ");
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** `KeyboardEvent.keyCode` for a key an IME has claimed. Windows' VK_PROCESS,
|
|
124
|
+
* reported by Blink and WebKit for the key that commits a composition, and by
|
|
125
|
+
* Android for essentially every soft-keyboard key — which is why every read of
|
|
126
|
+
* it is gated on a composition being open.
|
|
127
|
+
*
|
|
128
|
+
* Deprecated and nonetheless the only carrier of this fact: for that commit key
|
|
129
|
+
* both engines report `key` as the ordinary `"Enter"`, and Safari reports
|
|
130
|
+
* `isComposing` false, so neither the modern flag nor `key` distinguishes it. */
|
|
131
|
+
const IME_COMMIT_KEYCODE = 229;
|
|
132
|
+
|
|
123
133
|
function pick(root: ParentNode, selector: string): HTMLElement {
|
|
124
134
|
const el = root.querySelector<HTMLElement>(selector);
|
|
125
135
|
if (!el) {
|
|
@@ -158,17 +168,18 @@ function renderFatalStartupInto(
|
|
|
158
168
|
layoutMode: "viewport" | "container",
|
|
159
169
|
message: string = STARTUP_FAILURE_COPY.message,
|
|
160
170
|
): void {
|
|
161
|
-
const surface = document.createElement("
|
|
171
|
+
const surface = document.createElement("dialog");
|
|
162
172
|
surface.className = "wt-fatal";
|
|
173
|
+
// alertdialog over the implicit `dialog` role: an urgent message to act on. No
|
|
174
|
+
// `aria-modal` — showModal() conveys modality; an authored value can contradict the UA.
|
|
163
175
|
surface.setAttribute("role", "alertdialog");
|
|
164
176
|
surface.setAttribute("aria-labelledby", "wt-fatal-title");
|
|
165
177
|
surface.setAttribute("aria-describedby", "wt-fatal-message");
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
178
|
+
// showModal() makes Escape a close request, and the kernel has already cleared
|
|
179
|
+
// the root: nothing is behind this panel, so reload is the only recovery.
|
|
180
|
+
surface.addEventListener("cancel", (ev) => {
|
|
181
|
+
ev.preventDefault();
|
|
182
|
+
});
|
|
172
183
|
|
|
173
184
|
const card = document.createElement("div");
|
|
174
185
|
card.className = "wt-fatal-card";
|
|
@@ -191,9 +202,19 @@ function renderFatalStartupInto(
|
|
|
191
202
|
surface.appendChild(card);
|
|
192
203
|
root.replaceChildren(surface);
|
|
193
204
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
205
|
+
// Viewport: the terminal IS the page, so the top layer and an inert document
|
|
206
|
+
// are both accurate; container gets the bare `open` attribute, which is neither
|
|
207
|
+
// and is what an embedded panel wants. `isConnected` is showModal()'s own
|
|
208
|
+
// precondition and a detached root arrives here from a typed call, where a
|
|
209
|
+
// throw would bury the real startup cause under a second error out of the catch.
|
|
210
|
+
if (layoutMode === "viewport" && surface.isConnected) {
|
|
211
|
+
surface.showModal();
|
|
212
|
+
} else {
|
|
213
|
+
surface.open = true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The terminal input held focus before setup failed; move it to the only
|
|
217
|
+
// recovery action. showModal() lands there itself, the non-modal half does not.
|
|
197
218
|
reloadButton.focus();
|
|
198
219
|
}
|
|
199
220
|
|
|
@@ -390,14 +411,6 @@ function buildTerminal(
|
|
|
390
411
|
function sendText(text: string): void {
|
|
391
412
|
sendBytes(encoder.encode(text));
|
|
392
413
|
}
|
|
393
|
-
/** A keystroke the user typed with no composition running. Recorded so a
|
|
394
|
-
* keyboard that announces its composition only at commit time cannot have
|
|
395
|
-
* the same characters delivered twice (composition.ts, the ECHO_* note). */
|
|
396
|
-
function sendTyped(text: string): void {
|
|
397
|
-
const out = normalizeTypedText(text);
|
|
398
|
-
sendText(out);
|
|
399
|
-
composition.noteDirectSend(out);
|
|
400
|
-
}
|
|
401
414
|
function paste(text: string): void {
|
|
402
415
|
// The one bracketed-paste + newline-normalize funnel; every feature and the
|
|
403
416
|
// kernel's own paste paths route through here (paste-jacking defense).
|
|
@@ -766,19 +779,43 @@ function buildTerminal(
|
|
|
766
779
|
) {
|
|
767
780
|
resetToPlaceholder(input);
|
|
768
781
|
return;
|
|
782
|
+
} else if (inputType === "insertReplacementText") {
|
|
783
|
+
// A spellcheck / autocorrect / autofill REWRITE of text already typed.
|
|
784
|
+
// The bytes it is correcting have already gone to the pty and a terminal
|
|
785
|
+
// cannot retract them: 0x7f means whatever the remote application decides
|
|
786
|
+
// it means, so a pager, vim, a y/n prompt and a no-echo password prompt
|
|
787
|
+
// each read it as their own input. Applying the rewrite forward instead
|
|
788
|
+
// duplicates — measured on xterm.js's value-diff, where `helo` corrected
|
|
789
|
+
// to `hello` puts `helohello` on the wire (its issue #3600). So drop it
|
|
790
|
+
// and leave the typed text standing, which is also what a shell wants:
|
|
791
|
+
// `cd /usr` silently becoming `cd /use` is worse than no autocorrect.
|
|
792
|
+
//
|
|
793
|
+
// The spec's own signal for this, so no length heuristic is needed: a
|
|
794
|
+
// max-one-character rule would break glide typing (a whole word in one
|
|
795
|
+
// event, and SwiftKey's flagship input method) while still admitting
|
|
796
|
+
// CJK's one-for-one in-place replacement. Cancelling is not an option
|
|
797
|
+
// either — preventDefault on beforeinput is a no-op in Chrome, per
|
|
798
|
+
// CodeMirror's own note — but declining to SEND is entirely ours.
|
|
799
|
+
//
|
|
800
|
+
// Reachable on more than Android: WebKit fires this type for a spelling
|
|
801
|
+
// suggestion and for a macOS system text substitution whatever
|
|
802
|
+
// autocorrect="off" says. Dropping is right wherever it fires, because
|
|
803
|
+
// what it carries is always a replacement for bytes the pty already has.
|
|
804
|
+
resetToPlaceholder(input);
|
|
805
|
+
return;
|
|
769
806
|
} else if (typeof ev.data === "string" && ev.data.length > 0) {
|
|
770
807
|
if (inputType === "insertFromPaste") {
|
|
771
808
|
paste(ev.data);
|
|
772
809
|
} else {
|
|
773
810
|
// Normalize iOS's NBSP-for-space quirk, then send through the funnel.
|
|
774
|
-
|
|
811
|
+
sendText(normalizeTypedText(ev.data));
|
|
775
812
|
}
|
|
776
813
|
} else {
|
|
777
814
|
const v = input.value;
|
|
778
815
|
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
779
|
-
|
|
816
|
+
sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
|
|
780
817
|
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
781
|
-
|
|
818
|
+
sendText(normalizeTypedText(v));
|
|
782
819
|
}
|
|
783
820
|
}
|
|
784
821
|
resetToPlaceholder(input);
|
|
@@ -796,7 +833,12 @@ function buildTerminal(
|
|
|
796
833
|
input.addEventListener(
|
|
797
834
|
"blur",
|
|
798
835
|
() => {
|
|
799
|
-
resetToPlaceholder
|
|
836
|
+
// cancelComposition, not just resetToPlaceholder: `composing` gates both
|
|
837
|
+
// input listeners, so a composition abandoned by losing focus would
|
|
838
|
+
// otherwise keep the gate shut and the terminal would take no input when
|
|
839
|
+
// focus came back. The staleness read covers a keyboard that strands us
|
|
840
|
+
// while still focused; this covers the case with a definite signal.
|
|
841
|
+
composition.cancelComposition();
|
|
800
842
|
termWrap.classList.remove("focus");
|
|
801
843
|
},
|
|
802
844
|
{ signal },
|
|
@@ -846,6 +888,33 @@ function buildTerminal(
|
|
|
846
888
|
input.addEventListener(
|
|
847
889
|
"keydown",
|
|
848
890
|
(ev: KeyboardEvent) => {
|
|
891
|
+
// Three gates, and the order is the design. The spec signal first: UI
|
|
892
|
+
// Events requires a key event inside a composition session to carry
|
|
893
|
+
// isComposing, which covers every engine except one case.
|
|
894
|
+
//
|
|
895
|
+
// That case is the key which COMMITS a composition: Chrome and Safari both
|
|
896
|
+
// report keyCode 229 for it and Safari reports isComposing false (measured
|
|
897
|
+
// in quill PR #3445), so a second signal is needed. It is read against the
|
|
898
|
+
// RAW latch, before the reconciling isComposing() below, and both halves of
|
|
899
|
+
// that matter. Gated, because on Android 229 is the value for nearly every
|
|
900
|
+
// soft-keyboard key, so an unconditional check swallows ordinary Backspace
|
|
901
|
+
// and Enter — found in review after exactly that shipped here. Before the
|
|
902
|
+
// reconcile, because reconciling clears the latch, and a CJK user who left
|
|
903
|
+
// a candidate window open past the idle bound is still committing a real
|
|
904
|
+
// composition: taking this key as an ordinary Enter would send a carriage
|
|
905
|
+
// return before the composed phrase and execute a half-typed line.
|
|
906
|
+
//
|
|
907
|
+
// The consequence to know: in a stranded composition on Android, Enter and
|
|
908
|
+
// Backspace stay blocked until a character arrives (which reconciles the
|
|
909
|
+
// latch and releases them) or focus is lost. That is the deliberate side
|
|
910
|
+
// of the trade, because the alternative costs desktop and iOS correctness.
|
|
911
|
+
if (ev.isComposing) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- see above
|
|
915
|
+
if (composition.isCompositionOpen() && ev.keyCode === IME_COMMIT_KEYCODE) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
849
918
|
if (composition.isComposing()) {
|
|
850
919
|
return;
|
|
851
920
|
}
|
|
@@ -1179,7 +1248,7 @@ function buildTerminal(
|
|
|
1179
1248
|
// an uncanceled keydown's insertion target after a listener moved focus
|
|
1180
1249
|
// mid-dispatch. Chromium does; no specification requires it.
|
|
1181
1250
|
ev.preventDefault();
|
|
1182
|
-
|
|
1251
|
+
sendText(normalizeTypedText(char));
|
|
1183
1252
|
return;
|
|
1184
1253
|
}
|
|
1185
1254
|
applyMappedKey(ev, result);
|
|
@@ -1830,21 +1899,6 @@ function buildTerminal(
|
|
|
1830
1899
|
};
|
|
1831
1900
|
}
|
|
1832
1901
|
|
|
1833
|
-
/** Build the terminal UI inside `root`. Call exactly once.
|
|
1834
|
-
*
|
|
1835
|
-
* Wraps buildTerminal so that a SYNCHRONOUS startup failure gets the same
|
|
1836
|
-
* treatment an asynchronous one already got. Before this, the two halves of
|
|
1837
|
-
* "the terminal did not start" were handled inconsistently: an async
|
|
1838
|
-
* feature-setup rejection ran enterFatalStartup (overlay dismissed,
|
|
1839
|
-
* onFatalError delivered, recovery surface rendered), while a synchronous throw
|
|
1840
|
-
* propagated bare, leaving the consumer's loading overlay spinning forever over
|
|
1841
|
-
* a page with no terminal and no explanation. Consumers were left hand-building
|
|
1842
|
-
* their own surface to cover the gap, diverging from this one in shape and copy.
|
|
1843
|
-
*
|
|
1844
|
-
* The error still propagates: a consumer with its own handling sees it exactly
|
|
1845
|
-
* as before, only now against a page that is no longer stuck on a spinner. A
|
|
1846
|
-
* handler returning `true` suppresses the built-in surface, same contract as the
|
|
1847
|
-
* async phase. */
|
|
1848
1902
|
/** Resolve the mount target. A selector is looked up here, INSIDE
|
|
1849
1903
|
* createTerminal's try, which is the whole point: a consumer that passed
|
|
1850
1904
|
* `document.getElementById("terminal")` had to null-check it first (tsc forces
|
|
@@ -1901,6 +1955,10 @@ function createFallbackHost(): HTMLElement {
|
|
|
1901
1955
|
return host;
|
|
1902
1956
|
}
|
|
1903
1957
|
|
|
1958
|
+
/** Build the terminal UI inside `target`. Call exactly once. Wraps buildTerminal so
|
|
1959
|
+
* a SYNCHRONOUS startup failure gets the same recovery surface an async one does
|
|
1960
|
+
* and still propagates; that surface is modal in viewport layout, so
|
|
1961
|
+
* `onFatalError` returning `true` is the path that leaves the page interactive. */
|
|
1904
1962
|
export function createTerminal(
|
|
1905
1963
|
target: HTMLElement | string,
|
|
1906
1964
|
opts: CreateTerminalOptions = {},
|