@cplieger/web-terminal-ui 2.1.0 → 2.1.2

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": "2.1.0",
3
+ "version": "2.1.2",
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",
@@ -31,8 +31,8 @@
31
31
  "fast-check": "4.8.0",
32
32
  "happy-dom": "20.10.6",
33
33
  "html-validate": "11.5.3",
34
- "knip": "6.22.0",
35
- "prettier": "3.9.0",
34
+ "knip": "6.23.0",
35
+ "prettier": "3.9.1",
36
36
  "stylelint": "17.14.0",
37
37
  "stylelint-config-standard": "40.0.0",
38
38
  "typescript": "6.0.3",
@@ -32,6 +32,7 @@ const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
32
32
 
33
33
  let textarea: HTMLTextAreaElement;
34
34
  let compositionView: HTMLElement;
35
+ let scrollEl: HTMLElement;
35
36
  let getCursorPx: () => { left: number; top: number; cellH: number };
36
37
  let send: (bytes: string) => void;
37
38
 
@@ -43,11 +44,13 @@ let compositionSuffix = "";
43
44
  export function init(opts: {
44
45
  textarea: HTMLTextAreaElement;
45
46
  compositionView: HTMLElement;
47
+ scrollEl: HTMLElement;
46
48
  getCursorPx: () => { left: number; top: number; cellH: number };
47
49
  send: (bytes: string) => void;
48
50
  }): void {
49
51
  textarea = opts.textarea;
50
52
  compositionView = opts.compositionView;
53
+ scrollEl = opts.scrollEl;
51
54
  getCursorPx = opts.getCursorPx;
52
55
  send = opts.send;
53
56
 
@@ -137,13 +140,25 @@ function onPaste(ev: ClipboardEvent): void {
137
140
  * the focused textarea is on screen at a sane location. */
138
141
  export function positionCompositionView(): void {
139
142
  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.
140
148
  compositionView.style.left = `${left}px`;
141
149
  compositionView.style.top = `${top}px`;
142
150
  compositionView.style.height = `${cellH}px`;
143
151
  compositionView.style.lineHeight = `${cellH}px`;
144
- // Textarea piggy-backs on the cursor position so iOS scroll-into-
145
- // view targets the cursor area instead of jumping to top-left.
146
- textarea.style.left = `${left}px`;
147
- textarea.style.top = `${top}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`;
148
163
  textarea.style.height = `${cellH}px`;
149
164
  }
package/src/mount.ts CHANGED
@@ -437,6 +437,7 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
437
437
  composition.init({
438
438
  textarea: input,
439
439
  compositionView: compositionViewEl,
440
+ scrollEl: termWrap,
440
441
  getCursorPx: render.getCursorPx,
441
442
  send,
442
443
  });
@@ -645,6 +646,25 @@ export function mount(root: HTMLElement, opts: MountOptions = {}): TerminalUI {
645
646
  },
646
647
  { passive: true },
647
648
  );
649
+ // After a touch tap, iOS/WebKit dispatches a synthetic `mousedown` (following
650
+ // the `pointerup` above). Its default action moves focus to the nearest
651
+ // focusable ancestor of the tap point — but the input-model contract keeps
652
+ // `.term-output` and `.term` deliberately non-focusable (display-only) and the
653
+ // hidden textarea is `pointer-events: none`, so the tap never lands on a
654
+ // focusable node. With nothing focusable under the tap, that default blurs the
655
+ // textarea we just focused in `pointerup`, so iOS immediately dismisses the
656
+ // soft keyboard the tap had only just opened — the "keyboard flashes open then
657
+ // instantly closes" symptom on touch. Cancelling the synthetic mousedown's
658
+ // default keeps focus on the textarea, so the keyboard stays up. This is the
659
+ // xterm.js focus-preservation pattern, scoped to touch: a real mouse mousedown
660
+ // must keep its default so desktop drag-to-select text still works, and iOS
661
+ // text selection is long-press-driven (not this tap mousedown), so it is
662
+ // unaffected. Not passive — preventDefault must take effect.
663
+ termWrap.addEventListener("mousedown", (e) => {
664
+ if (lastPointerType === "touch") {
665
+ e.preventDefault();
666
+ }
667
+ });
648
668
  termWrap.addEventListener("click", (e) => {
649
669
  // Terminal links: URLs rendered by the engine are wrapped in
650
670
  // <a class="term-link" target="_blank" rel="noopener noreferrer">. The