@cplieger/web-terminal-ui 5.2.2 → 5.3.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/README.md +149 -11
- package/css/00-tokens.css +3 -3
- package/css/02-terminal.css +38 -7
- package/css/30-tabs.css +79 -18
- package/css/31-switcher.css +23 -1
- package/css/40-animations.css +18 -0
- package/css/page.css +8 -8
- package/package.json +23 -7
- 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 +1 -1
- 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
|
@@ -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
|
};
|
|
@@ -21,16 +21,22 @@ const FLIP_GAP = 16;
|
|
|
21
21
|
const SWALLOW_MS = 350;
|
|
22
22
|
|
|
23
23
|
/** createClickSwallow returns the two halves a point-anchored menu needs to
|
|
24
|
-
* survive the contextmenu-then-touchend race: `arm()`
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* survive the contextmenu-then-touchend race: `arm()` and `swallowing()` as the
|
|
25
|
+
* first guard in the outside-click dismiss.
|
|
26
|
+
*
|
|
27
|
+
* **Arm on the RELEASE edge, not when the menu opens.** The window is a fixed
|
|
28
|
+
* 350ms from `arm()`, so arming it while the finger is still down only covers
|
|
29
|
+
* the release if the user lifts within 350ms. Both menus shipped that way and
|
|
30
|
+
* both dismissed themselves on release whenever the press was held a beat
|
|
31
|
+
* longer: the terminal menu opened from a 550ms hold timer, so anything past
|
|
32
|
+
* ~900ms of hold lost its guard, and the tab menu armed from a mid-press
|
|
33
|
+
* `contextmenu`. The terminal menu now opens from `touchend` itself, so its
|
|
34
|
+
* arm() is on the release by construction; the tab menu opens mid-press and
|
|
35
|
+
* re-arms when the opening gesture's pointer goes up.
|
|
27
36
|
*
|
|
28
37
|
* It lives here, beside the shared placement math, because both point-anchored
|
|
29
38
|
* menus need it and a hand-synced second copy is what this module was extracted
|
|
30
|
-
* to stop.
|
|
31
|
-
* — which fires `contextmenu` on a long-press, and where the desktop tab strip
|
|
32
|
-
* is the chrome that shows — a long-pressed tab menu dismissed itself on
|
|
33
|
-
* release. Arm it only for touch: a mouse right-click emits no trailing click,
|
|
39
|
+
* to stop. Arm it only for touch: a mouse right-click emits no trailing click,
|
|
34
40
|
* and swallowing there would eat a genuine dismiss. */
|
|
35
41
|
export function createClickSwallow(): { arm: () => void; swallowing: () => boolean } {
|
|
36
42
|
let until = 0;
|
|
@@ -22,11 +22,11 @@ export function scrollToBottom(): TerminalFeature {
|
|
|
22
22
|
|
|
23
23
|
// Jump to the bottom. With motion allowed, smooth-scroll the surface so
|
|
24
24
|
// the jump is animated rather than an instant teleport; the engine's
|
|
25
|
-
// scroll controller re-
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
25
|
+
// scroll controller then re-engages follow when it sees the animation land
|
|
26
|
+
// at the bottom (a downward move reaching the tail), so the animation needs
|
|
27
|
+
// no programmatic flag and the render pin does not fight it mid-flight.
|
|
28
|
+
// Under prefers-reduced-motion, fall back to the engine's instant
|
|
29
|
+
// scrollToBottom (which re-engages following synchronously).
|
|
30
30
|
const jump = (): void => {
|
|
31
31
|
const reduce =
|
|
32
32
|
typeof window.matchMedia === "function" &&
|
|
@@ -36,6 +36,17 @@ export function scrollToBottom(): TerminalFeature {
|
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
38
|
const surface = ctx.surface();
|
|
39
|
+
// Already AT the bottom but possibly holding: a program erasing its
|
|
40
|
+
// scrollback (ED3) clamps a scrolled-up reader to the bottom without
|
|
41
|
+
// engaging follow, which is a durable state since the engine stopped
|
|
42
|
+
// letting an upward clamp engage it. A smooth scroll with zero distance
|
|
43
|
+
// to travel fires no scroll event, so nothing would re-derive the state
|
|
44
|
+
// and this button — whose entire job is resuming follow — would be inert
|
|
45
|
+
// until new output happened to arrive. State the intent directly instead.
|
|
46
|
+
if (surface.scrollHeight - surface.clientHeight - surface.scrollTop <= 0) {
|
|
47
|
+
ctx.scroll.scrollToBottom();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
39
50
|
surface.scrollTo({ top: surface.scrollHeight, behavior: "smooth" });
|
|
40
51
|
};
|
|
41
52
|
|