@cplieger/web-terminal-ui 7.0.6 → 7.1.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/README.md +34 -3
- package/css/02-terminal.css +17 -0
- package/css/10-primitives.css +50 -4
- package/package.json +8 -8
- package/src/kernel/kernel.ts +146 -34
package/README.md
CHANGED
|
@@ -186,7 +186,27 @@ want one, for example Ctrl+L); `reattach()` attaches again to whatever the serve
|
|
|
186
186
|
serves now, for a session that has ended (see "When a session ends"); and
|
|
187
187
|
`destroy()` tears every feature down and releases the kernel.
|
|
188
188
|
|
|
189
|
-
###
|
|
189
|
+
### Mouse and focus
|
|
190
|
+
|
|
191
|
+
A terminal application that turns mouse tracking on receives the clicks, drags
|
|
192
|
+
and wheel gestures a user makes in the browser, as SGR reports. The pointer says
|
|
193
|
+
so while that is happening: at rest the grid carries a text cursor, and while an
|
|
194
|
+
application holds the mouse the grid and any links in it carry an arrow instead,
|
|
195
|
+
because a click is reported to that application rather than selecting text or
|
|
196
|
+
opening the link. Shift-drag still selects and shift-click still opens a link,
|
|
197
|
+
which is the convention xterm set, so nothing becomes unreachable.
|
|
198
|
+
|
|
199
|
+
Mouse input is best-effort, as it is in every terminal: a report says where the
|
|
200
|
+
pointer is at that moment and carries nothing a receiver could use to notice it
|
|
201
|
+
had gone stale, so a report made while the socket is down is dropped rather than
|
|
202
|
+
delivered late against a screen that has since been repainted. Typed input keeps
|
|
203
|
+
its delivery guarantee and is unaffected.
|
|
204
|
+
|
|
205
|
+
Focus reporting is the server's answer rather than this UI's. The kernel tells
|
|
206
|
+
the server whether its own terminal widget holds focus and the server decides
|
|
207
|
+
what the application is told, so several devices attached to one session cannot
|
|
208
|
+
contradict each other. A blur that a press causes and that the same gesture's
|
|
209
|
+
click undoes is not reported, because the terminal did not lose focus.
|
|
190
210
|
|
|
191
211
|
`persistScrollback` takes the storage, not a switch, because storage is a
|
|
192
212
|
decision this library should not make for you. Chrome's page-lifecycle guidance
|
|
@@ -358,8 +378,19 @@ completed feature and core listener, clears the broken subtree, and shows the
|
|
|
358
378
|
panel. If `createTerminal` itself throws (`phase: "kernel-init"`: an
|
|
359
379
|
unresolvable mount selector, a preset that throws, an invalid feature list, a DOM
|
|
360
380
|
invariant), it shows the same panel and then rethrows, so a caller with its own
|
|
361
|
-
error handling still sees the error.
|
|
362
|
-
|
|
381
|
+
error handling still sees the error.
|
|
382
|
+
|
|
383
|
+
The panel is a native `<dialog>`. In `viewport` layout it opens with
|
|
384
|
+
`showModal()`, so the rest of your document is inert while it is up; in
|
|
385
|
+
`container` layout it opens non-modally, so the application around an embedded
|
|
386
|
+
terminal keeps working and keeps its focusables. Escape does not dismiss it in
|
|
387
|
+
either mode: the terminal is already gone, so there would be nothing behind it
|
|
388
|
+
and no way back. Reload is the recovery.
|
|
389
|
+
|
|
390
|
+
One visible consequence in `viewport` layout: a modal dialog is in the browser's
|
|
391
|
+
top layer, above every `z-index` in the document, so the panel now paints over
|
|
392
|
+
your loading overlay instead of under it. It appears at once and the overlay's
|
|
393
|
+
fade-out finishes behind it.
|
|
363
394
|
|
|
364
395
|
**This is why `target` takes a selector and `features` takes a function.** Both
|
|
365
396
|
are resolved INSIDE that boundary. Written the other way round
|
package/css/02-terminal.css
CHANGED
|
@@ -149,6 +149,11 @@
|
|
|
149
149
|
kernel focus handling + contextMenu feature). */
|
|
150
150
|
user-select: text;
|
|
151
151
|
-webkit-touch-callout: default;
|
|
152
|
+
|
|
153
|
+
/* Stated rather than inherited: the UA default over text is already an I-beam,
|
|
154
|
+
but the tracking rule below has to have something deterministic to override
|
|
155
|
+
(and to hand back to). */
|
|
156
|
+
cursor: text;
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
/* IME composition view: floating overlay positioned at the cursor
|
|
@@ -327,6 +332,18 @@
|
|
|
327
332
|
--bg: var(--text-default);
|
|
328
333
|
}
|
|
329
334
|
|
|
335
|
+
/* An application holds the mouse (DEC 1000/1002/1003) and the kernel toggles
|
|
336
|
+
.wt-mouse-app on the scroll container. A plain click is reported to that
|
|
337
|
+
application, so neither the I-beam nor the link's pointer is true any longer:
|
|
338
|
+
both promise something the click no longer does. Deliberately no
|
|
339
|
+
`user-select: none` here, because the engine hands a shifted gesture to the
|
|
340
|
+
browser, so shift+drag selecting and shift+click opening a link are the only
|
|
341
|
+
ways left to reach either and blanketing selection off would remove them. */
|
|
342
|
+
:where(.wt-root) .term.wt-mouse-app .term-output,
|
|
343
|
+
:where(.wt-root) .term.wt-mouse-app .term-link {
|
|
344
|
+
cursor: default;
|
|
345
|
+
}
|
|
346
|
+
|
|
330
347
|
:where(.wt-root) .term-blink {
|
|
331
348
|
animation: wt-blink-anim 1s step-end infinite;
|
|
332
349
|
}
|
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.1.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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"verify": "bash scripts/verify.sh"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"@cplieger/web-terminal-engine": "^5.0
|
|
43
|
+
"@cplieger/web-terminal-engine": "^5.1.0"
|
|
44
44
|
},
|
|
45
45
|
"overrides": {
|
|
46
46
|
"qs": "^6.15.2"
|
|
@@ -50,18 +50,18 @@
|
|
|
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.
|
|
53
|
+
"@types/node": "26.5.0",
|
|
54
54
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
55
55
|
"@vitest/browser": "5.0.0",
|
|
56
56
|
"@vitest/browser-playwright": "5.0.0",
|
|
57
57
|
"@vitest/coverage-v8": "5.0.0",
|
|
58
|
-
"eslint": "10.
|
|
58
|
+
"eslint": "10.10.0",
|
|
59
59
|
"fast-check": "4.9.0",
|
|
60
|
-
"html-validate": "11.
|
|
61
|
-
"knip": "6.
|
|
62
|
-
"playwright": "1.
|
|
60
|
+
"html-validate": "11.15.0",
|
|
61
|
+
"knip": "6.35.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",
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -13,12 +13,11 @@
|
|
|
13
13
|
// still single-instance, so createTerminal is called once per page (tabs
|
|
14
14
|
// multiplex sessions over the one kernel, section 22.5).
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// suppression is a tracked follow-up.
|
|
16
|
+
// The kernel owns the terminal widget's DOM, so it owns the widget's focus: it
|
|
17
|
+
// REPORTS focus and blur to the transport (`connection.setClientFocus`) and
|
|
18
|
+
// never writes DEC 1004 bytes itself. The server derives the answer for the
|
|
19
|
+
// session from every attached client's report plus its own hold, which is the
|
|
20
|
+
// only place the question can be answered once a second device attaches.
|
|
22
21
|
|
|
23
22
|
import {
|
|
24
23
|
render,
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
connection,
|
|
27
26
|
keyboard,
|
|
28
27
|
modes,
|
|
28
|
+
mouse,
|
|
29
29
|
LineStore,
|
|
30
30
|
} from "@cplieger/web-terminal-engine";
|
|
31
31
|
import * as composition from "../composition.js";
|
|
@@ -168,17 +168,18 @@ function renderFatalStartupInto(
|
|
|
168
168
|
layoutMode: "viewport" | "container",
|
|
169
169
|
message: string = STARTUP_FAILURE_COPY.message,
|
|
170
170
|
): void {
|
|
171
|
-
const surface = document.createElement("
|
|
171
|
+
const surface = document.createElement("dialog");
|
|
172
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.
|
|
173
175
|
surface.setAttribute("role", "alertdialog");
|
|
174
176
|
surface.setAttribute("aria-labelledby", "wt-fatal-title");
|
|
175
177
|
surface.setAttribute("aria-describedby", "wt-fatal-message");
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
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
|
+
});
|
|
182
183
|
|
|
183
184
|
const card = document.createElement("div");
|
|
184
185
|
card.className = "wt-fatal-card";
|
|
@@ -201,9 +202,19 @@ function renderFatalStartupInto(
|
|
|
201
202
|
surface.appendChild(card);
|
|
202
203
|
root.replaceChildren(surface);
|
|
203
204
|
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
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.
|
|
207
218
|
reloadButton.focus();
|
|
208
219
|
}
|
|
209
220
|
|
|
@@ -389,9 +400,9 @@ function buildTerminal(
|
|
|
389
400
|
// output never does (the scroll controller's follow/hold job is unchanged),
|
|
390
401
|
// so scrolling up to read while output streams is preserved. A no-op on the
|
|
391
402
|
// alt screen (no scrollback) and when already following. NOTE: every caller
|
|
392
|
-
// of sendBytes
|
|
393
|
-
//
|
|
394
|
-
//
|
|
403
|
+
// of sendBytes is genuine user input (typed keys, paste, mobile toolbar);
|
|
404
|
+
// mouse reports deliberately bypass this funnel and go out as ephemeral
|
|
405
|
+
// input, or the view would snap on every motion report.
|
|
395
406
|
scroll.scrollToBottom();
|
|
396
407
|
for (const obs of inputObservers) {
|
|
397
408
|
obs(out);
|
|
@@ -564,6 +575,100 @@ function buildTerminal(
|
|
|
564
575
|
});
|
|
565
576
|
render.updateFontMetrics();
|
|
566
577
|
|
|
578
|
+
// A mouse report is BEST-EFFORT and never enters the reliable outbox: it
|
|
579
|
+
// carries no sequence number, so one retransmitted after a resume describes a
|
|
580
|
+
// screen since repainted, and `sendEphemeral` refuses rather than queues. Not
|
|
581
|
+
// sendBytes either: that funnel snaps the view to the bottom on every accepted
|
|
582
|
+
// byte, so a motion report would jump the viewport. The listeners belong on the
|
|
583
|
+
// scroll container (a click in its padding or over the reserved scrollbar
|
|
584
|
+
// gutter never reaches a row, and the wheel has to be cancellable there); the
|
|
585
|
+
// coordinate frame is the row container, whose box IS the grid.
|
|
586
|
+
const disposeMouse = mouse.init({
|
|
587
|
+
sendReport: (data) => connection.sendEphemeral(data),
|
|
588
|
+
cellSize: render.cellSize,
|
|
589
|
+
termElement: () => termWrap,
|
|
590
|
+
gridElement: () => outputEl,
|
|
591
|
+
// The RENDERED grid, not computeSize(): the row hit test anchors on its
|
|
592
|
+
// height, and this client's own measurement disagrees with the negotiated
|
|
593
|
+
// screen whenever a second device is attached or a local resize has not been
|
|
594
|
+
// answered yet, which would shift every reported row.
|
|
595
|
+
gridSize: render.gridSize,
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// A press over the display-only output takes focus off the hidden textarea and
|
|
599
|
+
// the click handler below puts it back, so that blur/focus pair is an artifact
|
|
600
|
+
// of this input model, not a focus change the application should be told about.
|
|
601
|
+
// xterm.js never emits one because its textarea keeps focus through a click on
|
|
602
|
+
// the terminal. So a blur raised inside a press is withheld and the gesture's
|
|
603
|
+
// REAL end state is reported once it resolves: nothing when focus came back,
|
|
604
|
+
// one report when it did not (a live selection, a link and a bare touch tap all
|
|
605
|
+
// decline to restore it). Measured before this: 3600 focus reports in 90s of
|
|
606
|
+
// clicking, which reaches an application as FocusLost/FocusGained per click.
|
|
607
|
+
let pressHoldsFocus = false;
|
|
608
|
+
let focusResolve: ReturnType<typeof setTimeout> | null = null;
|
|
609
|
+
|
|
610
|
+
function reportFocusNow(): void {
|
|
611
|
+
connection.setClientFocus(
|
|
612
|
+
document.activeElement !== null && termWrap.contains(document.activeElement),
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Scheduled rather than read at pointerup: the focus restore happens in the
|
|
617
|
+
// `click` handler, which the browser dispatches after pointerup within the same
|
|
618
|
+
// task, so a task boundary is the first point where the end state is settled.
|
|
619
|
+
function endPressFocusGesture(): void {
|
|
620
|
+
if (focusResolve !== null) {
|
|
621
|
+
clearTimeout(focusResolve);
|
|
622
|
+
}
|
|
623
|
+
focusResolve = setTimeout(() => {
|
|
624
|
+
focusResolve = null;
|
|
625
|
+
pressHoldsFocus = false;
|
|
626
|
+
reportFocusNow();
|
|
627
|
+
}, 0);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// The keyboard target is the hidden textarea INSIDE termWrap, so the widget's
|
|
631
|
+
// own focus events bubble to here. What goes out is the widget's STATE, not
|
|
632
|
+
// DEC 1004 bytes: the server derives the answer from this report plus every
|
|
633
|
+
// other attached client's plus its own hold.
|
|
634
|
+
termWrap.addEventListener(
|
|
635
|
+
"focusin",
|
|
636
|
+
() => {
|
|
637
|
+
connection.setClientFocus(true);
|
|
638
|
+
},
|
|
639
|
+
{ signal },
|
|
640
|
+
);
|
|
641
|
+
termWrap.addEventListener(
|
|
642
|
+
"focusout",
|
|
643
|
+
(ev) => {
|
|
644
|
+
if (ev.relatedTarget instanceof Node && termWrap.contains(ev.relatedTarget)) {
|
|
645
|
+
return; // focus moving WITHIN the terminal is not a blur
|
|
646
|
+
}
|
|
647
|
+
if (pressHoldsFocus) {
|
|
648
|
+
return; // this gesture's own doing; endPressFocusGesture reports the truth
|
|
649
|
+
}
|
|
650
|
+
connection.setClientFocus(false);
|
|
651
|
+
},
|
|
652
|
+
{ signal },
|
|
653
|
+
);
|
|
654
|
+
// Seeded once, so a terminal mounted with its textarea already focused does not
|
|
655
|
+
// report blurred until the first real focusin.
|
|
656
|
+
reportFocusNow();
|
|
657
|
+
|
|
658
|
+
// The kernel is the toggler, and the class goes on the scroll container rather
|
|
659
|
+
// than on the rows: `.term-output` is replaced wholesale by the renderer, and
|
|
660
|
+
// `.term` is the element that survives every rebuild. What the shape MEANS is
|
|
661
|
+
// at the CSS rule it drives (css/02-terminal.css).
|
|
662
|
+
function updateMousePointer(): void {
|
|
663
|
+
termWrap.classList.toggle("wt-mouse-app", modes.getMouseMode() !== 0);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Derived once here as well as from the two triggers below: the modes module is
|
|
667
|
+
// a singleton that outlives a terminal, so a re-created terminal on a page
|
|
668
|
+
// whose mirror already holds a tracking mode would otherwise show the resting
|
|
669
|
+
// pointer until the next frame.
|
|
670
|
+
updateMousePointer();
|
|
671
|
+
|
|
567
672
|
composition.init({
|
|
568
673
|
textarea: input,
|
|
569
674
|
compositionView: compositionViewEl,
|
|
@@ -687,6 +792,7 @@ function buildTerminal(
|
|
|
687
792
|
bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
|
|
688
793
|
} else if (msg.type === "modes") {
|
|
689
794
|
render.updateReverseVideo();
|
|
795
|
+
updateMousePointer();
|
|
690
796
|
bus.emit("wire:modes", msg);
|
|
691
797
|
} else if (msg.type === "clipboard") {
|
|
692
798
|
// Inbound OSC 52. With no clipboard feature subscribed this is a no-op
|
|
@@ -958,9 +1064,13 @@ function buildTerminal(
|
|
|
958
1064
|
pointerDownX = e.clientX;
|
|
959
1065
|
pointerDownY = e.clientY;
|
|
960
1066
|
pointerDownTime = e.timeStamp;
|
|
1067
|
+
pressHoldsFocus = true;
|
|
961
1068
|
},
|
|
962
1069
|
{ passive: true, signal },
|
|
963
1070
|
);
|
|
1071
|
+
for (const end of ["pointerup", "pointercancel", "lostpointercapture"] as const) {
|
|
1072
|
+
termWrap.addEventListener(end, endPressFocusGesture, { passive: true, signal });
|
|
1073
|
+
}
|
|
964
1074
|
termWrap.addEventListener(
|
|
965
1075
|
"pointerup",
|
|
966
1076
|
(e) => {
|
|
@@ -1465,6 +1575,11 @@ function buildTerminal(
|
|
|
1465
1575
|
// detach here and the onSwitch attach below.
|
|
1466
1576
|
composition.cancelComposition();
|
|
1467
1577
|
resetToPlaceholder(input);
|
|
1578
|
+
// A held mouse gesture is latched input too, and the mouse module is per
|
|
1579
|
+
// TERMINAL while sessions multiplex over it: a record kept across the switch
|
|
1580
|
+
// would let the incoming session's first open synthesize a release for a
|
|
1581
|
+
// press its application never saw.
|
|
1582
|
+
mouse.disarmGesture();
|
|
1468
1583
|
for (const { instance } of instances) {
|
|
1469
1584
|
instance.onDetach?.();
|
|
1470
1585
|
}
|
|
@@ -1472,6 +1587,9 @@ function buildTerminal(
|
|
|
1472
1587
|
// Reconnect the terminal WS to this session using its per-tab resume
|
|
1473
1588
|
// state; the renderer was already pointed at its store by the owner.
|
|
1474
1589
|
connection.setSession(session.id);
|
|
1590
|
+
// setSession restores the incoming session's mode mirror synchronously and
|
|
1591
|
+
// delivers no modes frame, so nothing else re-derives the pointer here.
|
|
1592
|
+
updateMousePointer();
|
|
1475
1593
|
// The owned first connect has happened (session id is now on the WS URL);
|
|
1476
1594
|
// wake-reconnect handlers may fire from here on.
|
|
1477
1595
|
connectionInitiated = true;
|
|
@@ -1660,12 +1778,17 @@ function buildTerminal(
|
|
|
1660
1778
|
// Reset the composition singleton: a failure during an IME cycle must not
|
|
1661
1779
|
// leave module state or a deferred send alive for a later terminal mount.
|
|
1662
1780
|
composition.teardown();
|
|
1781
|
+
disposeMouse();
|
|
1663
1782
|
connection.disconnect();
|
|
1664
1783
|
connState.destroy();
|
|
1665
1784
|
if (toastTimer !== null) {
|
|
1666
1785
|
clearTimeout(toastTimer);
|
|
1667
1786
|
toastTimer = null;
|
|
1668
1787
|
}
|
|
1788
|
+
if (focusResolve !== null) {
|
|
1789
|
+
clearTimeout(focusResolve);
|
|
1790
|
+
focusResolve = null;
|
|
1791
|
+
}
|
|
1669
1792
|
for (const off of subscriptions) {
|
|
1670
1793
|
off();
|
|
1671
1794
|
}
|
|
@@ -1888,21 +2011,6 @@ function buildTerminal(
|
|
|
1888
2011
|
};
|
|
1889
2012
|
}
|
|
1890
2013
|
|
|
1891
|
-
/** Build the terminal UI inside `root`. Call exactly once.
|
|
1892
|
-
*
|
|
1893
|
-
* Wraps buildTerminal so that a SYNCHRONOUS startup failure gets the same
|
|
1894
|
-
* treatment an asynchronous one already got. Before this, the two halves of
|
|
1895
|
-
* "the terminal did not start" were handled inconsistently: an async
|
|
1896
|
-
* feature-setup rejection ran enterFatalStartup (overlay dismissed,
|
|
1897
|
-
* onFatalError delivered, recovery surface rendered), while a synchronous throw
|
|
1898
|
-
* propagated bare, leaving the consumer's loading overlay spinning forever over
|
|
1899
|
-
* a page with no terminal and no explanation. Consumers were left hand-building
|
|
1900
|
-
* their own surface to cover the gap, diverging from this one in shape and copy.
|
|
1901
|
-
*
|
|
1902
|
-
* The error still propagates: a consumer with its own handling sees it exactly
|
|
1903
|
-
* as before, only now against a page that is no longer stuck on a spinner. A
|
|
1904
|
-
* handler returning `true` suppresses the built-in surface, same contract as the
|
|
1905
|
-
* async phase. */
|
|
1906
2014
|
/** Resolve the mount target. A selector is looked up here, INSIDE
|
|
1907
2015
|
* createTerminal's try, which is the whole point: a consumer that passed
|
|
1908
2016
|
* `document.getElementById("terminal")` had to null-check it first (tsc forces
|
|
@@ -1959,6 +2067,10 @@ function createFallbackHost(): HTMLElement {
|
|
|
1959
2067
|
return host;
|
|
1960
2068
|
}
|
|
1961
2069
|
|
|
2070
|
+
/** Build the terminal UI inside `target`. Call exactly once. Wraps buildTerminal so
|
|
2071
|
+
* a SYNCHRONOUS startup failure gets the same recovery surface an async one does
|
|
2072
|
+
* and still propagates; that surface is modal in viewport layout, so
|
|
2073
|
+
* `onFatalError` returning `true` is the path that leaves the page interactive. */
|
|
1962
2074
|
export function createTerminal(
|
|
1963
2075
|
target: HTMLElement | string,
|
|
1964
2076
|
opts: CreateTerminalOptions = {},
|