@cplieger/web-terminal-ui 7.0.5 → 7.0.6
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/package.json +6 -6
- package/src/composition.ts +80 -71
- package/src/kernel/kernel.ts +71 -13
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.6",
|
|
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,12 +52,12 @@
|
|
|
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": "
|
|
55
|
+
"@vitest/browser": "5.0.0",
|
|
56
|
+
"@vitest/browser-playwright": "5.0.0",
|
|
57
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
58
58
|
"eslint": "10.9.1",
|
|
59
59
|
"fast-check": "4.9.0",
|
|
60
|
-
"html-validate": "11.
|
|
60
|
+
"html-validate": "11.14.0",
|
|
61
61
|
"knip": "6.34.0",
|
|
62
62
|
"playwright": "1.62.1",
|
|
63
63
|
"prettier": "3.9.6",
|
|
@@ -65,7 +65,7 @@
|
|
|
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) {
|
|
@@ -390,14 +400,6 @@ function buildTerminal(
|
|
|
390
400
|
function sendText(text: string): void {
|
|
391
401
|
sendBytes(encoder.encode(text));
|
|
392
402
|
}
|
|
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
403
|
function paste(text: string): void {
|
|
402
404
|
// The one bracketed-paste + newline-normalize funnel; every feature and the
|
|
403
405
|
// kernel's own paste paths route through here (paste-jacking defense).
|
|
@@ -766,19 +768,43 @@ function buildTerminal(
|
|
|
766
768
|
) {
|
|
767
769
|
resetToPlaceholder(input);
|
|
768
770
|
return;
|
|
771
|
+
} else if (inputType === "insertReplacementText") {
|
|
772
|
+
// A spellcheck / autocorrect / autofill REWRITE of text already typed.
|
|
773
|
+
// The bytes it is correcting have already gone to the pty and a terminal
|
|
774
|
+
// cannot retract them: 0x7f means whatever the remote application decides
|
|
775
|
+
// it means, so a pager, vim, a y/n prompt and a no-echo password prompt
|
|
776
|
+
// each read it as their own input. Applying the rewrite forward instead
|
|
777
|
+
// duplicates — measured on xterm.js's value-diff, where `helo` corrected
|
|
778
|
+
// to `hello` puts `helohello` on the wire (its issue #3600). So drop it
|
|
779
|
+
// and leave the typed text standing, which is also what a shell wants:
|
|
780
|
+
// `cd /usr` silently becoming `cd /use` is worse than no autocorrect.
|
|
781
|
+
//
|
|
782
|
+
// The spec's own signal for this, so no length heuristic is needed: a
|
|
783
|
+
// max-one-character rule would break glide typing (a whole word in one
|
|
784
|
+
// event, and SwiftKey's flagship input method) while still admitting
|
|
785
|
+
// CJK's one-for-one in-place replacement. Cancelling is not an option
|
|
786
|
+
// either — preventDefault on beforeinput is a no-op in Chrome, per
|
|
787
|
+
// CodeMirror's own note — but declining to SEND is entirely ours.
|
|
788
|
+
//
|
|
789
|
+
// Reachable on more than Android: WebKit fires this type for a spelling
|
|
790
|
+
// suggestion and for a macOS system text substitution whatever
|
|
791
|
+
// autocorrect="off" says. Dropping is right wherever it fires, because
|
|
792
|
+
// what it carries is always a replacement for bytes the pty already has.
|
|
793
|
+
resetToPlaceholder(input);
|
|
794
|
+
return;
|
|
769
795
|
} else if (typeof ev.data === "string" && ev.data.length > 0) {
|
|
770
796
|
if (inputType === "insertFromPaste") {
|
|
771
797
|
paste(ev.data);
|
|
772
798
|
} else {
|
|
773
799
|
// Normalize iOS's NBSP-for-space quirk, then send through the funnel.
|
|
774
|
-
|
|
800
|
+
sendText(normalizeTypedText(ev.data));
|
|
775
801
|
}
|
|
776
802
|
} else {
|
|
777
803
|
const v = input.value;
|
|
778
804
|
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
779
|
-
|
|
805
|
+
sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
|
|
780
806
|
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
781
|
-
|
|
807
|
+
sendText(normalizeTypedText(v));
|
|
782
808
|
}
|
|
783
809
|
}
|
|
784
810
|
resetToPlaceholder(input);
|
|
@@ -796,7 +822,12 @@ function buildTerminal(
|
|
|
796
822
|
input.addEventListener(
|
|
797
823
|
"blur",
|
|
798
824
|
() => {
|
|
799
|
-
resetToPlaceholder
|
|
825
|
+
// cancelComposition, not just resetToPlaceholder: `composing` gates both
|
|
826
|
+
// input listeners, so a composition abandoned by losing focus would
|
|
827
|
+
// otherwise keep the gate shut and the terminal would take no input when
|
|
828
|
+
// focus came back. The staleness read covers a keyboard that strands us
|
|
829
|
+
// while still focused; this covers the case with a definite signal.
|
|
830
|
+
composition.cancelComposition();
|
|
800
831
|
termWrap.classList.remove("focus");
|
|
801
832
|
},
|
|
802
833
|
{ signal },
|
|
@@ -846,6 +877,33 @@ function buildTerminal(
|
|
|
846
877
|
input.addEventListener(
|
|
847
878
|
"keydown",
|
|
848
879
|
(ev: KeyboardEvent) => {
|
|
880
|
+
// Three gates, and the order is the design. The spec signal first: UI
|
|
881
|
+
// Events requires a key event inside a composition session to carry
|
|
882
|
+
// isComposing, which covers every engine except one case.
|
|
883
|
+
//
|
|
884
|
+
// That case is the key which COMMITS a composition: Chrome and Safari both
|
|
885
|
+
// report keyCode 229 for it and Safari reports isComposing false (measured
|
|
886
|
+
// in quill PR #3445), so a second signal is needed. It is read against the
|
|
887
|
+
// RAW latch, before the reconciling isComposing() below, and both halves of
|
|
888
|
+
// that matter. Gated, because on Android 229 is the value for nearly every
|
|
889
|
+
// soft-keyboard key, so an unconditional check swallows ordinary Backspace
|
|
890
|
+
// and Enter — found in review after exactly that shipped here. Before the
|
|
891
|
+
// reconcile, because reconciling clears the latch, and a CJK user who left
|
|
892
|
+
// a candidate window open past the idle bound is still committing a real
|
|
893
|
+
// composition: taking this key as an ordinary Enter would send a carriage
|
|
894
|
+
// return before the composed phrase and execute a half-typed line.
|
|
895
|
+
//
|
|
896
|
+
// The consequence to know: in a stranded composition on Android, Enter and
|
|
897
|
+
// Backspace stay blocked until a character arrives (which reconciles the
|
|
898
|
+
// latch and releases them) or focus is lost. That is the deliberate side
|
|
899
|
+
// of the trade, because the alternative costs desktop and iOS correctness.
|
|
900
|
+
if (ev.isComposing) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- see above
|
|
904
|
+
if (composition.isCompositionOpen() && ev.keyCode === IME_COMMIT_KEYCODE) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
849
907
|
if (composition.isComposing()) {
|
|
850
908
|
return;
|
|
851
909
|
}
|
|
@@ -1179,7 +1237,7 @@ function buildTerminal(
|
|
|
1179
1237
|
// an uncanceled keydown's insertion target after a listener moved focus
|
|
1180
1238
|
// mid-dispatch. Chromium does; no specification requires it.
|
|
1181
1239
|
ev.preventDefault();
|
|
1182
|
-
|
|
1240
|
+
sendText(normalizeTypedText(char));
|
|
1183
1241
|
return;
|
|
1184
1242
|
}
|
|
1185
1243
|
applyMappedKey(ev, result);
|