@cplieger/web-terminal-ui 5.5.0 → 5.6.1
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 +2 -2
- package/src/dom-isolation-setup.ts +32 -0
- package/src/features/tabs/index.ts +22 -4
- 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.1",
|
|
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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"fast-check": "4.9.0",
|
|
58
58
|
"happy-dom": "20.11.2",
|
|
59
59
|
"html-validate": "11.6.2",
|
|
60
|
-
"knip": "6.32.
|
|
60
|
+
"knip": "6.32.2",
|
|
61
61
|
"prettier": "3.9.6",
|
|
62
62
|
"stylelint": "17.14.1",
|
|
63
63
|
"stylelint-config-standard": "40.0.0",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Per-test cleanup of DOM state that outlives a test file.
|
|
2
|
+
//
|
|
3
|
+
// Loaded via vitest's `setupFiles` alongside fc-strict-setup, because
|
|
4
|
+
// `vitest.config.ts` deliberately sets `isolate: false`: test FILES share one
|
|
5
|
+
// happy-dom instance inside a worker thread, which is what makes the suite fast
|
|
6
|
+
// and also what lets one file's leftovers reach another's assertions.
|
|
7
|
+
//
|
|
8
|
+
// The document selection is the state that actually bites, for two compounding
|
|
9
|
+
// reasons. Several suites end with a live selection on purpose (the kernel's
|
|
10
|
+
// mouse-selection and type-to-focus suites assert that a gesture PRESERVED one,
|
|
11
|
+
// and contextMenu's dismissal suite clicks a real Select All). And happy-dom does
|
|
12
|
+
// not apply the selection fixup a real browser does, so a range whose nodes were
|
|
13
|
+
// removed by `document.body.replaceChildren()` keeps reporting its old text:
|
|
14
|
+
// measured rangeCount 1, isCollapsed false, and toString() unchanged after the
|
|
15
|
+
// nodes were detached.
|
|
16
|
+
//
|
|
17
|
+
// Left alone, that reached `context-menu.test.ts`'s "omits Copy with nothing
|
|
18
|
+
// selected" case, which found text to copy and failed. Order-dependently, so it
|
|
19
|
+
// passed every local run and every PR check, then failed once on main where the
|
|
20
|
+
// thread layout put the files in a different order. Clearing here rather than in
|
|
21
|
+
// each suite keeps the guarantee independent of which files a worker happens to
|
|
22
|
+
// pair up.
|
|
23
|
+
import { beforeEach } from "vitest";
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
// Node-environment files (the default; DOM files opt in per file) have no
|
|
27
|
+
// window, and nothing to clean.
|
|
28
|
+
if (typeof window === "undefined") {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
window.getSelection()?.removeAllRanges();
|
|
32
|
+
});
|
|
@@ -2561,6 +2561,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2561
2561
|
if (activeId !== null) {
|
|
2562
2562
|
return;
|
|
2563
2563
|
}
|
|
2564
|
+
// ...but only once the bootstrap has adopted its list. Before that the
|
|
2565
|
+
// view is PARTIAL, and deterministically so: tabs subscribes to the status
|
|
2566
|
+
// stream during setup, before resolveInitialSession issues its
|
|
2567
|
+
// GET /api/sessions, so the SSE snapshot's one-event-per-session arrives
|
|
2568
|
+
// first and this ran on a strip holding only whichever session the server
|
|
2569
|
+
// sent first. Activating it both picked a tab on incomplete information and
|
|
2570
|
+
// silently disabled the saved-tab restore: the bootstrap ladder's first act
|
|
2571
|
+
// is to return early when activeId is already set, so localStorage was
|
|
2572
|
+
// never read and every reload landed on the oldest tab. Boot activation
|
|
2573
|
+
// belongs to that ladder alone, which is the only site with the whole list
|
|
2574
|
+
// AND the saved id; this stays the runtime repair it was written to be.
|
|
2575
|
+
if (!started) {
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2564
2578
|
const first = tabList.find((t) => !isEndedStatus(statusOf(t))) ?? tabList[0];
|
|
2565
2579
|
if (first) {
|
|
2566
2580
|
switchTo(first.id);
|
|
@@ -4025,10 +4039,14 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
4025
4039
|
}
|
|
4026
4040
|
// From here on, tabs added at runtime (create / adopt) animate in.
|
|
4027
4041
|
started = true;
|
|
4028
|
-
//
|
|
4029
|
-
//
|
|
4030
|
-
//
|
|
4031
|
-
//
|
|
4042
|
+
// Something already activated a tab while the list was in flight. The
|
|
4043
|
+
// chrome mounts synchronously, so a user can tap a chip (or a delivered
|
|
4044
|
+
// notification can be clicked) before this resolves, and an explicit
|
|
4045
|
+
// gesture outranks the saved id — return null and leave that switch alone;
|
|
4046
|
+
// the kernel sees connectionInitiated and leaves the loading overlay to
|
|
4047
|
+
// the normal ready path. ensureActive is deliberately NOT one of these
|
|
4048
|
+
// callers during boot (see its `started` gate): when it was, the SSE
|
|
4049
|
+
// snapshot won this race on every load and the ladder below never ran.
|
|
4032
4050
|
if (activeId !== null) {
|
|
4033
4051
|
return null;
|
|
4034
4052
|
}
|
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,
|