@cplieger/web-terminal-ui 5.5.0 → 5.6.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/package.json +1 -1
- package/src/kernel/kernel.ts +240 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.6.0",
|
|
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",
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -91,6 +91,34 @@ const CORE_TEMPLATE = `
|
|
|
91
91
|
<div class="composition-view" aria-hidden="true"></div>
|
|
92
92
|
</div>`;
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The single character this key types, or null when it types none. Used to tell
|
|
96
|
+
* a character the engine's encoder deferred to the `input` event apart from a
|
|
97
|
+
* key whose `key` is a NAME rather than text.
|
|
98
|
+
*
|
|
99
|
+
* `codePointAt` consumes a surrogate pair whole, so a key whose first scalar is
|
|
100
|
+
* the whole string is exactly one character; "Shift", "Dead", "F5" and "Process"
|
|
101
|
+
* are not. Code points rather than grapheme clusters on purpose: a character key
|
|
102
|
+
* carries one code point, and a segmenter would also accept a multi-scalar
|
|
103
|
+
* cluster. UI Events does allow a key "associated with multiple characters"
|
|
104
|
+
* (macros, some dead-key sequences); those return null here and are simply not
|
|
105
|
+
* recovered, which is a stated limitation rather than a claim about key names.
|
|
106
|
+
*/
|
|
107
|
+
function typedChar(ev: KeyboardEvent): string | null {
|
|
108
|
+
const first = ev.key.codePointAt(0);
|
|
109
|
+
return first !== undefined && String.fromCodePoint(first) === ev.key ? ev.key : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Normalizes text on its way to the PTY: iOS sends U+00A0 where a space was
|
|
114
|
+
* typed. Shared by every send path so one physical key cannot produce two byte
|
|
115
|
+
* sequences depending on which listener caught it (the `input` event on the
|
|
116
|
+
* focused textarea, or the document-level type-to-focus fallback).
|
|
117
|
+
*/
|
|
118
|
+
function normalizeTypedText(text: string): string {
|
|
119
|
+
return text.replace(/\u00A0/g, " ");
|
|
120
|
+
}
|
|
121
|
+
|
|
94
122
|
function pick(root: ParentNode, selector: string): HTMLElement {
|
|
95
123
|
const el = root.querySelector<HTMLElement>(selector);
|
|
96
124
|
if (!el) {
|
|
@@ -712,14 +740,14 @@ function buildTerminal(
|
|
|
712
740
|
paste(ev.data);
|
|
713
741
|
} else {
|
|
714
742
|
// Normalize iOS's NBSP-for-space quirk, then send through the funnel.
|
|
715
|
-
sendText(ev.data
|
|
743
|
+
sendText(normalizeTypedText(ev.data));
|
|
716
744
|
}
|
|
717
745
|
} else {
|
|
718
746
|
const v = input.value;
|
|
719
747
|
if (v.length > INPUT_PLACEHOLDER.length && v.startsWith(INPUT_PLACEHOLDER)) {
|
|
720
|
-
sendText(v.slice(INPUT_PLACEHOLDER.length)
|
|
748
|
+
sendText(normalizeTypedText(v.slice(INPUT_PLACEHOLDER.length)));
|
|
721
749
|
} else if (v !== INPUT_PLACEHOLDER && v.length > 0) {
|
|
722
|
-
sendText(v
|
|
750
|
+
sendText(normalizeTypedText(v));
|
|
723
751
|
}
|
|
724
752
|
}
|
|
725
753
|
resetToPlaceholder(input);
|
|
@@ -743,6 +771,42 @@ function buildTerminal(
|
|
|
743
771
|
{ signal },
|
|
744
772
|
);
|
|
745
773
|
|
|
774
|
+
/**
|
|
775
|
+
* Applies the part of a `mapKeyboardEvent` result that BOTH keydown paths (the
|
|
776
|
+
* focused textarea below, and the document-level type-to-focus fallback in the
|
|
777
|
+
* focus-strategy section) handle identically. Returns false only for "ignore",
|
|
778
|
+
* the one arm the two callers resolve differently: the textarea lets its own
|
|
779
|
+
* `input` event deliver the character, while the fallback has no `input` event
|
|
780
|
+
* coming and must send the character itself.
|
|
781
|
+
*
|
|
782
|
+
* Switching over the whole `KeyboardResult` union in one place is what keeps
|
|
783
|
+
* the two paths from drifting — handling only "send" in the fallback silently
|
|
784
|
+
* dropped Shift+PageUp, which is exactly what a reader who just selected some
|
|
785
|
+
* scrollback presses next.
|
|
786
|
+
*/
|
|
787
|
+
function applyMappedKey(ev: KeyboardEvent, result: keyboard.KeyboardResult): boolean {
|
|
788
|
+
switch (result.kind) {
|
|
789
|
+
case "send":
|
|
790
|
+
ev.preventDefault();
|
|
791
|
+
sendText(result.bytes);
|
|
792
|
+
return true;
|
|
793
|
+
case "scroll-up": {
|
|
794
|
+
ev.preventDefault();
|
|
795
|
+
const h = termWrap.clientHeight;
|
|
796
|
+
termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
case "scroll-down": {
|
|
800
|
+
ev.preventDefault();
|
|
801
|
+
const h = termWrap.clientHeight;
|
|
802
|
+
termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
|
|
803
|
+
return true;
|
|
804
|
+
}
|
|
805
|
+
case "ignore":
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
746
810
|
input.addEventListener(
|
|
747
811
|
"keydown",
|
|
748
812
|
(ev: KeyboardEvent) => {
|
|
@@ -755,27 +819,10 @@ function buildTerminal(
|
|
|
755
819
|
return;
|
|
756
820
|
}
|
|
757
821
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
sendText(result.bytes);
|
|
763
|
-
return;
|
|
764
|
-
case "scroll-up": {
|
|
765
|
-
ev.preventDefault();
|
|
766
|
-
const h = termWrap.clientHeight;
|
|
767
|
-
termWrap.scrollTop = Math.max(0, termWrap.scrollTop - h);
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
case "scroll-down": {
|
|
771
|
-
ev.preventDefault();
|
|
772
|
-
const h = termWrap.clientHeight;
|
|
773
|
-
termWrap.scrollTop = Math.min(termWrap.scrollHeight, termWrap.scrollTop + h);
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
case "ignore":
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
822
|
+
// A false return ("ignore") needs nothing here: the `input` listener above
|
|
823
|
+
// delivers the character, which is also how IME and dead-key composition
|
|
824
|
+
// output reaches the terminal.
|
|
825
|
+
applyMappedKey(ev, mapKeyboardEvent(ev, modes));
|
|
779
826
|
},
|
|
780
827
|
{ signal },
|
|
781
828
|
);
|
|
@@ -935,6 +982,175 @@ function buildTerminal(
|
|
|
935
982
|
{ signal },
|
|
936
983
|
);
|
|
937
984
|
|
|
985
|
+
// --- Type-to-focus: recovering a keystroke after a mouse selection ---
|
|
986
|
+
// A mouse gesture over `.term-output` leaves the browser no focusable target,
|
|
987
|
+
// so the key target becomes the document body (UI Events: "If no suitable
|
|
988
|
+
// element is in focus, the event target will be the HTML body element") and
|
|
989
|
+
// the hidden textarea stops receiving keys. The click handler above cannot fix
|
|
990
|
+
// that, because focusing the textarea COLLAPSES the selection the user just
|
|
991
|
+
// made, so it deliberately declines while one exists. Net effect without this
|
|
992
|
+
// listener: select a single cell with the mouse and typing silently does
|
|
993
|
+
// nothing, and the two shortcuts that ride the keydown chain go with it
|
|
994
|
+
// (clipboard's Ctrl+Shift+C, contextMenu's Escape).
|
|
995
|
+
//
|
|
996
|
+
// Native terminals have no such competition: a selection is app state and the
|
|
997
|
+
// keyboard belongs to the window, so VTE grabs widget focus on the very press
|
|
998
|
+
// that starts a selection. xterm.js sidesteps it by preventDefaulting mousedown
|
|
999
|
+
// and painting its own selection, which is not open to us — native touch
|
|
1000
|
+
// selection is this UI's mobile selection UX (css/02-terminal.css). So the
|
|
1001
|
+
// browser's model stands and typing takes the keyboard back instead, which is
|
|
1002
|
+
// also what Windows Terminal does ("most key input clears the selection and
|
|
1003
|
+
// passes the key event directly to the underlying shell").
|
|
1004
|
+
const doc = root.ownerDocument;
|
|
1005
|
+
function ownsSelection(): boolean {
|
|
1006
|
+
// EVERY range, and both ends of each: a range dragged from the terminal out
|
|
1007
|
+
// into the host page's own content never arms this in either drag direction,
|
|
1008
|
+
// and neither does a Firefox Ctrl+drag multi-range selection with one range
|
|
1009
|
+
// outside (the mousedown policy above deliberately preserves those, and
|
|
1010
|
+
// removeAllRanges below would take the host's range with the terminal's).
|
|
1011
|
+
// Range ends are in document order whichever way the user dragged, so
|
|
1012
|
+
// direction costs nothing here. Selected TEXT rather than isCollapsed, so a
|
|
1013
|
+
// bare caret does not arm, matching the click handler's own test. Scoped to
|
|
1014
|
+
// `.term-output`, so a selection in the chrome does not arm. Known
|
|
1015
|
+
// limitation: a terminal inside a host's shadow root never arms, because
|
|
1016
|
+
// getSelection() does not reach into shadow trees.
|
|
1017
|
+
const sel = doc.getSelection();
|
|
1018
|
+
if (sel === null || sel.toString().length === 0) {
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|
|
1021
|
+
for (let i = 0; i < sel.rangeCount; i++) {
|
|
1022
|
+
const range = sel.getRangeAt(i);
|
|
1023
|
+
if (!outputEl.contains(range.startContainer) || !outputEl.contains(range.endContainer)) {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return true;
|
|
1028
|
+
}
|
|
1029
|
+
doc.addEventListener(
|
|
1030
|
+
"keydown",
|
|
1031
|
+
(ev: KeyboardEvent) => {
|
|
1032
|
+
// Two groups, in this order. The ARMING gates immediately below decide
|
|
1033
|
+
// whether this listener is responsible for the keystroke at all; the
|
|
1034
|
+
// TERMINAL-KEY gates after the feature chain decide whether the terminal
|
|
1035
|
+
// wants this particular key. Both are preconditions rather than a
|
|
1036
|
+
// classification: nothing runs unless every gate passes, so no keystroke
|
|
1037
|
+
// falls between them.
|
|
1038
|
+
if (destroyed) {
|
|
1039
|
+
// Reachable in one window only: cleanupRuntime() tears features down
|
|
1040
|
+
// BEFORE it aborts the signal that removes this listener, so a feature
|
|
1041
|
+
// teardown that synchronously dispatched a key would otherwise be served
|
|
1042
|
+
// by a terminal that is already gone. (The textarea listener needs no
|
|
1043
|
+
// such gate: it only fires while the textarea holds focus, and a teardown
|
|
1044
|
+
// does not focus it.)
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
// A host that already handled this key marked it, so leave it alone. This
|
|
1048
|
+
// is the platform's own convention for composing keyboard handlers, and it
|
|
1049
|
+
// is the reason this listener needs no opt-out option: an embedder with a
|
|
1050
|
+
// document-level keymap cancels the events it owns.
|
|
1051
|
+
if (ev.defaultPrevented) {
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
// Nobody owns the keyboard. This one test replaces a blocklist of
|
|
1055
|
+
// interactive tag names and is strictly more complete: focus inside an open
|
|
1056
|
+
// shadow root reports the HOST, an open <dialog> reports the dialog (or its
|
|
1057
|
+
// focused child), and a tabindex="0" ARIA widget reports itself. All
|
|
1058
|
+
// non-body, so all bail. It also subsumes the double-send guard: a focused
|
|
1059
|
+
// textarea is not the body, so a key the handler above already took can
|
|
1060
|
+
// never reach here. Residual: HTML's focus fixup rule can park focus back
|
|
1061
|
+
// on the body while a modal dialog is still open (its focused child was
|
|
1062
|
+
// removed), which the focus-landed check below is what actually covers.
|
|
1063
|
+
if (doc.activeElement !== null && doc.activeElement !== doc.body) {
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
// Composition first, matching the textarea path, and on the composition
|
|
1067
|
+
// module's clock rather than ev.isComposing: that clock stays true through
|
|
1068
|
+
// the deferred compositionend read (the documented Chromium workaround).
|
|
1069
|
+
if (composition.isComposing()) {
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (!ownsSelection()) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
// Features next, while the selection is STILL INTACT, and BEFORE the
|
|
1076
|
+
// modifier gate below. Both orderings are load-bearing: clipboard's
|
|
1077
|
+
// Ctrl+Shift+C is a MODIFIED key that reads the selection, so a modifier
|
|
1078
|
+
// gate placed first would deny the chain the one shortcut this listener
|
|
1079
|
+
// exists to restore (caught by its own test). A claimed key never focuses,
|
|
1080
|
+
// so the selection survives it and a second copy is still possible.
|
|
1081
|
+
for (const h of keydownHandlers) {
|
|
1082
|
+
if (h(ev)) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// What this key DOES is decided before taking focus, because a keystroke
|
|
1087
|
+
// that delivers nothing must leave the selection and the focus untouched.
|
|
1088
|
+
// A bare modifier press is the case that proves it: Shift on its way to
|
|
1089
|
+
// Shift+click, or to Ctrl+Shift+C, would otherwise clear the very selection
|
|
1090
|
+
// the chord is about to act on.
|
|
1091
|
+
//
|
|
1092
|
+
// The character is computed ONLY for an "ignore" result, so the encoder
|
|
1093
|
+
// always wins a key it maps. Under the kitty protocol a MODIFIED character
|
|
1094
|
+
// key encodes to a CSI-u sequence rather than to its text, and the focused
|
|
1095
|
+
// path sends that sequence; this path has to agree with it.
|
|
1096
|
+
const result = mapKeyboardEvent(ev, modes);
|
|
1097
|
+
const char = result.kind === "ignore" ? typedChar(ev) : null;
|
|
1098
|
+
if (result.kind === "ignore" && char === null) {
|
|
1099
|
+
// Nothing to deliver: a bare modifier or lock key, a dead key, an IME
|
|
1100
|
+
// start, a multi-character or unknown key name.
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
// Modified keys stay with the browser, with one exception: an AltGr
|
|
1104
|
+
// CHARACTER, because on the layouts that need AltGr that is how the
|
|
1105
|
+
// character is typed. The exception is deliberately limited to a character
|
|
1106
|
+
// rather than granted to any AltGraph event, because AltGraph is not a
|
|
1107
|
+
// reliable signal on its own: Firefox reports it for plain Option on macOS
|
|
1108
|
+
// and for ordinary Ctrl+Alt on Windows, so admitting every AltGraph event
|
|
1109
|
+
// would hand the terminal Option+ArrowLeft, which is browser back.
|
|
1110
|
+
// (xterm.js carries the mirror-image heuristic, ctrl+alt meaning AltGr, and
|
|
1111
|
+
// scopes it to Windows.)
|
|
1112
|
+
//
|
|
1113
|
+
// Nothing else modified is recovered, and the asymmetry decides that: not
|
|
1114
|
+
// recovering Ctrl+ArrowLeft costs one keypress in a state that ends on the
|
|
1115
|
+
// next character typed, while stealing Ctrl+C costs the user the selection
|
|
1116
|
+
// they just made, and Alt+ArrowLeft is browser back.
|
|
1117
|
+
const altGraphChar = char !== null && ev.getModifierState("AltGraph");
|
|
1118
|
+
if ((ev.ctrlKey || ev.metaKey || ev.altKey) && !altGraphChar) {
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
// Tab stays with the browser: this only ever runs on a body-focused page,
|
|
1122
|
+
// where Tab is how a keyboard user moves on (web-terminal-kiro's
|
|
1123
|
+
// focusable-inventory test pins that expectation).
|
|
1124
|
+
if (ev.key === "Tab") {
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
// Take the keyboard. Collapsing explicitly rather than leaning on focus()
|
|
1128
|
+
// to do it keeps the behaviour identical wherever focus() would not.
|
|
1129
|
+
doc.getSelection()?.removeAllRanges();
|
|
1130
|
+
focusTerminal();
|
|
1131
|
+
// Fail closed. Focus can fail to land (an inert subtree under an open modal
|
|
1132
|
+
// dialog, a released root), and bytes whose effect the user cannot see do
|
|
1133
|
+
// not belong in a shell.
|
|
1134
|
+
if (doc.activeElement !== input) {
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (char !== null) {
|
|
1138
|
+
// The encoder defers a plain character to the `input` event, which the
|
|
1139
|
+
// focused path delivers. No input event is coming for a key that targeted
|
|
1140
|
+
// the body, so send it here. preventDefault makes that exactly once BY
|
|
1141
|
+
// SPEC ("If this event is canceled, the associated event types MUST NOT be
|
|
1142
|
+
// dispatched"), rather than by depending on whether an engine re-resolves
|
|
1143
|
+
// an uncanceled keydown's insertion target after a listener moved focus
|
|
1144
|
+
// mid-dispatch. Chromium does; no specification requires it.
|
|
1145
|
+
ev.preventDefault();
|
|
1146
|
+
sendText(normalizeTypedText(char));
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
applyMappedKey(ev, result);
|
|
1150
|
+
},
|
|
1151
|
+
{ signal },
|
|
1152
|
+
);
|
|
1153
|
+
|
|
938
1154
|
// --- Viewport ---
|
|
939
1155
|
viewport.init({
|
|
940
1156
|
termWrap,
|