@cplieger/web-terminal-ui 2.1.2 → 2.1.4

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/css/02-app.css CHANGED
@@ -57,15 +57,19 @@
57
57
  }
58
58
 
59
59
  .term-input {
60
- /* Keep the input INSIDE the viewport (not at left:-9999px) — iOS
61
- * Safari is increasingly strict about not opening the virtual
62
- * keyboard for off-screen inputs, especially during heavy DOM
63
- * mutations like a streaming agent turn. A 1×1 fully-transparent
64
- * pinned-bottom input is "visible enough" for iOS without being
65
- * tappable or perceptible to the user. opacity:0 + size:1px hides
66
- * it visually; pointer-events:none makes sure stray taps don't
67
- * land on it (programmatic focus() still works). */
68
- position: fixed;
60
+ /* The hidden keyboard target. position:absolute (NOT fixed) so it lives in
61
+ the terminal's scroll/content coordinate space alongside .composition-view
62
+ and .pred-cursor: composition.positionCompositionView() moves it to the
63
+ cursor row, and because .term is pinned to the visual viewport (viewport.ts)
64
+ the textarea always sits at the VISIBLE cursor, above the keyboard, which is
65
+ where iOS wants the focused input so it does not scroll the page. As
66
+ position:fixed it was placed at the cursor's content offset in viewport
67
+ space, so once output had scrolled it landed off-screen / behind the
68
+ keyboard, and focusing it made iOS scroll the page up to reveal it (the
69
+ content jump). Kept on-screen and 1x1 / opacity:0 so iOS still opens the
70
+ keyboard for it; pointer-events:none so taps fall through to the scroll
71
+ surface (programmatic focus() still works). */
72
+ position: absolute;
69
73
  inset-block-end: 0;
70
74
  inset-inline-start: 0;
71
75
  width: 1px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "type": "module",
5
5
  "description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: textarea input model, mobile key toolbar, context menu, IME, predictive echo, viewport/keyboard-inset handling, and CSS. One mount() call.",
6
6
  "license": "GPL-3.0-or-later",
@@ -21,7 +21,7 @@
21
21
  "verify": "bash scripts/verify.sh"
22
22
  },
23
23
  "peerDependencies": {
24
- "@cplieger/web-terminal-engine": ">=1.2.0"
24
+ "@cplieger/web-terminal-engine": ">=1.4.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@eslint/js": "10.0.1",
@@ -32,11 +32,11 @@
32
32
  "happy-dom": "20.10.6",
33
33
  "html-validate": "11.5.3",
34
34
  "knip": "6.23.0",
35
- "prettier": "3.9.1",
35
+ "prettier": "3.9.3",
36
36
  "stylelint": "17.14.0",
37
37
  "stylelint-config-standard": "40.0.0",
38
38
  "typescript": "6.0.3",
39
- "typescript-eslint": "8.62.0",
39
+ "typescript-eslint": "8.62.1",
40
40
  "vitest": "4.1.9"
41
41
  },
