@cplieger/web-terminal-ui 8.0.1 → 8.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/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 terminal root: receives the geometry CSS vars the sibling chrome reads
17
- * (--kb-inset, --vv-top), so they scope to the terminal subtree instead of
18
- * leaking onto the host document. */
19
- root?: HTMLElement;
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
- /** Release every listener and observer, stop the settle timer, and clear the
34
- * CSS vars published on the root. */
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
- export function createViewport(opts: ViewportOptions): Viewport {
39
- const { termWrap, scroll } = opts;
40
- const varTarget = opts.root ?? opts.termWrap;
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, its keyboard and its rotation, not
43
- // the importing page's.
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
- while (cleanup.length > 0) {
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
- function keyboardInsets(vv: VisualViewport): void {
90
- const onChange = (): void => {
91
- // iOS shrinks (and can offset) the visual viewport without resizing the
92
- // layout viewport, so a `position: fixed; inset: 0` box keeps the
93
- // full-screen height behind the keyboard; driving top and bottom from
94
- // visualViewport keeps the terminal over the visible area everywhere.
95
- // iPadOS has been seen to report a keyboard-sized shrink with no keyboard
96
- // shown and pin it, which is what suppressKeyboardInset defends against.
97
- const offsetTop = suppressKeyboardInset() ? 0 : Math.max(0, Math.round(vv.offsetTop));
98
- const bottomInset = suppressKeyboardInset()
99
- ? 0
100
- : Math.max(0, Math.round(win.innerHeight - vv.offsetTop - vv.height));
101
- // A feature sets --wt-reserve-bottom (px) on the root for bottom chrome the
102
- // content must clear; it is measured with the keyboard closed, so adding it
103
- // to the keyboard inset does not double-count.
104
- const rawReserve = Math.max(
105
- 0,
106
- Math.round(
107
- parseFloat(win.getComputedStyle(termWrap).getPropertyValue("--wt-reserve-bottom")) || 0,
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
- if (win.visualViewport) {
131
- keyboardInsets(win.visualViewport);
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(termWrap);
139
- listen(win, "resize", startTransition);
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
- listen(orientation, "change", startTransition);
239
+ listenInto(cleanup, orientation, "change", startTransition);
148
240
  } else if ("onorientationchange" in win) {
149
- listen(win, "orientationchange", startTransition);
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
  }