@cplieger/web-terminal-ui 3.3.1 → 3.4.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 +13 -9
- package/css/00-tokens.css +9 -2
- package/css/02-app.css +0 -139
- package/css/03-primitives.css +1 -0
- package/css/04-feedback.css +20 -6
- package/css/05-tabs.css +106 -23
- package/css/06-mobile.css +104 -38
- package/css/07-animations.css +22 -13
- package/package.json +1 -1
- package/src/composition.ts +29 -4
- package/src/features/activity-monitor.ts +8 -1
- package/src/features/animations.ts +10 -5
- package/src/features/clipboard.ts +8 -2
- package/src/features/connection-banner.ts +0 -1
- package/src/features/context-menu.ts +32 -0
- package/src/features/predictive-echo.ts +10 -0
- package/src/features/tabs.ts +602 -112
- package/src/input-placeholder.ts +2 -2
- package/src/kernel/kernel.ts +82 -10
- package/src/presets.ts +18 -15
- package/src/viewport.ts +80 -3
package/src/input-placeholder.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Single source of truth for the hidden-textarea placeholder shared by
|
|
2
|
-
//
|
|
2
|
+
// kernel.ts and composition.ts.
|
|
3
3
|
//
|
|
4
4
|
// A single-character placeholder is kept in the hidden textarea so iOS soft
|
|
5
5
|
// keyboards have something to "delete" when the user holds Backspace. iOS only
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// announcement of the input state stays empty-ish rather than "space".
|
|
12
12
|
//
|
|
13
13
|
// The constant AND the reset routine must stay in lockstep across the two
|
|
14
|
-
// modules (
|
|
14
|
+
// modules (kernel.ts seeds + re-pads it; composition.ts restores it on
|
|
15
15
|
// compositionend / paste) or the iOS held-Backspace key-repeat silently breaks
|
|
16
16
|
// with no compile error — which is why they live here in one place.
|
|
17
17
|
export const INPUT_PLACEHOLDER = "\u00A0";
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -248,6 +248,7 @@ export function createTerminal(
|
|
|
248
248
|
compositionView: compositionViewEl,
|
|
249
249
|
getCursorPx: render.getCursorPx,
|
|
250
250
|
send: sendText,
|
|
251
|
+
paste,
|
|
251
252
|
});
|
|
252
253
|
|
|
253
254
|
scroll.init({
|
|
@@ -281,7 +282,13 @@ export function createTerminal(
|
|
|
281
282
|
} else if (msg.type === "scroll") {
|
|
282
283
|
render.handleScroll(msg);
|
|
283
284
|
} else if (msg.type === "title") {
|
|
284
|
-
|
|
285
|
+
// Match the app's own title policy (tabs.ts wire:title / applyStatus,
|
|
286
|
+
// which ignore a blank OSC 0/2 title): a shell clears its window title
|
|
287
|
+
// when it redraws its prompt after idling, so hold the last-good browser
|
|
288
|
+
// title instead of flickering blank.
|
|
289
|
+
if (msg.title.trim() !== "") {
|
|
290
|
+
document.title = msg.title;
|
|
291
|
+
}
|
|
285
292
|
bus.emit("wire:title", { session: activeSession?.id ?? "", title: msg.title });
|
|
286
293
|
} else if (msg.type === "modes") {
|
|
287
294
|
render.updateReverseVideo();
|
|
@@ -412,6 +419,17 @@ export function createTerminal(
|
|
|
412
419
|
// touch and never clears a selection — a long-press to select a word, the OS
|
|
413
420
|
// copy/paste callout, and drag-to-scroll are all left to the platform (the
|
|
414
421
|
// research consensus: over text, allow the default and emit nothing).
|
|
422
|
+
// A fine pointer (mouse / trackpad) is available: a desktop, or a tablet with
|
|
423
|
+
// a trackpad / Magic Keyboard. Such a device has a hardware keyboard, so there
|
|
424
|
+
// is no soft keyboard to protect — focus should be eager and land in one tap.
|
|
425
|
+
// The bare-touch guards below bow out of focusing (to avoid popping the soft
|
|
426
|
+
// keyboard or stealing a selection); they are relaxed when a fine pointer
|
|
427
|
+
// exists. This is keyed off `any-pointer: fine` rather than the event's
|
|
428
|
+
// pointerType because iPadOS reports a COARSE primary pointer even with a
|
|
429
|
+
// trackpad attached, and its trackpad taps arrive inconsistently as "mouse" or
|
|
430
|
+
// "touch" — so pointerType alone made the terminal take several taps to focus.
|
|
431
|
+
const hasFinePointer = (): boolean =>
|
|
432
|
+
typeof window.matchMedia === "function" && window.matchMedia("(any-pointer: fine)").matches;
|
|
415
433
|
let lastPointerType = "mouse";
|
|
416
434
|
let pointerDownX = 0;
|
|
417
435
|
let pointerDownY = 0;
|
|
@@ -459,6 +477,14 @@ export function createTerminal(
|
|
|
459
477
|
const sel = window.getSelection();
|
|
460
478
|
if (sel && !sel.isCollapsed) {
|
|
461
479
|
sel.removeAllRanges();
|
|
480
|
+
// On a bare touchscreen a deselect tap must NOT also focus: that would
|
|
481
|
+
// pop the soft keyboard right after a copy. But with a fine pointer
|
|
482
|
+
// (a hardware keyboard is present) there is no soft keyboard to pop, so
|
|
483
|
+
// focus in the same tap rather than forcing the user to tap again — this
|
|
484
|
+
// is a large part of the "2-3 taps to focus" on an iPad + Magic Keyboard.
|
|
485
|
+
if (hasFinePointer()) {
|
|
486
|
+
focusTerminal();
|
|
487
|
+
}
|
|
462
488
|
return;
|
|
463
489
|
}
|
|
464
490
|
// A clean tap with nothing selected focuses the input, synchronously (an
|
|
@@ -471,8 +497,11 @@ export function createTerminal(
|
|
|
471
497
|
"mousedown",
|
|
472
498
|
(e) => {
|
|
473
499
|
// Cancel the synthetic mousedown after a touch tap so iOS keeps the
|
|
474
|
-
// keyboard up (xterm.js focus-preservation pattern, scoped to touch).
|
|
475
|
-
|
|
500
|
+
// keyboard up (xterm.js focus-preservation pattern, scoped to touch). Skip
|
|
501
|
+
// it when a fine pointer is present (iPad + trackpad / Magic Keyboard):
|
|
502
|
+
// there is no soft keyboard to protect, and suppressing the mousedown was
|
|
503
|
+
// defeating the native focus, so the terminal needed several taps to focus.
|
|
504
|
+
if (lastPointerType === "touch" && !hasFinePointer()) {
|
|
476
505
|
e.preventDefault();
|
|
477
506
|
}
|
|
478
507
|
},
|
|
@@ -487,7 +516,7 @@ export function createTerminal(
|
|
|
487
516
|
window.open(link.href, "_blank", "noopener,noreferrer");
|
|
488
517
|
return;
|
|
489
518
|
}
|
|
490
|
-
if (lastPointerType === "touch") {
|
|
519
|
+
if (lastPointerType === "touch" && !hasFinePointer()) {
|
|
491
520
|
return;
|
|
492
521
|
}
|
|
493
522
|
const sel = window.getSelection();
|
|
@@ -502,6 +531,7 @@ export function createTerminal(
|
|
|
502
531
|
// --- Viewport ---
|
|
503
532
|
viewport.init({
|
|
504
533
|
termWrap,
|
|
534
|
+
suppressKeyboardInset: hasFinePointer,
|
|
505
535
|
onSettled() {
|
|
506
536
|
render.updateFontMetrics();
|
|
507
537
|
if (fontsLoaded) {
|
|
@@ -636,6 +666,12 @@ export function createTerminal(
|
|
|
636
666
|
},
|
|
637
667
|
tablist: () => tablistController,
|
|
638
668
|
notifySwitch(session) {
|
|
669
|
+
// A feature's un-cancelled async (tabs bootstrap/create()/pollOnce()) can
|
|
670
|
+
// resolve after destroy() and call this; ignore it so a torn-down terminal
|
|
671
|
+
// never re-points or reopens the socket (connection.setSession below).
|
|
672
|
+
if (isDestroyed()) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
639
675
|
// Detach (design 5.1): make input safe before the socket is re-pointed.
|
|
640
676
|
// End any in-flight IME composition and clear the textarea so
|
|
641
677
|
// half-composed text is not delivered to either session, and let every
|
|
@@ -674,6 +710,12 @@ export function createTerminal(
|
|
|
674
710
|
|
|
675
711
|
// --- Feature lifecycle ---
|
|
676
712
|
let destroyed = false;
|
|
713
|
+
// Live read of `destroyed` for the post-await re-check in setupFeatures():
|
|
714
|
+
// a plain `if (destroyed)` there is narrowed to always-false by TS CFA
|
|
715
|
+
// (it cannot model destroy() firing during the await), tripping
|
|
716
|
+
// @typescript-eslint/no-unnecessary-condition. A call defeats the stale
|
|
717
|
+
// narrowing with identical runtime behavior.
|
|
718
|
+
const isDestroyed = (): boolean => destroyed;
|
|
677
719
|
function teardownFeatures(): void {
|
|
678
720
|
for (let i = instances.length - 1; i >= 0; i--) {
|
|
679
721
|
const entry = instances[i];
|
|
@@ -696,6 +738,19 @@ export function createTerminal(
|
|
|
696
738
|
}
|
|
697
739
|
try {
|
|
698
740
|
const instance = await feature.setup(makeContext(feature.name));
|
|
741
|
+
// destroy() may have run during the await above; teardownFeatures() has already
|
|
742
|
+
// swept `instances`, and destroy() is one-shot, so a straight push here would
|
|
743
|
+
// leave this instance's listeners/timers/observers (tabs' SSE + poll + window/
|
|
744
|
+
// document listeners + ResizeObservers) alive forever. Tear it down instead of
|
|
745
|
+
// registering it.
|
|
746
|
+
if (isDestroyed()) {
|
|
747
|
+
try {
|
|
748
|
+
instance.teardown();
|
|
749
|
+
} catch (err) {
|
|
750
|
+
reportError(feature.name, err);
|
|
751
|
+
}
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
699
754
|
instances.push({ feature, instance });
|
|
700
755
|
// Populate the feature value's readonly api (consumer pattern:
|
|
701
756
|
// tabs.api?.create()) via a narrow cast, and the ctx.use lookup map.
|
|
@@ -714,12 +769,6 @@ export function createTerminal(
|
|
|
714
769
|
}
|
|
715
770
|
}
|
|
716
771
|
}
|
|
717
|
-
// Features set up in the background; first paint is never gated on them.
|
|
718
|
-
void setupFeatures();
|
|
719
|
-
|
|
720
|
-
// --- Connect + focus ---
|
|
721
|
-
render.updateFontMetrics();
|
|
722
|
-
composition.positionCompositionView();
|
|
723
772
|
// A session-managing feature (tabs) drives the first connect itself once it
|
|
724
773
|
// has resolved a session id (ctx.notifySwitch -> connection.setSession, which
|
|
725
774
|
// adds ?session=<id>). Connecting here first would open a bare /ws that a
|
|
@@ -727,6 +776,23 @@ export function createTerminal(
|
|
|
727
776
|
// churning the reconnect backoff until the feature sets a session. So skip the
|
|
728
777
|
// startup connect when managed; the single-terminal presets leave it to us.
|
|
729
778
|
const sessionManaged = featureList.some((f) => f.managesSessions === true);
|
|
779
|
+
|
|
780
|
+
// Features set up in the background; first paint is never gated on them.
|
|
781
|
+
void setupFeatures().then(() => {
|
|
782
|
+
// A session-managing feature that finished setup WITHOUT initiating a
|
|
783
|
+
// connection means its bootstrap failed (no session could be listed or
|
|
784
|
+
// spawned) or its setup threw. markReady (needs a screen frame) and onGiveUp
|
|
785
|
+
// (needs the connect to be attempted and fail) then never fire, so the
|
|
786
|
+
// loading overlay would stay up forever, hiding the retry chrome the feature
|
|
787
|
+
// keeps alive (tabs' "+"). Dismiss it so the page shows its real state.
|
|
788
|
+
if (sessionManaged && !connectionInitiated && !destroyed) {
|
|
789
|
+
dismissLoadingOverlay();
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// --- Connect + focus ---
|
|
794
|
+
render.updateFontMetrics();
|
|
795
|
+
composition.positionCompositionView();
|
|
730
796
|
if (!sessionManaged) {
|
|
731
797
|
connection.connect();
|
|
732
798
|
connectionInitiated = true;
|
|
@@ -742,6 +808,12 @@ export function createTerminal(
|
|
|
742
808
|
destroyed = true;
|
|
743
809
|
teardownFeatures();
|
|
744
810
|
kernelAbort.abort();
|
|
811
|
+
viewport.teardown();
|
|
812
|
+
// Reset the composition singleton (kernel-driven, no feature teardown): a
|
|
813
|
+
// destroy() mid-IME-composition otherwise leaves module-level `composing`
|
|
814
|
+
// stuck true, so a remounted terminal swallows every keydown until an IME
|
|
815
|
+
// cycle resets it; also neutralizes a pending compositionend setTimeout.
|
|
816
|
+
composition.teardown();
|
|
745
817
|
connection.disconnect();
|
|
746
818
|
connState.destroy();
|
|
747
819
|
if (toastTimer !== null) {
|
package/src/presets.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
// lists (convenient, full UI); a consumer wanting a minimal footprint imports
|
|
5
5
|
// individual features instead.
|
|
6
6
|
//
|
|
7
|
-
// The tabbed presets (presetTabbed / presetAgentTabbed)
|
|
8
|
-
// include the activity monitor,
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
7
|
+
// The tabbed presets (presetTabbed / presetAgentTabbed) share the same feature
|
|
8
|
+
// set (both include the activity monitor, whose per-tab dot reveals itself only
|
|
9
|
+
// on OSC 9;4 progress, so a plain shell never shows one). They differ in the
|
|
10
|
+
// title source: presetAgentTabbed sets preferInputTitle (the agent's program
|
|
11
|
+
// emits a useless OSC title, so the label follows the latest submitted line),
|
|
12
|
+
// while presetTabbed is OSC-first. The generic-vs-agent STATUS distinction stays
|
|
13
|
+
// server-side — an agent server sets a classifier mapping OSC 9 notifications to
|
|
14
|
+
// done/needs-input.
|
|
14
15
|
|
|
15
16
|
import type { TerminalFeature } from "./kernel/types.js";
|
|
16
17
|
import { clipboard } from "./features/clipboard.js";
|
|
@@ -50,14 +51,14 @@ export function presetTouch(): TerminalFeature<unknown>[] {
|
|
|
50
51
|
// per tab only when a session reports activity (OSC 9;4), so the monitor is
|
|
51
52
|
// always included — a plain shell just never reveals a dot. The toolbar and
|
|
52
53
|
// monitor are ordered before tabs because tabs reads their APIs via ctx.use.
|
|
53
|
-
function buildTabbed(): TerminalFeature<unknown>[] {
|
|
54
|
+
function buildTabbed(preferInputTitle: boolean): TerminalFeature<unknown>[] {
|
|
54
55
|
const kb = mobileToolbar({ externalToggle: true });
|
|
55
56
|
const monitor = activityMonitor();
|
|
56
57
|
return [
|
|
57
58
|
...presetSingle(),
|
|
58
59
|
kb,
|
|
59
60
|
monitor,
|
|
60
|
-
tabs({ keyboardToggle: kb, activityMonitor: monitor }),
|
|
61
|
+
tabs({ keyboardToggle: kb, activityMonitor: monitor, preferInputTitle }),
|
|
61
62
|
animations(),
|
|
62
63
|
];
|
|
63
64
|
}
|
|
@@ -71,13 +72,15 @@ function buildTabbed(): TerminalFeature<unknown>[] {
|
|
|
71
72
|
* reports activity via OSC 9;4 progress (kiro-cli, Claude Code, …), so a plain
|
|
72
73
|
* bash/sh keeps clean, label-only tabs. */
|
|
73
74
|
export function presetTabbed(): TerminalFeature<unknown>[] {
|
|
74
|
-
return buildTabbed();
|
|
75
|
+
return buildTabbed(false);
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
/** Tabbed UI for an agent shell (vibecli). Same features as presetTabbed, but
|
|
79
|
+
* with `preferInputTitle`: the agent's program (kiro-cli) emits a non-empty but
|
|
80
|
+
* useless OSC 0/2 title, so each tab's label follows the latest submitted line
|
|
81
|
+
* (persisted server-side and recovered on reload) and the OSC title is ignored.
|
|
82
|
+
* The agent-vs-generic status distinction remains server-side (a status
|
|
83
|
+
* classifier mapping OSC 9 notifications to done/needs-input). */
|
|
81
84
|
export function presetAgentTabbed(): TerminalFeature<unknown>[] {
|
|
82
|
-
return buildTabbed();
|
|
85
|
+
return buildTabbed(true);
|
|
83
86
|
}
|
package/src/viewport.ts
CHANGED
|
@@ -24,6 +24,15 @@ let onSettled: ((wasAtBottom: boolean) => void) | null = null;
|
|
|
24
24
|
let inTransition = false;
|
|
25
25
|
let wasAtBottomAtStart = true;
|
|
26
26
|
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
// When true, ignore the visualViewport keyboard geometry entirely (a hardware
|
|
28
|
+
// keyboard/trackpad is present, so the soft keyboard never opens). Set at init
|
|
29
|
+
// from the kernel's hasFinePointer; see the onChange note below.
|
|
30
|
+
let suppressKeyboardInset: () => boolean = () => false;
|
|
31
|
+
// Removals for every listener/observer init() attaches, so teardown() can
|
|
32
|
+
// release them on kernel destroy(). Without it the window/visualViewport/
|
|
33
|
+
// screen.orientation listeners and the ResizeObserver survive destroy() and a
|
|
34
|
+
// re-created terminal double-binds them.
|
|
35
|
+
let cleanup: (() => void)[] = [];
|
|
27
36
|
|
|
28
37
|
function startTransition(): void {
|
|
29
38
|
// Capture pre-transition scroll state at the *start* of the transition,
|
|
@@ -51,9 +60,14 @@ function startTransition(): void {
|
|
|
51
60
|
export function init(opts: {
|
|
52
61
|
termWrap: HTMLElement;
|
|
53
62
|
onSettled: (wasAtBottom: boolean) => void;
|
|
63
|
+
/** Ignore the visualViewport keyboard geometry (a hardware-keyboard device
|
|
64
|
+
* has no soft keyboard to accommodate); only reserved bottom chrome insets
|
|
65
|
+
* the terminal. The kernel passes its hasFinePointer here. */
|
|
66
|
+
suppressKeyboardInset?: () => boolean;
|
|
54
67
|
}): void {
|
|
55
68
|
termWrap = opts.termWrap;
|
|
56
69
|
onSettled = opts.onSettled;
|
|
70
|
+
suppressKeyboardInset = opts.suppressKeyboardInset ?? (() => false);
|
|
57
71
|
|
|
58
72
|
// iOS soft keyboard. interactive-widget=resizes-content makes the
|
|
59
73
|
// layout viewport shrink; we still apply a manual bottom inset as
|
|
@@ -72,15 +86,26 @@ export function init(opts: {
|
|
|
72
86
|
// scroll" symptom). Driving top AND bottom from visualViewport keeps the
|
|
73
87
|
// terminal exactly over the visible area in every browser, so nothing is
|
|
74
88
|
// left behind the keyboard for iOS to scroll to.
|
|
75
|
-
|
|
76
|
-
|
|
89
|
+
// suppressKeyboardInset (set at init from the kernel's hasFinePointer): on
|
|
90
|
+
// a device with a hardware keyboard/trackpad (a desktop, or an iPad with a
|
|
91
|
+
// Magic Keyboard) the on-screen keyboard never opens, so there is no
|
|
92
|
+
// keyboard geometry to accommodate. iPadOS has been seen to briefly report
|
|
93
|
+
// a keyboard-sized visualViewport shrink with no keyboard shown, which this
|
|
94
|
+
// handler then pinned as a bottom inset and left stuck (the terminal "moved
|
|
95
|
+
// up ~50%", black below, surviving tab switches because nothing recomputes
|
|
96
|
+
// it — only a reload cleared it). Ignore the keyboard geometry there and
|
|
97
|
+
// keep the terminal full-height; only the reserved bottom chrome applies.
|
|
98
|
+
const offsetTop = suppressKeyboardInset() ? 0 : Math.max(0, Math.round(vv.offsetTop));
|
|
99
|
+
const bottomInset = suppressKeyboardInset()
|
|
100
|
+
? 0
|
|
101
|
+
: Math.max(0, Math.round(window.innerHeight - vv.offsetTop - vv.height));
|
|
77
102
|
// Reserved bottom chrome (a bottom tab bar) the content must clear, on top
|
|
78
103
|
// of the keyboard inset. A feature sets --wt-reserve-bottom (px) on the
|
|
79
104
|
// root, 0 when none; viewport owns the terminal's fixed-box geometry, so it
|
|
80
105
|
// folds the reserve into the bottom offset here. The reserve excludes the
|
|
81
106
|
// keyboard (it is measured with the keyboard closed), so adding it to
|
|
82
107
|
// bottomInset does not double-count.
|
|
83
|
-
const
|
|
108
|
+
const rawReserve = Math.max(
|
|
84
109
|
0,
|
|
85
110
|
Math.round(
|
|
86
111
|
parseFloat(
|
|
@@ -88,6 +113,12 @@ export function init(opts: {
|
|
|
88
113
|
) || 0,
|
|
89
114
|
),
|
|
90
115
|
);
|
|
116
|
+
// Sanity cap: the reserve is bottom chrome (a tab bar), tens of px. A value
|
|
117
|
+
// near half the screen is a bad measurement — e.g. the switcher bar
|
|
118
|
+
// measured while a phantom keyboard inset had lifted it — that would
|
|
119
|
+
// otherwise strand the lower half of the terminal black. Never let it
|
|
120
|
+
// exceed a third of the viewport height.
|
|
121
|
+
const reserve = Math.min(rawReserve, Math.round(window.innerHeight / 3));
|
|
91
122
|
const bottom = bottomInset + reserve;
|
|
92
123
|
termWrap.style.top = offsetTop > 0 ? `${offsetTop}px` : "";
|
|
93
124
|
termWrap.style.bottom = bottom > 0 ? `${bottom}px` : "";
|
|
@@ -102,6 +133,20 @@ export function init(opts: {
|
|
|
102
133
|
};
|
|
103
134
|
vv.addEventListener("resize", onChange);
|
|
104
135
|
vv.addEventListener("scroll", onChange);
|
|
136
|
+
// Self-heal a stuck inset (see the fine-pointer note above): recompute when
|
|
137
|
+
// the window regains focus or is restored from the bfcache, so a one-off bad
|
|
138
|
+
// visualViewport reading that got pinned clears on the next natural
|
|
139
|
+
// interaction instead of surviving until a full page reload.
|
|
140
|
+
window.addEventListener("focus", onChange);
|
|
141
|
+
window.addEventListener("pageshow", onChange);
|
|
142
|
+
cleanup.push(() => {
|
|
143
|
+
window.removeEventListener("focus", onChange);
|
|
144
|
+
window.removeEventListener("pageshow", onChange);
|
|
145
|
+
});
|
|
146
|
+
cleanup.push(() => {
|
|
147
|
+
vv.removeEventListener("resize", onChange);
|
|
148
|
+
vv.removeEventListener("scroll", onChange);
|
|
149
|
+
});
|
|
105
150
|
onChange();
|
|
106
151
|
}
|
|
107
152
|
|
|
@@ -109,6 +154,10 @@ export function init(opts: {
|
|
|
109
154
|
const ro = new ResizeObserver(startTransition);
|
|
110
155
|
ro.observe(termWrap);
|
|
111
156
|
window.addEventListener("resize", startTransition);
|
|
157
|
+
cleanup.push(() => {
|
|
158
|
+
ro.disconnect();
|
|
159
|
+
window.removeEventListener("resize", startTransition);
|
|
160
|
+
});
|
|
112
161
|
|
|
113
162
|
// Orientation change on mobile. iOS Safari often emits the
|
|
114
163
|
// window.resize event late or not at all on rotation while
|
|
@@ -120,7 +169,35 @@ export function init(opts: {
|
|
|
120
169
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard for older Safari without screen.orientation
|
|
121
170
|
if (orientation) {
|
|
122
171
|
orientation.addEventListener("change", startTransition);
|
|
172
|
+
cleanup.push(() => {
|
|
173
|
+
orientation.removeEventListener("change", startTransition);
|
|
174
|
+
});
|
|
123
175
|
} else if ("onorientationchange" in window) {
|
|
124
176
|
window.addEventListener("orientationchange", startTransition);
|
|
177
|
+
cleanup.push(() => {
|
|
178
|
+
window.removeEventListener("orientationchange", startTransition);
|
|
179
|
+
});
|
|
125
180
|
}
|
|
126
181
|
}
|
|
182
|
+
|
|
183
|
+
// Release every listener/observer init() attached and stop the settle timer.
|
|
184
|
+
// The kernel calls this from destroy() so a create->destroy->create remount
|
|
185
|
+
// does not leave stale global listeners double-bound.
|
|
186
|
+
export function teardown(): void {
|
|
187
|
+
for (const fn of cleanup) {
|
|
188
|
+
fn();
|
|
189
|
+
}
|
|
190
|
+
cleanup = [];
|
|
191
|
+
if (settleTimer !== null) {
|
|
192
|
+
clearTimeout(settleTimer);
|
|
193
|
+
settleTimer = null;
|
|
194
|
+
}
|
|
195
|
+
// Clear the global root CSS vars onChange published, so a destroy without
|
|
196
|
+
// a remount (which would recompute them) leaves no stale inset on :root.
|
|
197
|
+
const rootStyle = document.documentElement.style;
|
|
198
|
+
rootStyle.removeProperty("--kb-inset");
|
|
199
|
+
rootStyle.removeProperty("--vv-top");
|
|
200
|
+
inTransition = false;
|
|
201
|
+
onSettled = null;
|
|
202
|
+
suppressKeyboardInset = () => false;
|
|
203
|
+
}
|