42
42
  "publishConfig": {
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover,interactive-widget=resizes-content">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
6
6
  <meta name="color-scheme" content="dark">
7
7
  <title>Web Terminal</title>
8
8
  <!-- App icons and web-app manifest are deployment-specific; add your own. -->
@@ -32,7 +32,6 @@ const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
32
32
 
33
33
  let textarea: HTMLTextAreaElement;
34
34
  let compositionView: HTMLElement;
35
- let scrollEl: HTMLElement;
36
35
  let getCursorPx: () => { left: number; top: number; cellH: number };
37
36
  let send: (bytes: string) => void;
38
37
 
@@ -44,13 +43,11 @@ let compositionSuffix = "";
44
43
  export function init(opts: {
45
44
  textarea: HTMLTextAreaElement;
46
45
  compositionView: HTMLElement;
47
- scrollEl: HTMLElement;
48
46
  getCursorPx: () => { left: number; top: number; cellH: number };
49
47
  send: (bytes: string) => void;
50
48
  }): void {
51
49
  textarea = opts.textarea;
52
50
  compositionView = opts.compositionView;
53
- scrollEl = opts.scrollEl;
54
51
  getCursorPx = opts.getCursorPx;
55
52
  send = opts.send;
56
53
 
@@ -140,25 +137,17 @@ function onPaste(ev: ClipboardEvent): void {
140
137
  * the focused textarea is on screen at a sane location. */
141
138
  export function positionCompositionView(): void {
142
139
  const { left, top, cellH } = getCursorPx();
143
- // getCursorPx() returns the cursor position in the scroll container's CONTENT
144
- // coordinate space (measured from the top of all rows, scrollback included).
145
- // The composition view is position:absolute inside that scroll container, so
146
- // it scrolls with the content — content coordinates place it at the visible
147
- // cursor directly.
148
140
  compositionView.style.left = `${left}px`;
149
141
  compositionView.style.top = `${top}px`;
150
142
  compositionView.style.height = `${cellH}px`;
151
143
  compositionView.style.lineHeight = `${cellH}px`;
152
- // The textarea is position:fixed (viewport space) so iOS reliably opens the
153
- // soft keyboard for it. A fixed element does NOT scroll with the content, so
154
- // the content-space cursor offset must be converted to viewport space by
155
- // subtracting the scroll offset. Without this, once a screenful of output has
156
- // scrolled the cursor's content offset well below the viewport, the fixed
157
- // textarea lands far off-screen; focusing it makes iOS scroll the page up to
158
- // pull the focused input into view, pushing the terminal output and prompt
159
- // behind the keyboard until the user scrolls back. Anchoring the textarea at
160
- // the VISIBLE cursor keeps it on-screen so iOS has no reason to scroll.
161
- textarea.style.left = `${left - scrollEl.scrollLeft}px`;
162
- textarea.style.top = `${top - scrollEl.scrollTop}px`;
144
+ // The textarea is position:absolute in the same scroll/content space as the
145
+ // composition view (see the .term-input CSS note), so the cursor's content
146
+ // coordinates place it at the cursor row directly. Because .term is pinned to
147
+ // the visual viewport (viewport.ts), that keeps the textarea at the VISIBLE
148
+ // cursor, above the keyboard — where iOS wants the focused input so it doesn't
149
+ // scroll the page to reveal it.
150
+ textarea.style.left = `${left}px`;
151
+ textarea.style.top = `${top}px`;
163
152
  textarea.style.height = `${cellH}px`;
164
153
  }
package/src/mount.ts CHANGED
@@ -437,7 +437,6 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
437
437
  composition.init({
438
438
  textarea: input,
439
439
  compositionView: compositionViewEl,
440
- scrollEl: termWrap,
441
440
  getCursorPx: render.getCursorPx,
442
441
  send,
443
442
  });
@@ -470,6 +469,11 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
470
469
  document.title = msg.title;
471
470
  } else if (msg.type === "modes") {
472
471
  render.updateReverseVideo();
472
+ } else if (msg.type === "clipboard") {
473
+ // An app copied via OSC 52; mirror it to the system clipboard. This is
474
+ // an async push (not a user gesture), so Safari/Firefox may reject
475
+ // writeText — copyToClipboard surfaces that as a "Copy failed" toast.
476
+ copyToClipboard(msg.text);
473
477
  }
474
478
  },
475
479
  onOpen() {
package/src/viewport.ts CHANGED
@@ -61,15 +61,28 @@ export function init(opts: {
61
61
  if (window.visualViewport) {
62
62
  const vv = window.visualViewport;
63
63
  const onChange = (): void => {
64
- const inset = Math.max(0, Math.round(window.innerHeight - vv.height));
65
- termWrap.style.bottom = inset > 0 ? `${inset}px` : "";
66
- // Expose viewport geometry as CSS vars so fixed chrome stays in view:
67
- // --kb-inset lifts the bottom banner above the keyboard; --vv-top lets
68
- // the top toolbar counter iOS shifting content up to reveal the
69
- // bottom-pinned input (otherwise the toolbar scrolls off the top).
64
+ // Pin the terminal's fixed box to the exact region the VISUAL viewport
65
+ // occupies inside the layout viewport. On iOS the soft keyboard shrinks
66
+ // (and can offset) the visual viewport WITHOUT resizing the layout
67
+ // viewport — interactive-widget=resizes-content is not honored on iOS
68
+ // Safari — so a `position: fixed; inset: 0` terminal keeps the full-screen
69
+ // height (keyboard overlaying the bottom) while iOS scrolls the focused
70
+ // input into view and shifts the fixed box up, sliding the output and
71
+ // prompt behind the keyboard (the "content jumps up, hidden until I
72
+ // scroll" symptom). Driving top AND bottom from visualViewport keeps the
73
+ // terminal exactly over the visible area in every browser, so nothing is
74
+ // left behind the keyboard for iOS to scroll to.
75
+ const offsetTop = Math.max(0, Math.round(vv.offsetTop));
76
+ const bottomInset = Math.max(0, Math.round(window.innerHeight - vv.offsetTop - vv.height));
77
+ termWrap.style.top = offsetTop > 0 ? `${offsetTop}px` : "";
78
+ termWrap.style.bottom = bottomInset > 0 ? `${bottomInset}px` : "";
79
+ // Expose the same geometry as CSS vars for the sibling fixed chrome:
80
+ // --kb-inset lifts the bottom banner above the keyboard; --vv-top lets the
81
+ // top key toolbar follow the visual viewport's offset (otherwise it
82
+ // scrolls off the top when iOS shifts the layout up).
70
83
  const root = document.documentElement.style;
71
- root.setProperty("--kb-inset", `${inset}px`);
72
- root.setProperty("--vv-top", `${Math.max(0, Math.round(vv.offsetTop))}px`);
84
+ root.setProperty("--kb-inset", `${bottomInset}px`);
85
+ root.setProperty("--vv-top", `${offsetTop}px`);
73
86
  startTransition();
74
87
  };
75
88
  vv.addEventListener("resize", onChange);