@cplieger/web-terminal-ui 7.0.4 → 7.0.5
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 +5 -5
- package/src/composition.ts +69 -1
- package/src/kernel/kernel.ts +12 -4
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.5",
|
|
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",
|
|
@@ -50,21 +50,21 @@
|
|
|
50
50
|
"@stryker-mutator/core": "10.0.0",
|
|
51
51
|
"@stryker-mutator/typescript-checker": "10.0.0",
|
|
52
52
|
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
53
|
-
"@types/node": "26.4.
|
|
53
|
+
"@types/node": "26.4.1",
|
|
54
54
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
55
55
|
"@vitest/browser": "4.1.11",
|
|
56
56
|
"@vitest/browser-playwright": "4.1.11",
|
|
57
57
|
"@vitest/coverage-v8": "4.1.11",
|
|
58
58
|
"eslint": "10.9.1",
|
|
59
59
|
"fast-check": "4.9.0",
|
|
60
|
-
"html-validate": "11.
|
|
61
|
-
"knip": "6.
|
|
60
|
+
"html-validate": "11.12.0",
|
|
61
|
+
"knip": "6.34.0",
|
|
62
62
|
"playwright": "1.62.1",
|
|
63
63
|
"prettier": "3.9.6",
|
|
64
64
|
"stylelint": "17.14.1",
|
|
65
65
|
"stylelint-config-standard": "40.0.0",
|
|
66
66
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
67
|
-
"typescript-eslint": "8.
|
|
67
|
+
"typescript-eslint": "8.69.0",
|
|
68
68
|
"vitest": "4.1.11"
|
|
69
69
|
},
|
|
70
70
|
"publishConfig": {
|
package/src/composition.ts
CHANGED
|
@@ -39,6 +39,31 @@ let sendingComposition = false;
|
|
|
39
39
|
let compositionStart = 0;
|
|
40
40
|
let compositionSuffix = "";
|
|
41
41
|
|
|
42
|
+
// A soft keyboard may announce its composition only at COMMIT time: the
|
|
43
|
+
// per-character `input` events arrive with no composition in progress, the
|
|
44
|
+
// kernel sends each keystroke as typed, and then the keyboard fires
|
|
45
|
+
// compositionstart + compositionend back-to-back on the committing key while
|
|
46
|
+
// writing the WHOLE accumulated word into the textarea. The deferred read below
|
|
47
|
+
// then sends that word a second time — reported on Android Chrome with
|
|
48
|
+
// SwiftKey, where typing "hello" and pressing space produced "hellohello ".
|
|
49
|
+
//
|
|
50
|
+
// The terminal has already been given the literal keystrokes and cannot be
|
|
51
|
+
// asked to retract them, so a commit that only repeats them is a no-op.
|
|
52
|
+
// `noteDirectSend` records what the kernel sent with no composition running;
|
|
53
|
+
// the finaliser drops a commit that matches it.
|
|
54
|
+
//
|
|
55
|
+
// Unreachable on a keyboard that announces its composition BEFORE the
|
|
56
|
+
// keystrokes (every desktop IME, iOS, Android's own keyboard): there the
|
|
57
|
+
// kernel's `input` listener returns early while composing, so nothing is
|
|
58
|
+
// recorded and there is nothing for a commit to match.
|
|
59
|
+
const ECHO_TAIL_MAX = 64;
|
|
60
|
+
const ECHO_WINDOW_MS = 1500;
|
|
61
|
+
const ECHO_MAX_COMPOSE_MS = 60;
|
|
62
|
+
|
|
63
|
+
let directTail = "";
|
|
64
|
+
let directTailAt = 0;
|
|
65
|
+
let compositionStartedAt: number | null = null;
|
|
66
|
+
|
|
42
67
|
export function init(opts: {
|
|
43
68
|
textarea: HTMLTextAreaElement;
|
|
44
69
|
compositionView: HTMLElement;
|
|
@@ -67,6 +92,42 @@ export function isComposing(): boolean {
|
|
|
67
92
|
return composing || sendingComposition;
|
|
68
93
|
}
|
|
69
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;
|
|
102
|
+
}
|
|
103
|
+
const now = Date.now();
|
|
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 === "") {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
// A composition the user typed INTO spans human time; a commit-time
|
|
117
|
+
// announcement starts and ends inside one keystroke.
|
|
118
|
+
if (startedAt !== null && endedAt - startedAt > ECHO_MAX_COMPOSE_MS) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
// Only the burst still being typed can be a re-commit of itself.
|
|
122
|
+
if (endedAt - directTailAt > ECHO_WINDOW_MS) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
// The committing key (space, tab) is delivered as its own `input` event, so
|
|
126
|
+
// what has to match is the WORD the keyboard is re-committing.
|
|
127
|
+
const word = composed.replace(/[ \t]+$/u, "");
|
|
128
|
+
return word !== "" && directTail.endsWith(word);
|
|
129
|
+
}
|
|
130
|
+
|
|
70
131
|
/** Abort any in-flight composition without sending, and clear the textarea.
|
|
71
132
|
* The kernel calls this on a tab switch (detach): committing half-composed IME
|
|
72
133
|
* text to either the outgoing or the incoming session is wrong, so we discard
|
|
@@ -77,6 +138,8 @@ export function isComposing(): boolean {
|
|
|
77
138
|
export function cancelComposition(): void {
|
|
78
139
|
composing = false;
|
|
79
140
|
sendingComposition = false;
|
|
141
|
+
compositionStartedAt = null;
|
|
142
|
+
directTail = "";
|
|
80
143
|
compositionView.textContent = "";
|
|
81
144
|
compositionView.classList.remove("active");
|
|
82
145
|
resetToPlaceholder(textarea);
|
|
@@ -104,6 +167,7 @@ export function teardown(): void {
|
|
|
104
167
|
|
|
105
168
|
function onStart(): void {
|
|
106
169
|
composing = true;
|
|
170
|
+
compositionStartedAt = Date.now();
|
|
107
171
|
const start = textarea.selectionStart;
|
|
108
172
|
const end = textarea.selectionEnd;
|
|
109
173
|
compositionStart = Math.min(start, end);
|
|
@@ -131,6 +195,9 @@ function onEnd(): void {
|
|
|
131
195
|
sendingComposition = true;
|
|
132
196
|
const startSnapshot = compositionStart;
|
|
133
197
|
const suffixSnapshot = compositionSuffix;
|
|
198
|
+
const startedAt = compositionStartedAt;
|
|
199
|
+
const endedAt = Date.now();
|
|
200
|
+
compositionStartedAt = null;
|
|
134
201
|
setTimeout(() => {
|
|
135
202
|
if (!sendingComposition) {
|
|
136
203
|
return;
|
|
@@ -144,9 +211,10 @@ function onEnd(): void {
|
|
|
144
211
|
const composed = value
|
|
145
212
|
.substring(startSnapshot, Math.max(startSnapshot, valueEnd))
|
|
146
213
|
.replace(/\u00A0/g, " ");
|
|
147
|
-
if (composed.length > 0) {
|
|
214
|
+
if (composed.length > 0 && !repeatsDirectSends(composed, startedAt, endedAt)) {
|
|
148
215
|
send(composed);
|
|
149
216
|
}
|
|
217
|
+
directTail = "";
|
|
150
218
|
resetToPlaceholder(textarea);
|
|
151
219
|
}, 0);
|
|
152
220
|
}
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -390,6 +390,14 @@ function buildTerminal(
|
|
|
390
390
|
function sendText(text: string): void {
|
|
391
391
|
sendBytes(encoder.encode(text));
|
|
392
392
|
}
|
|
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
|
+
}
|
|
393
401
|
function paste(text: string): void {
|
|
394
402
|
// The one bracketed-paste + newline-normalize funnel; every feature and the
|
|
395
403
|
// kernel's own paste paths route through here (paste-jacking defense).
|
|
@@ -763,14 +771,14 @@ function buildTerminal(
|
|
|
763
771
|
paste(ev.data);
|
|
764
772
|
} else {
|
|
765
773
|
// Normalize iOS's NBSP-for-space quirk, then send through the funnel.
|
|
766
|
-
|
|
774
|
+
sendTyped(ev.data);
|
|
767
775
|
}
|
|
768
776
|
} else {
|
|
769
777
|
const v = input.value;
|
|
770
778
|
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
771
|
-
|
|
779
|
+
sendTyped(v.slice(INPUT_PLACEHOLDER.length));
|
|
772
780
|
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
773
|
-
|
|
781
|
+
sendTyped(v);
|
|
774
782
|
}
|
|
775
783
|
}
|
|
776
784
|
resetToPlaceholder(input);
|
|
@@ -1171,7 +1179,7 @@ function buildTerminal(
|
|
|
1171
1179
|
// an uncanceled keydown's insertion target after a listener moved focus
|
|
1172
1180
|
// mid-dispatch. Chromium does; no specification requires it.
|
|
1173
1181
|
ev.preventDefault();
|
|
1174
|
-
|
|
1182
|
+
sendTyped(char);
|
|
1175
1183
|
return;
|
|
1176
1184
|
}
|
|
1177
1185
|
applyMappedKey(ev, result);
|