@cplieger/web-terminal-ui 8.4.0 → 8.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "8.4.0",
3
+ "version": "8.5.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",
@@ -219,9 +219,10 @@ export interface PaneKernel extends PaneHandle {
219
219
  * gets when the closing transition ends. */
220
220
  setHidden(value: boolean): void;
221
221
  /** The shell is about to slide this pane's box: nothing it measures from now
222
- * until its viewport settles is final, and the settle sends the size. Live
223
- * panes only: a failed pane is torn down before the split next opens. */
224
- holdGeometry(): void;
222
+ * until its viewport settles after the returned release is final, and the
223
+ * settle sends the size. Live panes only: a failed pane is torn down before
224
+ * the split next opens. */
225
+ holdGeometry(): () => void;
225
226
  /** Whether this pane's input is a stop in the document's Tab order. On while
226
227
  * the split is open and the pane shows a tab, so Tab runs left input, divider,
227
228
  * right input, chrome; off otherwise, where typing or a click enters the one
@@ -1508,7 +1509,7 @@ function buildPaneInto(
1508
1509
  hidden = value;
1509
1510
  },
1510
1511
  holdGeometry() {
1511
- viewport.beginTransition();
1512
+ return viewport.hold();
1512
1513
  },
1513
1514
  setTabStop(value) {
1514
1515
  input.tabIndex = value ? 0 : -1;
@@ -508,7 +508,9 @@ function createShellInto(
508
508
  return cssTimeMs(win.getComputedStyle(shellRoot).getPropertyValue("--dur-standard"));
509
509
  }
510
510
  function slideFor(durationMs: number, tail: () => void): Slide {
511
+ let live = true;
511
512
  const stop = (): void => {
513
+ live = false;
512
514
  win.clearTimeout(timer);
513
515
  shellRoot.removeEventListener("transitionend", onEnd);
514
516
  };
@@ -518,12 +520,39 @@ function createShellInto(
518
520
  };
519
521
  const onEnd = (ev: TransitionEvent): void => {
520
522
  if (ev.target === shellRoot && ev.propertyName === "grid-template-columns") {
523
+ settleWhenStill();
524
+ }
525
+ };
526
+ // Removing the slide class does not stop a running transition (the fallback
527
+ // `transition-property: all` still matches it), so a slide whose first frame
528
+ // came late would finish while its columns still move, announcing mid-slide
529
+ // sizes. Nor is every transitionend this slide's: the previous slide's can
530
+ // land after its finish. So the timer and transitionend settle only once no
531
+ // columns transition runs; a direct `settle()` still ends the slide at once.
532
+ const settleWhenStill = (): void => {
533
+ if (!live) {
534
+ return;
535
+ }
536
+ // A completed transition can stay listed until the next style change event
537
+ // (https://www.w3.org/TR/css-transitions-1/#complete); waiting on its settled
538
+ // `finished` would rerun this check in microtasks forever.
539
+ const running = shellRoot
540
+ .getAnimations()
541
+ .filter(
542
+ (a) =>
543
+ a.playState === "running" &&
544
+ "transitionProperty" in a &&
545
+ a.transitionProperty === "grid-template-columns",
546
+ );
547
+ if (running.length === 0) {
521
548
  settle();
549
+ return;
522
550
  }
551
+ void Promise.allSettled(running.map((a) => a.finished)).then(settleWhenStill);
523
552
  };
524
553
  shellRoot.addEventListener("transitionend", onEnd);
525
554
  // An engine that applies a track-list change at once fires no transitionend.
526
- const timer = win.setTimeout(settle, durationMs);
555
+ const timer = win.setTimeout(settleWhenStill, durationMs);
527
556
  return { settle, abort: stop };
528
557
  }
529
558
  function beginClosing(toward: 0 | 1, durationMs: number, tail: () => void): void {
@@ -546,9 +575,12 @@ function createShellInto(
546
575
  * start shows the new pane 0 px wide, so the panes hold their geometry until
547
576
  * it is over, or a socket opening meanwhile would size its PTY to that. */
548
577
  function beginOpening(durationMs: number): void {
549
- for (const p of builtPanes()) {
550
- p.holdGeometry();
551
- }
578
+ const holds = builtPanes().map((p) => p.holdGeometry());
579
+ const release = (): void => {
580
+ for (const r of holds) {
581
+ r();
582
+ }
583
+ };
552
584
  shellRoot.style.setProperty("--wt-split-ratio", "1");
553
585
  // The read commits the start as a style of its own; without it the slide
554
586
  // would start from the closed view's single column.
@@ -560,6 +592,7 @@ function createShellInto(
560
592
  for (const p of builtPanes()) {
561
593
  p.announceSize();
562
594
  }
595
+ release();
563
596
  });
564
597
  opening = {
565
598
  settle: slide.settle,
@@ -567,6 +600,7 @@ function createShellInto(
567
600
  slide.abort();
568
601
  opening = null;
569
602
  shellRoot.classList.remove("wt-split-opening");
603
+ release();
570
604
  },
571
605
  };
572
606
  applyRatioVar();
package/src/viewport.ts CHANGED
@@ -79,8 +79,11 @@ export interface Viewport {
79
79
  /** Whether a transition is in flight, so geometry measured now is provisional. */
80
80
  isInTransition(): boolean;
81
81
  /** Start a transition as a resize would, for a box about to move that has not
82
- * moved yet; it settles SETTLE_MS after the last resize, as any other does. */
83
- beginTransition(): void;
82
+ * moved yet, and keep it from settling until the returned release runs (which
83
+ * is idempotent). It settles SETTLE_MS after the last hold or resize, as any
84
+ * transition does; a settle that came due while held runs SETTLE_MS after the
85
+ * last release instead. */
86
+ hold(): () => void;
84
87
  /** Release every listener and observer and stop the settle timer. */
85
88
  teardown(): void;
86
89
  }
@@ -185,6 +188,7 @@ export function createViewport(opts: ViewportOptions): Viewport {
185
188
  // page's.
186
189
  const win = windowOf(termWrap.ownerDocument);
187
190
  let inTransition = false;
191
+ let holds = 0;
188
192
  let settleTimer: number | null = null;
189
193
  const cleanup: (() => void)[] = [];
190
194
  function release(): void {
@@ -203,6 +207,9 @@ export function createViewport(opts: ViewportOptions): Viewport {
203
207
  }
204
208
  settleTimer = win.setTimeout(() => {
205
209
  settleTimer = null;
210
+ if (holds > 0) {
211
+ return;
212
+ }
206
213
  inTransition = false;
207
214
  // Read at SETTLE, not at the start of the burst: every further event
208
215
  // re-arms the timer without re-reading, and on iOS a wake emits a stream
@@ -215,6 +222,22 @@ export function createViewport(opts: ViewportOptions): Viewport {
215
222
  opts.onSettled(stillFollowing);
216
223
  }, SETTLE_MS);
217
224
  }
225
+ function hold(): () => void {
226
+ holds++;
227
+ startTransition();
228
+ let held = true;
229
+ return () => {
230
+ if (!held) {
231
+ return;
232
+ }
233
+ held = false;
234
+ holds--;
235
+ // A settle that came due while held was dropped: start it again.
236
+ if (holds === 0 && inTransition && settleTimer === null) {
237
+ startTransition();
238
+ }
239
+ };
240
+ }
218
241
 
219
242
  // iOS shrinks (and can offset) the visual viewport without resizing the layout
220
243
  // viewport, so a `position: fixed; inset: 0` box keeps the full-screen height
@@ -267,7 +290,7 @@ export function createViewport(opts: ViewportOptions): Viewport {
267
290
 
268
291
  return {
269
292
  isInTransition: () => inTransition,
270
- beginTransition: startTransition,
293
+ hold,
271
294
  teardown: release,
272
295
  };
273
296
  }