@cplieger/web-terminal-ui 8.0.1 → 8.1.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/README.md +53 -27
- package/css/00-tokens.css +1 -1
- package/css/01-scope.css +1 -1
- package/css/10-primitives.css +1 -1
- package/css/21-scroll-to-bottom.css +3 -3
- package/css/30-tabs.css +82 -29
- package/css/31-switcher.css +5 -0
- package/css/32-split.css +67 -15
- package/css/MANIFEST.touch +1 -1
- package/css/page.css +1 -1
- package/package.json +12 -12
- package/src/features/tabs/device-snap.ts +56 -0
- package/src/features/tabs/index.ts +212 -99
- package/src/features/tabs/strip.ts +8 -3
- package/src/kernel/pane.ts +34 -14
- package/src/kernel/shell.ts +91 -16
- package/src/kernel/split-handle.ts +37 -13
- package/src/kernel/types.ts +8 -1
- package/src/viewport.ts +166 -73
package/src/viewport.ts
CHANGED
|
@@ -11,58 +11,168 @@ import { windowOf } from "./kernel/realm.js";
|
|
|
11
11
|
// and reflow.
|
|
12
12
|
const SETTLE_MS = 350;
|
|
13
13
|
|
|
14
|
+
/** The visual viewport against the layout viewport, in whole CSS px. */
|
|
15
|
+
interface KeyboardGeometry {
|
|
16
|
+
/** How far the visual viewport is scrolled down the layout viewport. */
|
|
17
|
+
readonly top: number;
|
|
18
|
+
/** How much of the layout viewport's bottom the soft keyboard covers. */
|
|
19
|
+
readonly bottom: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface KeyboardInsetsOptions {
|
|
23
|
+
/** The outermost terminal root: receives --kb-inset and --vv-top, which every
|
|
24
|
+
* pane and chrome element beneath it inherits, so they scope to the terminal
|
|
25
|
+
* subtree instead of leaking onto the host document. */
|
|
26
|
+
root: HTMLElement;
|
|
27
|
+
/** Ignore the visualViewport keyboard geometry (a hardware-keyboard device
|
|
28
|
+
* has no soft keyboard to accommodate). */
|
|
29
|
+
suppressKeyboardInset?: () => boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The document's one reading of its visual viewport. */
|
|
33
|
+
export interface KeyboardInsets {
|
|
34
|
+
/** The latest geometry; zero where the window has no visual viewport. */
|
|
35
|
+
current(): KeyboardGeometry;
|
|
36
|
+
/** Call `fn` with the geometry on every visual-viewport resize and scroll, on
|
|
37
|
+
* every window focus and bfcache restore, and on every `refresh()`; returns
|
|
38
|
+
* the release. */
|
|
39
|
+
onChange(fn: (geometry: KeyboardGeometry) => void): () => void;
|
|
40
|
+
/** Read again and call every `onChange` subscriber. A no-op after teardown. */
|
|
41
|
+
refresh(): void;
|
|
42
|
+
/** Release every listener and clear the CSS vars published on the root. */
|
|
43
|
+
teardown(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
14
46
|
export interface ViewportOptions {
|
|
15
47
|
termWrap: HTMLElement;
|
|
16
|
-
/** The
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
48
|
+
/** The box termWrap is pinned within, observed in its place: termWrap's
|
|
49
|
+
* insets follow chrome measured in ResizeObserver callbacks on boxes deeper
|
|
50
|
+
* than termWrap, and an observed box resized from such a callback is a
|
|
51
|
+
* ResizeObserver loop. So a caller that moves termWrap's insets other than
|
|
52
|
+
* through `keyboard` calls `keyboard.refresh()`, which pins again and starts
|
|
53
|
+
* a transition. */
|
|
54
|
+
box: HTMLElement;
|
|
55
|
+
/** The document's keyboard geometry, which pins the term wrap over the visible
|
|
56
|
+
* area. */
|
|
57
|
+
keyboard: KeyboardInsets;
|
|
20
58
|
/** This pane's scroll controller, read at settle. */
|
|
21
59
|
scroll: Pick<ScrollController, "isUserScrolledUp" | "stickToBottom">;
|
|
22
60
|
onSettled: (wasAtBottom: boolean) => void;
|
|
23
|
-
/** Ignore the visualViewport keyboard geometry (a hardware-keyboard device
|
|
24
|
-
* has no soft keyboard to accommodate); only reserved bottom chrome insets
|
|
25
|
-
* the terminal. */
|
|
26
|
-
suppressKeyboardInset?: () => boolean;
|
|
27
61
|
}
|
|
28
62
|
|
|
29
63
|
/** One pane's viewport tracker. */
|
|
30
64
|
export interface Viewport {
|
|
31
65
|
/** Whether a transition is in flight, so geometry measured now is provisional. */
|
|
32
66
|
isInTransition(): boolean;
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
67
|
+
/** Start a transition as a resize would, for a box about to move that has not
|
|
68
|
+
* moved yet; it settles SETTLE_MS after the last resize, as any other does. */
|
|
69
|
+
beginTransition(): void;
|
|
70
|
+
/** Release every listener and observer and stop the settle timer. */
|
|
35
71
|
teardown(): void;
|
|
36
72
|
}
|
|
37
73
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
74
|
+
// The release is pushed as the listener is acquired, so a registration that
|
|
75
|
+
// throws drains what came before it.
|
|
76
|
+
function listenInto(
|
|
77
|
+
cleanup: (() => void)[],
|
|
78
|
+
target: EventTarget,
|
|
79
|
+
type: string,
|
|
80
|
+
handler: () => void,
|
|
81
|
+
): void {
|
|
82
|
+
target.addEventListener(type, handler);
|
|
83
|
+
cleanup.push(() => {
|
|
84
|
+
target.removeEventListener(type, handler);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function drain(cleanup: (() => void)[]): void {
|
|
89
|
+
while (cleanup.length > 0) {
|
|
90
|
+
cleanup.pop()?.();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createKeyboardInsets(opts: KeyboardInsetsOptions): KeyboardInsets {
|
|
95
|
+
const { root } = opts;
|
|
41
96
|
const suppressKeyboardInset = opts.suppressKeyboardInset ?? ((): boolean => false);
|
|
42
|
-
// The terminal's own window: its viewport
|
|
43
|
-
//
|
|
97
|
+
// The terminal's own window: its viewport and its keyboard, not the importing
|
|
98
|
+
// page's.
|
|
99
|
+
const win = windowOf(root.ownerDocument);
|
|
100
|
+
const vv = win.visualViewport;
|
|
101
|
+
const subscribers = new Set<(geometry: KeyboardGeometry) => void>();
|
|
102
|
+
let geometry: KeyboardGeometry = { top: 0, bottom: 0 };
|
|
103
|
+
let live = true;
|
|
104
|
+
const cleanup: (() => void)[] = [];
|
|
105
|
+
function release(): void {
|
|
106
|
+
live = false;
|
|
107
|
+
drain(cleanup);
|
|
108
|
+
subscribers.clear();
|
|
109
|
+
root.style.removeProperty("--kb-inset");
|
|
110
|
+
root.style.removeProperty("--vv-top");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function update(): void {
|
|
114
|
+
// iPadOS has been seen to report a keyboard-sized shrink with no keyboard
|
|
115
|
+
// shown and pin it, which is what suppressKeyboardInset defends against.
|
|
116
|
+
geometry =
|
|
117
|
+
!vv || suppressKeyboardInset()
|
|
118
|
+
? { top: 0, bottom: 0 }
|
|
119
|
+
: {
|
|
120
|
+
top: Math.max(0, Math.round(vv.offsetTop)),
|
|
121
|
+
bottom: Math.max(0, Math.round(win.innerHeight - vv.offsetTop - vv.height)),
|
|
122
|
+
};
|
|
123
|
+
root.style.setProperty("--kb-inset", `${geometry.bottom}px`);
|
|
124
|
+
root.style.setProperty("--vv-top", `${geometry.top}px`);
|
|
125
|
+
for (const fn of [...subscribers]) {
|
|
126
|
+
fn(geometry);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
if (vv) {
|
|
132
|
+
listenInto(cleanup, vv, "resize", update);
|
|
133
|
+
listenInto(cleanup, vv, "scroll", update);
|
|
134
|
+
// Recompute on focus and bfcache restore, so a one-off bad visualViewport
|
|
135
|
+
// reading clears on the next natural interaction instead of at reload.
|
|
136
|
+
listenInto(cleanup, win, "focus", update);
|
|
137
|
+
listenInto(cleanup, win, "pageshow", update);
|
|
138
|
+
}
|
|
139
|
+
update();
|
|
140
|
+
} catch (err) {
|
|
141
|
+
release();
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
current: () => geometry,
|
|
147
|
+
onChange(fn) {
|
|
148
|
+
subscribers.add(fn);
|
|
149
|
+
return () => {
|
|
150
|
+
subscribers.delete(fn);
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
refresh() {
|
|
154
|
+
if (live) {
|
|
155
|
+
update();
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
teardown: release,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createViewport(opts: ViewportOptions): Viewport {
|
|
163
|
+
const { termWrap, keyboard, scroll } = opts;
|
|
164
|
+
// The terminal's own window: its viewport and its rotation, not the importing
|
|
165
|
+
// page's.
|
|
44
166
|
const win = windowOf(termWrap.ownerDocument);
|
|
45
167
|
let inTransition = false;
|
|
46
168
|
let settleTimer: number | null = null;
|
|
47
|
-
// Every listener and the observer push their release as they are acquired,
|
|
48
|
-
// so a registration that throws drains what came before it.
|
|
49
169
|
const cleanup: (() => void)[] = [];
|
|
50
|
-
function listen(target: EventTarget, type: string, handler: () => void): void {
|
|
51
|
-
target.addEventListener(type, handler);
|
|
52
|
-
cleanup.push(() => {
|
|
53
|
-
target.removeEventListener(type, handler);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
170
|
function release(): void {
|
|
57
|
-
|
|
58
|
-
cleanup.pop()?.();
|
|
59
|
-
}
|
|
171
|
+
drain(cleanup);
|
|
60
172
|
if (settleTimer !== null) {
|
|
61
173
|
win.clearTimeout(settleTimer);
|
|
62
174
|
settleTimer = null;
|
|
63
175
|
}
|
|
64
|
-
varTarget.style.removeProperty("--kb-inset");
|
|
65
|
-
varTarget.style.removeProperty("--vv-top");
|
|
66
176
|
inTransition = false;
|
|
67
177
|
}
|
|
68
178
|
|
|
@@ -86,57 +196,39 @@ export function createViewport(opts: ViewportOptions): Viewport {
|
|
|
86
196
|
}, SETTLE_MS);
|
|
87
197
|
}
|
|
88
198
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// The reserve is a tab bar, tens of px; a value near half the screen is a
|
|
111
|
-
// bad measurement that would strand the lower half of the terminal black.
|
|
112
|
-
const reserve = Math.min(rawReserve, Math.round(win.innerHeight / 3));
|
|
113
|
-
const bottom = bottomInset + reserve;
|
|
114
|
-
termWrap.style.top = offsetTop > 0 ? `${offsetTop}px` : "";
|
|
115
|
-
termWrap.style.bottom = bottom > 0 ? `${bottom}px` : "";
|
|
116
|
-
varTarget.style.setProperty("--kb-inset", `${bottomInset}px`);
|
|
117
|
-
varTarget.style.setProperty("--vv-top", `${offsetTop}px`);
|
|
118
|
-
startTransition();
|
|
119
|
-
};
|
|
120
|
-
listen(vv, "resize", onChange);
|
|
121
|
-
listen(vv, "scroll", onChange);
|
|
122
|
-
// Recompute on focus and bfcache restore, so a one-off bad visualViewport
|
|
123
|
-
// reading clears on the next natural interaction instead of at reload.
|
|
124
|
-
listen(win, "focus", onChange);
|
|
125
|
-
listen(win, "pageshow", onChange);
|
|
126
|
-
onChange();
|
|
199
|
+
// iOS shrinks (and can offset) the visual viewport without resizing the layout
|
|
200
|
+
// viewport, so a `position: fixed; inset: 0` box keeps the full-screen height
|
|
201
|
+
// behind the keyboard; driving top and bottom from the keyboard geometry keeps
|
|
202
|
+
// the terminal over the visible area everywhere.
|
|
203
|
+
function pin(geometry: KeyboardGeometry): void {
|
|
204
|
+
// A feature sets --wt-reserve-bottom (px) on the root for bottom chrome the
|
|
205
|
+
// content must clear; it is measured with the keyboard closed, so adding it
|
|
206
|
+
// to the keyboard inset does not double-count.
|
|
207
|
+
const rawReserve = Math.max(
|
|
208
|
+
0,
|
|
209
|
+
Math.round(
|
|
210
|
+
parseFloat(win.getComputedStyle(termWrap).getPropertyValue("--wt-reserve-bottom")) || 0,
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
// The reserve is a tab bar, tens of px; a value near half the screen is a
|
|
214
|
+
// bad measurement that would strand the lower half of the terminal black.
|
|
215
|
+
const reserve = Math.min(rawReserve, Math.round(win.innerHeight / 3));
|
|
216
|
+
const bottom = geometry.bottom + reserve;
|
|
217
|
+
termWrap.style.top = geometry.top > 0 ? `${geometry.top}px` : "";
|
|
218
|
+
termWrap.style.bottom = bottom > 0 ? `${bottom}px` : "";
|
|
219
|
+
startTransition();
|
|
127
220
|
}
|
|
128
221
|
|
|
129
222
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
223
|
+
cleanup.push(keyboard.onChange(pin));
|
|
224
|
+
pin(keyboard.current());
|
|
133
225
|
|
|
134
226
|
const ro = new win.ResizeObserver(startTransition);
|
|
135
227
|
cleanup.push(() => {
|
|
136
228
|
ro.disconnect();
|
|
137
229
|
});
|
|
138
|
-
ro.observe(
|
|
139
|
-
|
|
230
|
+
ro.observe(opts.box);
|
|
231
|
+
listenInto(cleanup, win, "resize", startTransition);
|
|
140
232
|
|
|
141
233
|
// iOS Safari often emits window.resize late or not at all on rotation while
|
|
142
234
|
// screen.orientation.change survives; older Safari has only the deprecated
|
|
@@ -144,9 +236,9 @@ export function createViewport(opts: ViewportOptions): Viewport {
|
|
|
144
236
|
const orientation = (win.screen as Screen & { orientation?: ScreenOrientation }).orientation;
|
|
145
237
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard for older Safari without screen.orientation
|
|
146
238
|
if (orientation) {
|
|
147
|
-
|
|
239
|
+
listenInto(cleanup, orientation, "change", startTransition);
|
|
148
240
|
} else if ("onorientationchange" in win) {
|
|
149
|
-
|
|
241
|
+
listenInto(cleanup, win, "orientationchange", startTransition);
|
|
150
242
|
}
|
|
151
243
|
} catch (err) {
|
|
152
244
|
release();
|
|
@@ -155,6 +247,7 @@ export function createViewport(opts: ViewportOptions): Viewport {
|
|
|
155
247
|
|
|
156
248
|
return {
|
|
157
249
|
isInTransition: () => inTransition,
|
|
250
|
+
beginTransition: startTransition,
|
|
158
251
|
teardown: release,
|
|
159
252
|
};
|
|
160
253
|
}
|