@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/README.md +149 -11
- package/css/00-tokens.css +23 -17
- package/css/02-terminal.css +6 -0
- package/css/30-tabs.css +105 -31
- package/css/31-switcher.css +31 -7
- package/css/40-animations.css +18 -0
- package/css/page.css +46 -9
- package/package.json +22 -6
- package/src/features/context-menu.ts +159 -197
- package/src/features/menu-position.ts +13 -7
- package/src/features/scroll-to-bottom.ts +16 -5
- package/src/features/tabs/index.ts +177 -51
- package/src/features/tabs/ink-centre.ts +38 -30
- package/src/features/tabs/model.ts +15 -2
- package/src/index.ts +4 -0
- package/src/kernel/gesture.ts +32 -0
- package/src/kernel/kernel.ts +431 -18
- package/src/kernel/options.ts +58 -0
- package/src/kernel/scrollback-storage.ts +316 -0
- package/src/kernel/scrollback.ts +333 -0
- package/src/kernel/types.ts +209 -4
- package/src/viewport.ts +29 -12
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: "
|
|
221
|
-
src: url("/vendor/fonts/
|
|
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: "
|
|
229
|
-
src: url("/vendor/fonts/
|
|
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: "
|
|
237
|
-
src: url("/vendor/fonts/
|
|
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: "
|
|
245
|
-
src: url("/vendor/fonts/
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
+
"eslint": "10.8.1",
|
|
57
57
|
"fast-check": "4.9.0",
|
|
58
|
-
"happy-dom": "20.11.
|
|
58
|
+
"happy-dom": "20.11.2",
|
|
59
59
|
"html-validate": "11.6.2",
|
|
60
|
-
"knip": "6.
|
|
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
|
+
"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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
8
|
-
// The terminal output is the browser
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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" (
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
-
|
|
90
|
-
|
|
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:
|
|
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
|
-
|
|
101
|
-
//
|
|
102
|
-
// the
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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 (!
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
348
|
-
|
|
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",
|
|
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",
|
|
330
|
+
surface.removeEventListener("touchcancel", onTouchCancel);
|
|
369
331
|
menu.remove();
|
|
370
332
|
},
|
|
371
333
|
};
|