@cplieger/web-terminal-ui 5.2.3 → 5.3.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/css/page.css CHANGED
@@ -215,35 +215,72 @@ body {
215
215
  different typeface per engine (see --label-ink-shift in 00-tokens.css). An
216
216
  embedder does not serve these files and never inherits these URLs, so it
217
217
  either ships its own @font-face or lets both tokens fall back to the platform
218
- monospace stack. */
218
+ monospace stack.
219
+
220
+ EVERY face overrides the font's vertical metrics to 0.995em over the baseline
221
+ and 0.250em under, and that is load-bearing for the terminal, not taste.
222
+
223
+ A cell's background is painted by an inline span, and an inline box paints its
224
+ FONT's content area, never the line box: at `font-size: 14px` with
225
+ `line-height: 17px` (02-terminal.css) a font whose ascent + descent is under
226
+ 17px leaves an unpainted stripe between consecutive rows, so an application
227
+ drawing a solid column of background (kiro-cli's green and purple block
228
+ gutters) renders as dashes. GitHub's own Monaspace webfont declares
229
+ ascent 1890/2000 and descent 400/2000 with USE_TYPO_METRICS set, which is
230
+ 0.945em + 0.200em = 16.03px at 14px, and Chromium rounds that to a 16px box
231
+ inside a 17px row: a 1px gap on every row boundary, measured in the sidecar.
232
+
233
+ 0.995em + 0.250em is what the nerd-fonts-patched MonaspiceNe NFM OTFs carried
234
+ until 5.3.0 swapped them for these WOFF2 files (the swap was gated on
235
+ horizontal advances only, which is how the regression shipped). It rounds to
236
+ an 18px box, so a row's background overhangs its box by 1px and consecutive
237
+ rows meet with no seam — and the baseline does not move (13px from the row's
238
+ top edge, before and after), so no glyph shifts by restoring it. It is also
239
+ the metric pair the rest of this package is already tuned and documented
240
+ against: --label-ink-shift's sawtooth and ink-centre.ts both derive from
241
+ 0.995em / 0.250em by name.
242
+
243
+ Do not "modernise" these away, and do not close the gap by lowering
244
+ `line-height` instead: that shrinks the cell, so it changes how many rows fit
245
+ a viewport and what the engine reports to the PTY. If a Monaspace bump ever
246
+ changes the outlines enough that a 0.995em ascent clips them, re-measure both
247
+ halves together — the terminal needs ascent + descent >= the cell height. */
219
248
  @font-face {
220
- font-family: "MonaspiceNe NFM";
221
- src: url("/vendor/fonts/MonaspiceNeNerdFontMono-Regular.otf") format("opentype");
249
+ font-family: "Monaspace Neon NF";
250
+ src: url("/vendor/fonts/MonaspaceNeonNF-Regular.woff2") format("woff2");
222
251
  font-weight: 400;
223
252
  font-style: normal;
224
253
  font-display: block;
254
+ ascent-override: 99.5%;
255
+ descent-override: 25%;
225
256
  }
226
257
 
227
258
  @font-face {
228
- font-family: "MonaspiceNe NFM";
229
- src: url("/vendor/fonts/MonaspiceNeNerdFontMono-Bold.otf") format("opentype");
259
+ font-family: "Monaspace Neon NF";
260
+ src: url("/vendor/fonts/MonaspaceNeonNF-Bold.woff2") format("woff2");
230
261
  font-weight: 700;
231
262
  font-style: normal;
232
263
  font-display: block;
264
+ ascent-override: 99.5%;
265
+ descent-override: 25%;
233
266
  }
234
267
 
235
268
  @font-face {
236
- font-family: "MonaspiceNe NFM";
237
- src: url("/vendor/fonts/MonaspiceNeNerdFontMono-Italic.otf") format("opentype");
269
+ font-family: "Monaspace Neon NF";
270
+ src: url("/vendor/fonts/MonaspaceNeonNF-Italic.woff2") format("woff2");
238
271
  font-weight: 400;
239
272
  font-style: italic;
240
273
  font-display: block;
274
+ ascent-override: 99.5%;
275
+ descent-override: 25%;
241
276
  }
242
277
 
243
278
  @font-face {
244
- font-family: "MonaspiceNe NFM";
245
- src: url("/vendor/fonts/MonaspiceNeNerdFontMono-BoldItalic.otf") format("opentype");
279
+ font-family: "Monaspace Neon NF";
280
+ src: url("/vendor/fonts/MonaspaceNeonNF-BoldItalic.woff2") format("woff2");
246
281
  font-weight: 700;
247
282
  font-style: italic;
248
283
  font-display: block;
284
+ ascent-override: 99.5%;
285
+ descent-override: 25%;
249
286
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/web-terminal-ui",
3
- "version": "5.2.3",
3
+ "version": "5.3.1",
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": "GPL-3.0-or-later",
@@ -40,7 +40,7 @@
40
40
  "verify": "bash scripts/verify.sh"
41
41
  },
42
42
  "peerDependencies": {
43
- "@cplieger/web-terminal-engine": "^3.3.0"
43
+ "@cplieger/web-terminal-engine": "^3.7.0"
44
44
  },
45
45
  "overrides": {
46
46
  "qs": "^6.15.2"
@@ -53,16 +53,16 @@
53
53
  "@types/node": "25.9.5",
54
54
  "@typescript/native": "npm:typescript@7.0.2",
55
55
  "@vitest/coverage-v8": "4.1.10",
56
- "eslint": "10.8.0",
56
+ "eslint": "10.8.1",
57
57
  "fast-check": "4.9.0",
58
- "happy-dom": "20.11.1",
58
+ "happy-dom": "20.11.2",
59
59
  "html-validate": "11.6.2",
60
- "knip": "6.31.0",
60
+ "knip": "6.32.0",
61
61
  "prettier": "3.9.6",
62
62
  "stylelint": "17.14.1",
63
63
  "stylelint-config-standard": "40.0.0",
64
64
  "typescript": "npm:@typescript/typescript6@6.0.2",
65
- "typescript-eslint": "8.65.0",
65
+ "typescript-eslint": "8.66.0",
66
66
  "vitest": "4.1.10"
67
67
  },
68
68
  "publishConfig": {
@@ -72,6 +72,22 @@
72
72
  "type": "git",
73
73
  "url": "git+https://github.com/cplieger/web-terminal-ui.git"
74
74
  },
75
+ "homepage": "https://github.com/cplieger/web-terminal-ui",
76
+ "bugs": {
77
+ "url": "https://github.com/cplieger/web-terminal-ui/issues"
78
+ },
79
+ "author": "cplieger <917744+cplieger@users.noreply.github.com>",
80
+ "keywords": [
81
+ "terminal",
82
+ "terminal-ui",
83
+ "web-terminal",
84
+ "touch",
85
+ "mobile",
86
+ "tabs",
87
+ "ime",
88
+ "browser",
89
+ "typescript"
90
+ ],
75
91
  "files": [
76
92
  "src/",
77
93
  "css/",
@@ -1,50 +1,57 @@
1
- // contextMenu feature: the right-click / long-press menu (Copy, Select All,
2
- // Paste) rendered into the overlay region (design section 22.4). Copy/Paste are
3
- // offered only when the clipboard feature is present (ctx.use), routing Paste
4
- // through the kernel's sanitizing funnel. Escape-to-close goes through the
5
- // kernel keydown intercept; outside-click is a document gesture.
1
+ // contextMenu feature: the Copy / Select All / Paste menu for the terminal
2
+ // surface (design section 22.4), rendered into the overlay region. Copy and Paste
3
+ // need the clipboard feature (ctx.use), and Paste routes through the kernel's
4
+ // sanitizing funnel. Escape-to-close goes through the kernel keydown intercept;
5
+ // outside-click is a document gesture.
6
6
  //
7
- // TOUCH MODEL (best-practice, cross-platform iOS + Android):
8
- // The terminal output is the browser's native text-selection surface, so a
9
- // touch long-press ON TEXT is left to the platform: it selects a word and shows
10
- // the OS copy callout. We do NOT stack a custom menu on that (it fought native
11
- // selection and, since the long-press is immediately followed by a touchend,
12
- // closed the instant the finger lifted). The custom menu opens on touch ONLY
13
- // when the long-press selected nothing (empty space) — its job there is Paste,
14
- // which the native callout cannot offer because our keyboard target is a 1x1,
15
- // pointer-events:none textarea (no editable surface under the finger for the OS
16
- // paste item). Paste reads the clipboard via the Clipboard API (secure context
17
- // required, so serve over HTTPS for touch paste). Desktop is the ordinary
18
- // right-click menu (Copy / Select All / Paste).
7
+ // WHY THIS MENU EXISTS
8
+ // The terminal output is real DOM text, so the browser already does the hard
9
+ // part: drag-select with a mouse, long-press word-select plus the OS copy callout
10
+ // on touch. What no platform can offer is PASTE — the keyboard target is a 1x1
11
+ // pointer-events:none textarea, so there is no editable surface under the pointer
12
+ // for a native paste item to attach to. This menu is the paste path. It carries
13
+ // Copy and Select All because a menu appearing where the platform's would have
14
+ // appeared should not be missing them.
19
15
  //
20
- // Deciding "did the long-press select text?" is the crux — a naive
21
- // getSelection() read at a fixed delay races the native selection and caused the
22
- // reported flakiness (sometimes native, sometimes custom, sometimes both). So we
23
- // TRACK whether a selection is made during THIS press via a selectionchange
24
- // listener armed only while a touch is down (the moment one appears we cancel
25
- // the pending menu and, if it already opened, close it), AND we gate the menu on
26
- // PRESS POSITION: a long-press that landed on a glyph run belongs to the
27
- // platform's word-select even when no selection has registered yet (iOS 26's
28
- // native selection can land well past our 550ms timer, so timing alone misfires
29
- // over text). The platforms deliver the long-press differently: iPhone fires NO
30
- // contextmenu (a hold timer detects it); iPadOS DOES fire contextmenu mid-press
31
- // and WebKit treats preventDefault on it as "cancel every remaining default of
32
- // this gesture" — including a not-yet-registered selection — so on Apple touch
33
- // devices the contextmenu handler never preventDefaults (that asymmetry is what
34
- // once made iPad unable to select at all while iPhone worked); Android also
35
- // fires contextmenu, and there we DO preventDefault when showing our own menu so
36
- // Android's native menu doesn't also appear (the "both menus" case). All paths
37
- // share the same selection + position gates, so the outcome is consistent.
16
+ // THE MODEL: one owner per gesture, and nothing decided mid-gesture.
17
+ //
18
+ // Mouse / pen — `contextmenu` is an already-classified request for a menu:
19
+ // preventDefault and open at the pointer. No timers, no heuristics, no
20
+ // platform branches.
21
+ //
22
+ // Touch — the platform owns the press while the finger is down. We run no hold
23
+ // timer against it, open nothing before it finishes, and never cancel its
24
+ // gesture on WebKit. We classify ONCE, on `touchend`, when every fact is
25
+ // settled: a single-finger, stationary press held past the tap ceiling
26
+ // (kernel/gesture.ts, shared with the kernel's tap-to-focus so the two cannot
27
+ // both claim one press) that selected nothing and did not start on a link is a
28
+ // press the platform declined — so it is ours, and it is the paste path. A
29
+ // press that produced a selection belongs to the OS callout; a press on a link
30
+ // belongs to the platform's link preview; a shorter press is the kernel's tap.
31
+ //
32
+ // WHY AT RELEASE RATHER THAN DURING THE HOLD
33
+ // Opening from a ~550ms hold timer put this feature in a race it cannot win: the
34
+ // timer has to beat the platform's own long-press threshold and then GUESS
35
+ // whether a selection is still coming (iOS 26 registers a word selection well
36
+ // after 550ms). Four mechanisms existed only to referee that guess — a hit test
37
+ // for glyphs under the finger, a selectionchange watch to retract a menu that
38
+ // opened too early, a device sniff, and per-platform contextmenu branching,
39
+ // because WebKit reads preventDefault on a touch contextmenu as "cancel every
40
+ // remaining default of this gesture", the not-yet-registered selection included.
41
+ // Deciding at release deletes all four: the outcome is observed, not predicted.
42
+ //
43
+ // It also fixes the symptom that prompted the rewrite. A touch long-press emits a
44
+ // trailing click on release, and the swallow window that covers that click was
45
+ // armed when the menu OPENED — ~550ms into the press, expiring 350ms later — so
46
+ // holding a beat longer meant the release click landed as an outside click and
47
+ // dismissed the menu the instant the finger lifted. Opened BY the release, the
48
+ // window can only ever start at the release edge.
38
49
 
39
50
  import type { TerminalFeature } from "../kernel/types.js";
51
+ import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "../kernel/gesture.js";
40
52
  import type { ClipboardApi } from "./clipboard.js";
41
53
  import { createClickSwallow, placeMenuAt } from "./menu-position.js";
42
54
 
43
- // Slightly longer than the browser's ~500ms native long-press so a native word
44
- // selection (which we defer to) has registered — and fired selectionchange —
45
- // before this fires.
46
- const LONG_PRESS_MS = 550;
47
- const LONG_PRESS_MOVE_PX = 10;
48
55
  // Viewport clamping, the flip-above-the-fingertip gap, and the trailing-click
49
56
  // swallow all live in the shared point-anchored menu module (menu-position.ts),
50
57
  // shared with the tab menu.
@@ -55,14 +62,18 @@ export interface ContextMenuOptions {
55
62
  clipboard?: TerminalFeature<ClipboardApi>;
56
63
  }
57
64
 
58
- /** iPhone/iPad/iPod, including iPadOS Safari's default "desktop mode" (which
59
- * reports platform MacIntel but with a touch screen). On these, a long-press on
60
- * text fires `contextmenu` slightly BEFORE the native word-selection registers,
61
- * so hasNativeSelection() is still false at that instant and the Android
62
- * empty-space branch would wrongly open our menu and preventDefault the native
63
- * selection. Deferring on any Apple touch device lets iOS/iPadOS run its own
64
- * text selection + drag handles (bug: an iPad long-press opened the custom menu
65
- * instead of native selection, leaving Select All the only way to select). */
65
+ /** iPhone/iPad/iPod, including iPadOS Safari's default "desktop mode" (platform
66
+ * MacIntel with a touch screen).
67
+ *
68
+ * This decides exactly one thing: whether a touch `contextmenu` may be
69
+ * cancelled. WebKit reads preventDefault there as "cancel every remaining
70
+ * default action of this gesture", which takes the platform's own
71
+ * not-yet-registered word selection with it — that asymmetry is what once left
72
+ * an iPad unable to select text at all while an iPhone (which fires no
73
+ * contextmenu) could. Everywhere else, cancelling is how we stop the platform's
74
+ * menu appearing alongside ours. It no longer decides whether or when our menu
75
+ * opens, so a wrong answer here costs at most a duplicated menu on an unusual
76
+ * device, never a broken selection and never a lost paste. */
66
77
  function isAppleTouchDevice(): boolean {
67
78
  if (typeof navigator === "undefined") {
68
79
  return false;
@@ -77,6 +88,17 @@ function isAppleTouchDevice(): boolean {
77
88
  return platform === "MacIntel" && navigator.maxTouchPoints > 1;
78
89
  }
79
90
 
91
+ /** The current selection as text ("" when nothing is selected). A collapsed
92
+ * selection stringifies to "", so this is the whole test. */
93
+ function selectionText(): string {
94
+ return window.getSelection()?.toString() ?? "";
95
+ }
96
+
97
+ /** True when an event target sits inside a hyperlink. A long-press on a link
98
+ * raises the platform's own link preview/menu without ever making a selection,
99
+ * so the selection test alone would not keep us out of its way. */
100
+ const onLink = isLinkTarget;
101
+
80
102
  export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
81
103
  return {
82
104
  name: "contextMenu",
@@ -86,23 +108,28 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
86
108
  menu.className = "wt-ctx-menu";
87
109
  ctx.region("overlay", "menu").appendChild(menu);
88
110
 
89
- let longPressTimer = 0;
90
- let longPressOrigin = { x: 0, y: 0 };
91
- // The device that started the current interaction: contextmenu fires on
92
- // desktop right-click AND on Android long-press, so its handler branches
111
+ // The device that started the current interaction: `contextmenu` fires on a
112
+ // desktop right-click AND on an Android long-press, so its handler branches
93
113
  // on this to tell them apart.
94
114
  let lastPointerType = "mouse";
95
- // Computed once: on an Apple touch device, defer a touch contextmenu to
96
- // native text selection (see isAppleTouchDevice).
115
+ // Computed once: may a touch contextmenu be cancelled (see isAppleTouchDevice).
97
116
  const appleTouch = isAppleTouchDevice();
98
117
  // Swallows the trailing click a touch long-press emits on release.
99
118
  const swallow = createClickSwallow();
100
- // A native selection was made during the current touch press (tracked via
101
- // selectionchange). Gates the touch menu: a selected long-press belongs to
102
- // the native callout, an empty one to us (Paste).
103
- let pressSelectedText = false;
104
- // A touch press is in progress, so selectionchange is relevant (armed).
105
- let touchActive = false;
119
+
120
+ // The in-flight single-finger press, all of it read at `touchend`.
121
+ // pressLive goes false the moment the gesture stops being a candidate (a
122
+ // second finger, movement past the ceiling, a cancel), so touchend does not
123
+ // have to re-derive any of it.
124
+ let pressLive = false;
125
+ let pressStart = 0;
126
+ let pressX = 0;
127
+ let pressY = 0;
128
+ let pressOnLink = false;
129
+ // The selection as it stood when the press began, so touchend can tell a
130
+ // selection THIS press produced (the OS callout owns it) from one that was
131
+ // already on screen (ours to offer Copy for).
132
+ let pressSelection = "";
106
133
 
107
134
  const clip = (): ClipboardApi | undefined =>
108
135
  opts.clipboard ? ctx.use(opts.clipboard) : undefined;
@@ -111,42 +138,6 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
111
138
  surface.querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
112
139
  }
113
140
 
114
- const clearLongPress = (): void => {
115
- if (longPressTimer) {
116
- clearTimeout(longPressTimer);
117
- longPressTimer = 0;
118
- }
119
- };
120
-
121
- // True when the press landed on rendered text (a glyph run inside the
122
- // output). The empty-space menu must not open there: a long-press on text
123
- // belongs to the platform's word-selection, and opening ours first races
124
- // it (on iOS 26 the native selection can register well after 550ms, so
125
- // the old no-selection-yet check misfired over text). Hitting the row or
126
- // container (the blank area beside/below content) still counts as empty
127
- // space so Paste stays reachable.
128
- const pressOverText = (x: number, y: number): boolean => {
129
- const el = document.elementFromPoint(x, y);
130
- if (!(el instanceof HTMLElement) || !el.closest(".term-output")) {
131
- return false;
132
- }
133
- const run = el.closest("span, a");
134
- return run !== null && run.textContent.trim().length > 0;
135
- };
136
-
137
- // True when text is (or just got) selected, so the OS callout owns Copy and
138
- // our menu stays out of the way. pressSelectedText catches a native
139
- // selection the instant it happens (selectionchange), avoiding a race with
140
- // a fixed-delay read; the live check is the fallback for when selectionchange
141
- // did not arm (a desktop right-click on an existing selection).
142
- const hasNativeSelection = (): boolean => {
143
- if (pressSelectedText) {
144
- return true;
145
- }
146
- const s = window.getSelection();
147
- return s !== null && !s.isCollapsed && s.toString().length > 0;
148
- };
149
-
150
141
  function hide(refocus = true): void {
151
142
  const shouldRefocus = refocus && menu.contains(document.activeElement);
152
143
  menu.classList.remove("visible");
@@ -170,19 +161,16 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
170
161
  menu.appendChild(b);
171
162
  }
172
163
 
173
- function show(x: number, y: number, touch: boolean): void {
164
+ /** Open the menu at viewport point (x, y). Same items for every modality:
165
+ * Copy when something is selected, then Select All, then Paste. */
166
+ function show(x: number, y: number): void {
174
167
  hide();
175
168
  const clipboard = clip();
176
- // On touch we only reach here over empty space (no selection), so Copy
177
- // has nothing to act on; the native callout owns Copy over selected
178
- // text. On desktop, offer Copy when there is a selection.
179
- if (!touch) {
180
- const sel = window.getSelection()?.toString();
181
- if (sel) {
182
- addButton("Copy", () => {
183
- clipboard?.copy(sel);
184
- });
185
- }
169
+ const sel = selectionText();
170
+ if (clipboard && sel) {
171
+ addButton("Copy", () => {
172
+ clipboard.copy(sel);
173
+ });
186
174
  }
187
175
  addButton(
188
176
  "Select All",
@@ -204,15 +192,10 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
204
192
  return; // nothing to offer
205
193
  }
206
194
  // Make visible (so it has measurable size), then place it within the
207
- // visible viewport via the shared point-anchored positioner (clamp to
208
- // the visual viewport; flip above the finger near the bottom edge).
195
+ // visible viewport via the shared point-anchored positioner (clamp to the
196
+ // visual viewport; flip above the finger near the bottom edge).
209
197
  menu.classList.add("visible");
210
198
  placeMenuAt(menu, x, y);
211
- // A touch long-press is immediately followed by a synthetic click on
212
- // release; swallow it so the menu stays open.
213
- if (touch) {
214
- swallow.arm();
215
- }
216
199
  }
217
200
 
218
201
  const onPointerDown = (e: PointerEvent): void => {
@@ -220,66 +203,24 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
220
203
  };
221
204
  surface.addEventListener("pointerdown", onPointerDown, { passive: true });
222
205
 
223
- // contextmenu fires on desktop right-click AND on Android long-press (iOS
224
- // fires none). Desktop: always our menu. Touch (Android): our menu only
225
- // when nothing is selected — and preventDefault so Android's native menu
226
- // does not ALSO appear (the "both menus" bug); with a selection, defer to
227
- // the native selection toolbar (don't preventDefault).
228
206
  const onContextMenu = (e: MouseEvent): void => {
229
207
  if (lastPointerType !== "touch") {
230
208
  e.preventDefault();
231
- show(e.clientX, e.clientY, false);
232
- return;
233
- }
234
- if (appleTouch) {
235
- // NEVER preventDefault a touch contextmenu on iOS/iPadOS: WebKit then
236
- // cancels every remaining default action of the gesture, including a
237
- // native word-selection that registers AFTER this event (iPadOS fires
238
- // contextmenu mid-long-press; on iOS 26 the selection can land well
239
- // after it). iPhone fires no contextmenu at all, so suppressing here
240
- // is exactly what made iPad behave differently from iPhone. If our
241
- // empty-space menu opened first (the hold timer beat a slow native
242
- // selection), stand down so the platform's selection wins.
243
- if (menu.classList.contains("visible") && !hasNativeSelection()) {
244
- hide(false);
245
- }
209
+ show(e.clientX, e.clientY);
246
210
  return;
247
211
  }
248
- if (menu.classList.contains("visible")) {
249
- e.preventDefault(); // the hold timer already opened it; just suppress native
250
- return;
251
- }
252
- if (hasNativeSelection()) {
253
- return; // native selection toolbar owns Copy over text
254
- }
255
- if (pressOverText(e.clientX, e.clientY)) {
256
- return; // a long-press on text belongs to the platform's word-select
212
+ // Touch: our menu never opens from here — the touchend classifier owns
213
+ // that. The only question left is whether the PLATFORM's menu may
214
+ // proceed. On WebKit it must (cancelling here cancels the whole gesture's
215
+ // remaining defaults, the word selection included). Elsewhere (Android
216
+ // fires contextmenu mid-press) cancel it when the press has nothing of
217
+ // the platform's own to show, so its menu and ours cannot both appear.
218
+ if (!appleTouch && !onLink(e.target) && selectionText() === "") {
219
+ e.preventDefault();
257
220
  }
258
- e.preventDefault();
259
- clearLongPress();
260
- show(e.clientX, e.clientY, true);
261
221
  };
262
222
  surface.addEventListener("contextmenu", onContextMenu);
263
223
 
264
- // Track a native selection made during the current touch press, so the
265
- // long-press decision never races a fixed-delay read. Armed only while a
266
- // touch is down (touchActive) so unrelated selection changes (Select All,
267
- // typing) don't trip it.
268
- const onSelectionChange = (): void => {
269
- if (!touchActive) {
270
- return;
271
- }
272
- const s = window.getSelection();
273
- if (s && !s.isCollapsed && s.toString().length > 0) {
274
- pressSelectedText = true;
275
- clearLongPress(); // native selection is happening; don't open our menu
276
- if (menu.classList.contains("visible")) {
277
- hide(false); // a native selection appeared after we opened; native wins
278
- }
279
- }
280
- };
281
- document.addEventListener("selectionchange", onSelectionChange);
282
-
283
224
  // Escape closes the menu without also sending ESC to the PTY.
284
225
  const offKey = ctx.registerKeydown((ev) => {
285
226
  if (ev.key === "Escape" && menu.classList.contains("visible")) {
@@ -290,8 +231,16 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
290
231
  return false;
291
232
  });
292
233
 
293
- const onDocClick = (): void => {
294
- // Swallow the release click of the long-press that just opened the menu.
234
+ const onDocClick = (e: MouseEvent): void => {
235
+ // A click on an item is that item's own business: its handler hides the
236
+ // menu with the correct refocus behaviour (Select All must not refocus
237
+ // the input, or Firefox collapses the selection it just made). Reaching
238
+ // hide() from here as well would override that choice.
239
+ if (e.target instanceof Node && menu.contains(e.target)) {
240
+ return;
241
+ }
242
+ // The release click of the long-press that just opened the menu is that
243
+ // gesture's own end, not a click-away.
295
244
  if (swallow.swallowing()) {
296
245
  return;
297
246
  }
@@ -308,64 +257,77 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
308
257
  };
309
258
  document.addEventListener("contextmenu", onDocContextMenu);
310
259
 
311
- // Touch long-press — the iOS path (iOS fires no contextmenu). Open the menu
312
- // only when nothing got natively selected during the hold; if a word/line
313
- // was selected, the OS callout owns it and we stay out.
314
260
  const onTouchStart = (e: TouchEvent): void => {
315
- if (e.touches.length !== 1) {
316
- clearLongPress();
317
- return;
318
- }
319
- const t = e.touches[0];
261
+ const t = e.touches.length === 1 ? e.touches[0] : undefined;
320
262
  if (!t) {
263
+ pressLive = false; // a second finger: pinch/zoom, not a long-press
321
264
  return;
322
265
  }
323
- touchActive = true;
324
- pressSelectedText = false;
325
- longPressOrigin = { x: t.clientX, y: t.clientY };
326
- longPressTimer = window.setTimeout(() => {
327
- longPressTimer = 0;
328
- if (!hasNativeSelection() && !pressOverText(longPressOrigin.x, longPressOrigin.y)) {
329
- show(longPressOrigin.x, longPressOrigin.y, true);
330
- }
331
- }, LONG_PRESS_MS);
266
+ pressLive = true;
267
+ pressStart = e.timeStamp;
268
+ pressX = t.clientX;
269
+ pressY = t.clientY;
270
+ pressOnLink = onLink(e.target);
271
+ pressSelection = selectionText();
332
272
  };
333
273
  const onTouchMove = (e: TouchEvent): void => {
334
- if (!longPressTimer || e.touches.length !== 1) {
274
+ if (!pressLive) {
335
275
  return;
336
276
  }
337
- const t = e.touches[0];
277
+ const t = e.touches.length === 1 ? e.touches[0] : undefined;
338
278
  if (!t) {
279
+ pressLive = false;
339
280
  return;
340
281
  }
341
- const dx = t.clientX - longPressOrigin.x;
342
- const dy = t.clientY - longPressOrigin.y;
343
- if (dx * dx + dy * dy > LONG_PRESS_MOVE_PX * LONG_PRESS_MOVE_PX) {
344
- clearLongPress();
282
+ // A drag is a scroll or a selection-extend, both the browser's.
283
+ if (
284
+ Math.abs(t.clientX - pressX) > TAP_MOVEMENT_PX ||
285
+ Math.abs(t.clientY - pressY) > TAP_MOVEMENT_PX
286
+ ) {
287
+ pressLive = false;
288
+ }
289
+ };
290
+ // The whole touch decision, made with the gesture complete: every input is
291
+ // a settled fact, so there is nothing here to race and nothing to retract.
292
+ const onTouchEnd = (e: TouchEvent): void => {
293
+ const live = pressLive;
294
+ pressLive = false;
295
+ if (!live || pressOnLink) {
296
+ return;
297
+ }
298
+ if (e.timeStamp - pressStart <= TAP_MAX_MS) {
299
+ return; // a tap: the kernel focuses the input / clears the selection
300
+ }
301
+ const sel = selectionText();
302
+ if (sel !== "" && sel !== pressSelection) {
303
+ return; // this press selected text; the OS callout owns it
304
+ }
305
+ show(pressX, pressY);
306
+ if (menu.classList.contains("visible")) {
307
+ // Armed at the release edge, so the trailing click this same gesture
308
+ // emits is always inside the window.
309
+ swallow.arm();
345
310
  }
346
311
  };
347
- const onTouchEnd = (): void => {
348
- clearLongPress();
349
- touchActive = false;
312
+ const onTouchCancel = (): void => {
313
+ pressLive = false;
350
314
  };
351
315
  surface.addEventListener("touchstart", onTouchStart, { passive: true });
352
316
  surface.addEventListener("touchmove", onTouchMove, { passive: true });
353
317
  surface.addEventListener("touchend", onTouchEnd, { passive: true });
354
- surface.addEventListener("touchcancel", onTouchEnd, { passive: true });
318
+ surface.addEventListener("touchcancel", onTouchCancel, { passive: true });
355
319
 
356
320
  return {
357
321
  teardown() {
358
- clearLongPress();
359
322
  offKey();
360
323
  surface.removeEventListener("pointerdown", onPointerDown);
361
324
  surface.removeEventListener("contextmenu", onContextMenu);
362
- document.removeEventListener("selectionchange", onSelectionChange);
363
325
  document.removeEventListener("click", onDocClick);
364
326
  document.removeEventListener("contextmenu", onDocContextMenu);
365
327
  surface.removeEventListener("touchstart", onTouchStart);
366
328
  surface.removeEventListener("touchmove", onTouchMove);
367
329
  surface.removeEventListener("touchend", onTouchEnd);
368
- surface.removeEventListener("touchcancel", onTouchEnd);
330
+ surface.removeEventListener("touchcancel", onTouchCancel);
369
331
  menu.remove();
370
332
  },
371
333
  };