@cplieger/web-terminal-ui 3.3.0 → 3.4.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 +13 -9
- package/css/00-tokens.css +9 -2
- package/css/02-app.css +15 -141
- package/css/03-primitives.css +1 -0
- package/css/04-feedback.css +20 -6
- package/css/05-tabs.css +106 -23
- package/css/06-mobile.css +104 -38
- package/css/07-animations.css +22 -13
- package/package.json +2 -2
- package/src/composition.ts +29 -4
- package/src/features/activity-monitor.ts +8 -1
- package/src/features/animations.ts +10 -5
- package/src/features/clipboard.ts +8 -2
- package/src/features/connection-banner.ts +0 -1
- package/src/features/context-menu.ts +32 -0
- package/src/features/predictive-echo.ts +10 -0
- package/src/features/tabs.ts +602 -112
- package/src/input-placeholder.ts +2 -2
- package/src/kernel/kernel.ts +82 -10
- package/src/presets.ts +18 -15
- package/src/viewport.ts +80 -3
package/css/06-mobile.css
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/* ===== Mobile tab chrome (tabs feature, section 12) =====
|
|
2
2
|
The bottom bar (switcher) is the mobile tab surface, the externally-driven key
|
|
3
3
|
grid docks above it, and the overview sheet lists every tab. The desktop strip
|
|
4
|
-
is hidden on a coarse
|
|
5
|
-
fade, sheet slide) lives in 07-animations.css. */
|
|
6
|
-
|
|
7
|
-
/* The switcher is
|
|
8
|
-
the top strip is
|
|
9
|
-
the
|
|
4
|
+
is hidden only on a narrow coarse-pointer device (a phone) and these appear
|
|
5
|
+
instead; all motion (bar number fade, sheet slide) lives in 07-animations.css. */
|
|
6
|
+
|
|
7
|
+
/* The switcher is used only on a NARROW coarse-pointer device (a phone), where
|
|
8
|
+
the top strip is hidden so tabs live in the thumb zone. A wide touchscreen (an
|
|
9
|
+
iPad) and any fine-pointer device keep the multi-tab top strip — a single
|
|
10
|
+
giant tab wastes a big screen. This media query MUST stay in lockstep with
|
|
11
|
+
SWITCHER_MEDIA in src/features/tabs.ts and the complementary strip clearance
|
|
12
|
+
in css/05-tabs.css. */
|
|
10
13
|
.wt-switcher {
|
|
11
14
|
display: none;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
@media (pointer: coarse) {
|
|
17
|
+
@media (pointer: coarse) and (width <= 600px) {
|
|
15
18
|
.wt-tab-bar,
|
|
16
19
|
.wt-tab-new {
|
|
17
20
|
display: none;
|
|
@@ -57,7 +60,11 @@
|
|
|
57
60
|
padding: 0 var(--sp-2);
|
|
58
61
|
display: flex;
|
|
59
62
|
flex-direction: column;
|
|
60
|
-
|
|
63
|
+
/* A touch more room than the desktop strip's --sp-1: vertical touch rows read
|
|
64
|
+
better with a bit more separation, and this equals the bar's --sp-2 bottom
|
|
65
|
+
padding so the active-tab -> first-row seam matches the inter-row gap (see
|
|
66
|
+
the expanded-list padding-top: 0 below). */
|
|
67
|
+
gap: var(--sp-2);
|
|
61
68
|
max-height: 0;
|
|
62
69
|
overflow: hidden;
|
|
63
70
|
/* max-height caps the whole box (incl. the expanded padding) so an interactive
|
|
@@ -67,9 +74,14 @@
|
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
.wt-switcher-expanded .wt-switcher-list {
|
|
70
|
-
|
|
77
|
+
/* Cap against the DYNAMIC viewport so a shown mobile browser toolbar can't push the list
|
|
78
|
+
(and the bar above it) off the top; the switcher's own bottom: kb-inset already lifts it
|
|
79
|
+
clear of the soft keyboard. */
|
|
80
|
+
max-height: 50dvh;
|
|
71
81
|
overflow-y: auto;
|
|
72
|
-
padding
|
|
82
|
+
/* No top padding: the bar's --sp-2 bottom padding already sits between the
|
|
83
|
+
active tab and the first row, so the seam equals the --sp-2 inter-row gap. */
|
|
84
|
+
padding-top: 0;
|
|
73
85
|
padding-bottom: var(--sp-1);
|
|
74
86
|
}
|
|
75
87
|
|
|
@@ -102,7 +114,7 @@
|
|
|
102
114
|
fill/foreground the desktop strip uses (themeable; purple in vibecli). */
|
|
103
115
|
background: var(--tab-active-bg);
|
|
104
116
|
color: var(--tab-active-fg);
|
|
105
|
-
border:
|
|
117
|
+
border: 1px solid var(--tab-active-border);
|
|
106
118
|
border-radius: var(--radius-inner);
|
|
107
119
|
font-family: var(--font-ui);
|
|
108
120
|
font-size: 0.875rem;
|
|
@@ -167,6 +179,13 @@
|
|
|
167
179
|
button collapses to zero width and fades out (the key grid is closed too, in
|
|
168
180
|
JS), and the active row flex-grows to fill the freed space. */
|
|
169
181
|
.wt-switcher-kb {
|
|
182
|
+
/* Rigid 44px box like the "+" and switch buttons (flex: none), so a long
|
|
183
|
+
active-tab title that overflows the bar is absorbed entirely by the
|
|
184
|
+
ellipsizing active-tab field, not shared with this button. It is otherwise
|
|
185
|
+
the only bar control with flex-shrink, so it squished (≈28px, non-square) on
|
|
186
|
+
long-title tabs while staying square on short ones. The `width` below still
|
|
187
|
+
drives the expand-collapse animation; flex: none honours it. */
|
|
188
|
+
flex: none;
|
|
170
189
|
width: var(--touch-target);
|
|
171
190
|
/* Override .wt-btn's min-width (the 44px tap floor). min-width is NOT
|
|
172
191
|
transitioned, so on collapse it would revert to 44px instantly and clamp the
|
|
@@ -206,6 +225,41 @@
|
|
|
206
225
|
pointer-events: none;
|
|
207
226
|
}
|
|
208
227
|
|
|
228
|
+
/* Tab-switcher button: toggles the list open/closed, sitting between the
|
|
229
|
+
keyboard and "+" buttons. position: relative anchors its notification dot,
|
|
230
|
+
absolutely placed in the top-right corner. Unlike the keyboard button it
|
|
231
|
+
stays visible while the list is expanded — it is the close control. */
|
|
232
|
+
.wt-switcher-switch {
|
|
233
|
+
position: relative;
|
|
234
|
+
flex: none;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
@media (hover: hover) {
|
|
238
|
+
.wt-switcher-switch:hover {
|
|
239
|
+
background: var(--tab-hover-bg);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* Latest-wins background-tab notification dot on the switch button — the moved
|
|
244
|
+
attention cue. Amber = a background terminal needs input, green = a background
|
|
245
|
+
turn finished. Reuses the exact .wt-status-dot colours (05-tabs.css) so it
|
|
246
|
+
matches the per-tab dots. Hidden until the tabs feature sets data-status;
|
|
247
|
+
cleared when the list opens (expandSwitcher). */
|
|
248
|
+
.wt-switcher-switch-dot {
|
|
249
|
+
position: absolute;
|
|
250
|
+
top: 6px;
|
|
251
|
+
right: 6px;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* The dot carries the .wt-status-dot class, so its size, shape, and colour come
|
|
255
|
+
straight from the shared .wt-status-dot[data-status="input"|"done"] rules
|
|
256
|
+
(05-tabs.css) — the SAME dot colours every tab already uses, defined once.
|
|
257
|
+
.wt-status-dot is display:none by default; force it visible only while a
|
|
258
|
+
notification is set (data-status present). */
|
|
259
|
+
.wt-switcher-switch-dot[data-status] {
|
|
260
|
+
display: block;
|
|
261
|
+
}
|
|
262
|
+
|
|
209
263
|
/* Mobile "+": the primary action, carried in the accent colour so it reads as
|
|
210
264
|
permanent and prominent. Hover fill is gated to real hover pointers: on touch,
|
|
211
265
|
:hover sticks after a tap (no pointer-leave), so the purple would linger — the
|
|
@@ -225,31 +279,12 @@
|
|
|
225
279
|
background: var(--tab-hover-bg);
|
|
226
280
|
}
|
|
227
281
|
|
|
228
|
-
/* Aggregate needs-input attention now rides the active surface (the separate
|
|
229
|
-
overview button is gone): a background tab is blocked on you, and a tap /
|
|
230
|
-
swipe-up here opens the overview to resolve it. Shape + color (an inset ring
|
|
231
|
-
plus a corner glyph), never hue alone (WCAG 1.4.1). */
|
|
232
|
-
.wt-switcher-current[data-attention="input"] {
|
|
233
|
-
box-shadow: inset 0 0 0 2px oklch(70% 0.18 25deg / 45%);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
.wt-switcher-current[data-attention="input"]::after {
|
|
237
|
-
content: "";
|
|
238
|
-
position: absolute;
|
|
239
|
-
top: 4px;
|
|
240
|
-
right: 4px;
|
|
241
|
-
width: 8px;
|
|
242
|
-
height: 8px;
|
|
243
|
-
border-radius: 50%;
|
|
244
|
-
background: oklch(70% 0.18 25deg);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
282
|
/* ===== Externally-driven key grid (tabbed presets, WS3) =====
|
|
248
283
|
In the tabbed presets the mobile toolbar hides its own toggle (the tab bar's
|
|
249
284
|
keyboard button drives it) and the grid docks above the bar on the right, to
|
|
250
285
|
the left of where scroll-to-bottom sits. It collapses to fully hidden, since
|
|
251
286
|
there is no in-grid toggle to keep visible. */
|
|
252
|
-
@media (pointer: coarse) {
|
|
287
|
+
@media (pointer: coarse) and (width <= 600px) {
|
|
253
288
|
.wt-toolbar-external .kb-toggle {
|
|
254
289
|
display: none;
|
|
255
290
|
}
|
|
@@ -268,6 +303,36 @@
|
|
|
268
303
|
}
|
|
269
304
|
}
|
|
270
305
|
|
|
306
|
+
/* Desktop tab strip on a WIDE touchscreen (an iPad): the mobile switcher is
|
|
307
|
+
gone, so the desktop strip carries its OWN keyboard button (.wt-tab-kb, built
|
|
308
|
+
by the same factory as the switcher's — see css/05-tabs.css + tabs.ts) to open
|
|
309
|
+
the key grid. So, exactly like switcher mode, the toolbar's own toggle is
|
|
310
|
+
hidden and the collapsed grid fully hidden — the tab-strip keyboard button is
|
|
311
|
+
the sole control (this replaces the old CSS-only workaround that un-hid the
|
|
312
|
+
toolbar's own toggle here). The grid still docks bottom-right ABOVE the strip:
|
|
313
|
+
above the tabs (never overlapping them, even when many are open and the strip
|
|
314
|
+
scrolls) and to the left of the scroll-to-bottom FAB (same right offset as
|
|
315
|
+
switcher mode). Complements the strip-shown breakpoint in css/05-tabs.css;
|
|
316
|
+
keep in lockstep with SWITCHER_MEDIA in src/features/tabs.ts. */
|
|
317
|
+
@media (pointer: coarse) and (width >= 601px) {
|
|
318
|
+
.wt-toolbar-external .kb-toggle {
|
|
319
|
+
display: none;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.key-toolbar.wt-toolbar-external {
|
|
323
|
+
top: auto;
|
|
324
|
+
right: calc(var(--sp-3) + var(--touch-target) + var(--sp-2));
|
|
325
|
+
bottom: calc(
|
|
326
|
+
var(--wt-tabbar-h, 44px) + var(--safe-bottom, 0px) + var(--kb-inset, 0px) + var(--sp-2)
|
|
327
|
+
);
|
|
328
|
+
grid-template-columns: repeat(4, var(--touch-target));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.key-toolbar.wt-toolbar-external.collapsed {
|
|
332
|
+
display: none;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
271
336
|
/* ===== Expanded tab-list rows (tabs feature) =====
|
|
272
337
|
One row per OTHER tab in the expanded list: a stretched select target (dot +
|
|
273
338
|
label) with the close (x) laid inside at the right, mirroring the desktop
|
|
@@ -285,9 +350,9 @@
|
|
|
285
350
|
min-height: var(--touch-target);
|
|
286
351
|
/* Right padding reserves room for the close (x) overlaid inside the box. */
|
|
287
352
|
padding: 0 var(--touch-target) 0 var(--sp-2);
|
|
288
|
-
background:
|
|
353
|
+
background: var(--tab-bg);
|
|
289
354
|
color: var(--text);
|
|
290
|
-
border:
|
|
355
|
+
border: 1px solid var(--border);
|
|
291
356
|
border-radius: var(--radius-inner);
|
|
292
357
|
font-family: var(--font-ui);
|
|
293
358
|
font-size: 0.875rem;
|
|
@@ -298,15 +363,16 @@
|
|
|
298
363
|
|
|
299
364
|
@media (hover: hover) {
|
|
300
365
|
.wt-switcher-row-select:hover {
|
|
301
|
-
background: var(--tab-
|
|
366
|
+
background: color-mix(in oklch, var(--tab-bg), var(--text) 15%);
|
|
302
367
|
}
|
|
303
368
|
}
|
|
304
369
|
|
|
305
|
-
/* Press / touch-hold feedback on a listed (inactive) tab:
|
|
306
|
-
the
|
|
307
|
-
|
|
370
|
+
/* Press / touch-hold feedback on a listed (inactive) tab: a solid lift over the
|
|
371
|
+
chip (the same 15% --text lift the desktop tabs use), not the translucent
|
|
372
|
+
--tab-hover-bg wash that would erase the chip fill. The active tab is set apart
|
|
373
|
+
by its accent border, so a lit inactive row is never mistaken for it. */
|
|
308
374
|
.wt-switcher-row-select:active {
|
|
309
|
-
background: var(--tab-
|
|
375
|
+
background: color-mix(in oklch, var(--tab-bg), var(--text) 15%);
|
|
310
376
|
}
|
|
311
377
|
|
|
312
378
|
.wt-switcher-row-label {
|
package/css/07-animations.css
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
/* ===== Animations (animations feature) =====
|
|
2
|
-
|
|
3
|
-
that the animations feature sets only when the OS is not in reduced-motion
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
Chrome-surface motion is centralized here, gated on the root .wt-animate class
|
|
3
|
+
that the animations feature sets only when the OS is not in reduced-motion:
|
|
4
|
+
base rules in 03-06 carry the resting state, and these rules add the
|
|
5
|
+
transitions and keyframes on top. A few pieces of motion are intentionally NOT
|
|
6
|
+
gated on .wt-animate and animate unconditionally: terminal-core motion (cursor
|
|
7
|
+
and SGR blink) and the mobile key toolbar open/close in 02-app.css, plus the
|
|
8
|
+
working-status-dot pulse in 05-tabs.css (motion is its only non-hue cue, per
|
|
9
|
+
WCAG 1.4.1). prefers-reduced-motion still defeats all of it via the reset in
|
|
10
|
+
01-reset.css. Keeping the chrome-surface motion in one file is the
|
|
11
|
+
design-system seam for "does this chrome animate, and how". */
|
|
8
12
|
|
|
9
|
-
/* Fade
|
|
10
|
-
the
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
|
|
13
|
+
/* Fade the toast OUT on hide (display allow-discrete holds display:block for
|
|
14
|
+
the fade, then flips to display:none). It appears INSTANTLY on show: a
|
|
15
|
+
transition out of display:none does not animate without a matching
|
|
16
|
+
@starting-style rule (none is defined in css/), so opacity snaps 0->1 on
|
|
17
|
+
show. Only the toast sets opacity in its base and
|
|
18
|
+
.visible rules, so only it can interpolate. The other transient surfaces
|
|
19
|
+
(connection banner, scroll-to-bottom, context menu, tab menu) toggle `display`
|
|
20
|
+
only and never set opacity, so they are intentionally not listed here: a
|
|
21
|
+
shared opacity transition would be inert while the `display allow-discrete`
|
|
22
|
+
half held them fully visible for --dur-standard before popping out on hide.
|
|
23
|
+
They show and hide instantly. */
|
|
24
|
+
.wt-animate .wt-toast {
|
|
16
25
|
transition:
|
|
17
26
|
opacity var(--dur-standard) var(--ease-standard),
|
|
18
27
|
display var(--dur-standard) allow-discrete;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"fast-check": "4.8.0",
|
|
36
36
|
"happy-dom": "20.10.6",
|
|
37
37
|
"html-validate": "11.5.5",
|
|
38
|
-
"knip": "6.
|
|
38
|
+
"knip": "6.24.0",
|
|
39
39
|
"prettier": "3.9.4",
|
|
40
40
|
"stylelint": "17.14.0",
|
|
41
41
|
"stylelint-config-standard": "40.0.0",
|
package/src/composition.ts
CHANGED
|
@@ -26,14 +26,13 @@
|
|
|
26
26
|
// uses the textarea as its anchor; we move the textarea to the cursor
|
|
27
27
|
// for the same reason.
|
|
28
28
|
|
|
29
|
-
import { keyboard } from "@cplieger/web-terminal-engine";
|
|
30
29
|
import { resetToPlaceholder } from "./input-placeholder.js";
|
|
31
|
-
const { bracketTextForPaste, prepareTextForTerminal } = keyboard;
|
|
32
30
|
|
|
33
31
|
let textarea: HTMLTextAreaElement;
|
|
34
32
|
let compositionView: HTMLElement;
|
|
35
33
|
let getCursorPx: () => { left: number; top: number; cellH: number };
|
|
36
34
|
let send: (bytes: string) => void;
|
|
35
|
+
let paste: (text: string) => void;
|
|
37
36
|
|
|
38
37
|
let composing = false;
|
|
39
38
|
let sendingComposition = false;
|
|
@@ -45,11 +44,15 @@ export function init(opts: {
|
|
|
45
44
|
compositionView: HTMLElement;
|
|
46
45
|
getCursorPx: () => { left: number; top: number; cellH: number };
|
|
47
46
|
send: (bytes: string) => void;
|
|
47
|
+
/** The kernel's single bracketed-paste+normalize funnel; the native iOS-callout
|
|
48
|
+
* paste routes through it rather than re-composing the funnel here. */
|
|
49
|
+
paste: (text: string) => void;
|
|
48
50
|
}): void {
|
|
49
51
|
textarea = opts.textarea;
|
|
50
52
|
compositionView = opts.compositionView;
|
|
51
53
|
getCursorPx = opts.getCursorPx;
|
|
52
54
|
send = opts.send;
|
|
55
|
+
paste = opts.paste;
|
|
53
56
|
|
|
54
57
|
textarea.addEventListener("compositionstart", onStart);
|
|
55
58
|
textarea.addEventListener("compositionupdate", onUpdate);
|
|
@@ -79,6 +82,26 @@ export function cancelComposition(): void {
|
|
|
79
82
|
resetToPlaceholder(textarea);
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
/** Release init()-attached listeners and drop the captured kernel closures on kernel
|
|
86
|
+
* destroy(), so a destroy without a remount does not retain the old textarea + the
|
|
87
|
+
* kernel send/paste closure graph. Mirrors viewport.teardown(). */
|
|
88
|
+
export function teardown(): void {
|
|
89
|
+
cancelComposition(); // neutralize a pending finalizer + reset composing state
|
|
90
|
+
textarea.removeEventListener("compositionstart", onStart);
|
|
91
|
+
textarea.removeEventListener("compositionupdate", onUpdate);
|
|
92
|
+
textarea.removeEventListener("compositionend", onEnd);
|
|
93
|
+
textarea.removeEventListener("paste", onPaste);
|
|
94
|
+
// Drop references to the old kernel instance so its closure graph is GC-eligible
|
|
95
|
+
// (the detached textarea/compositionView are overwritten on the next init()).
|
|
96
|
+
send = () => {
|
|
97
|
+
/* no-op after teardown */
|
|
98
|
+
};
|
|
99
|
+
paste = () => {
|
|
100
|
+
/* no-op after teardown */
|
|
101
|
+
};
|
|
102
|
+
getCursorPx = () => ({ left: 0, top: 0, cellH: 0 });
|
|
103
|
+
}
|
|
104
|
+
|
|
82
105
|
function onStart(): void {
|
|
83
106
|
composing = true;
|
|
84
107
|
const start = textarea.selectionStart;
|
|
@@ -118,7 +141,9 @@ function onEnd(): void {
|
|
|
118
141
|
suffixSnapshot.length > 0 && value.endsWith(suffixSnapshot)
|
|
119
142
|
? value.length - suffixSnapshot.length
|
|
120
143
|
: value.length;
|
|
121
|
-
const composed = value
|
|
144
|
+
const composed = value
|
|
145
|
+
.substring(startSnapshot, Math.max(startSnapshot, valueEnd))
|
|
146
|
+
.replace(/\u00A0/g, " ");
|
|
122
147
|
if (composed.length > 0) {
|
|
123
148
|
send(composed);
|
|
124
149
|
}
|
|
@@ -139,7 +164,7 @@ function onPaste(ev: ClipboardEvent): void {
|
|
|
139
164
|
}
|
|
140
165
|
ev.preventDefault();
|
|
141
166
|
ev.stopPropagation();
|
|
142
|
-
|
|
167
|
+
paste(raw);
|
|
143
168
|
// Restore the placeholder so the subsequent `input` event (some
|
|
144
169
|
// browsers fire it after paste) and held-Backspace auto-repeat keep
|
|
145
170
|
// working.
|
|
@@ -38,7 +38,14 @@ export function activityMonitor(
|
|
|
38
38
|
statuses.set(s.id, s);
|
|
39
39
|
}
|
|
40
40
|
for (const cb of [...subs]) {
|
|
41
|
-
|
|
41
|
+
try {
|
|
42
|
+
cb(s);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
// Isolate a throwing subscriber so it neither skips the
|
|
45
|
+
// remaining subscribers nor propagates into the engine's
|
|
46
|
+
// SSE reader (mirrors the kernel bus's ctx.on wrapping).
|
|
47
|
+
console.error("web-terminal-ui: activityMonitor subscriber threw", err);
|
|
48
|
+
}
|
|
42
49
|
}
|
|
43
50
|
},
|
|
44
51
|
});
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
// animations feature: opt-in motion
|
|
2
|
-
//
|
|
3
|
-
// reduced motion, it enables the shared
|
|
4
|
-
// terminal root, which the feature
|
|
5
|
-
//
|
|
1
|
+
// animations feature: opt-in motion for the chrome surfaces (design sections
|
|
2
|
+
// 13, 22.4). Without it the chrome surfaces are instant (no transitions); with
|
|
3
|
+
// it, and when the user has not asked for reduced motion, it enables the shared
|
|
4
|
+
// animation vocabulary by marking the terminal root, which the feature
|
|
5
|
+
// stylesheets key their transitions off. A few pieces of motion animate
|
|
6
|
+
// unconditionally regardless of this feature (terminal-core cursor/SGR blink and
|
|
7
|
+
// the mobile key toolbar open/close in 02-app.css, plus the working-status-dot
|
|
8
|
+
// pulse in 05-tabs.css, its WCAG non-hue cue), though prefers-reduced-motion
|
|
9
|
+
// still defeats all of it. It tracks prefers-reduced-motion live so toggling the
|
|
10
|
+
// OS setting takes effect.
|
|
6
11
|
|
|
7
12
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
8
13
|
|
|
@@ -89,9 +89,15 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
|
|
|
89
89
|
copy(text);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
// Native copy (e.g. Cmd/Ctrl+C on a selection) feedback toast
|
|
92
|
+
// Native copy (e.g. Cmd/Ctrl+C on a selection) feedback toast, scoped to a
|
|
93
|
+
// selection inside the terminal surface so an embedding host's copies of
|
|
94
|
+
// its own (non-terminal) content do not raise a spurious "Copied".
|
|
95
|
+
const surface = ctx.surface();
|
|
93
96
|
const onCopy = (): void => {
|
|
94
|
-
|
|
97
|
+
const node = window.getSelection()?.anchorNode ?? null;
|
|
98
|
+
if (node && surface.contains(node)) {
|
|
99
|
+
ctx.toast("Copied");
|
|
100
|
+
}
|
|
95
101
|
};
|
|
96
102
|
document.addEventListener("copy", onCopy);
|
|
97
103
|
|
|
@@ -20,7 +20,6 @@ export function connectionBanner(): TerminalFeature {
|
|
|
20
20
|
const slot = ctx.region("banner", "status");
|
|
21
21
|
const banner = document.createElement("div");
|
|
22
22
|
banner.className = "wt-conn-banner";
|
|
23
|
-
banner.setAttribute("role", "status");
|
|
24
23
|
slot.appendChild(banner);
|
|
25
24
|
|
|
26
25
|
let last: ConnState | null = null;
|
|
@@ -53,6 +53,28 @@ export interface ContextMenuOptions {
|
|
|
53
53
|
clipboard?: TerminalFeature<ClipboardApi>;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** iPhone/iPad/iPod, including iPadOS Safari's default "desktop mode" (which
|
|
57
|
+
* reports platform MacIntel but with a touch screen). On these, a long-press on
|
|
58
|
+
* text fires `contextmenu` slightly BEFORE the native word-selection registers,
|
|
59
|
+
* so hasNativeSelection() is still false at that instant and the Android
|
|
60
|
+
* empty-space branch would wrongly open our menu and preventDefault the native
|
|
61
|
+
* selection. Deferring on any Apple touch device lets iOS/iPadOS run its own
|
|
62
|
+
* text selection + drag handles (bug: an iPad long-press opened the custom menu
|
|
63
|
+
* instead of native selection, leaving Select All the only way to select). */
|
|
64
|
+
function isAppleTouchDevice(): boolean {
|
|
65
|
+
if (typeof navigator === "undefined") {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const ua = navigator.userAgent || "";
|
|
69
|
+
const platform = navigator.platform || "";
|
|
70
|
+
if (/iP(hone|ad|od)/.test(ua) || /iP(hone|ad|od)/.test(platform)) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
// iPadOS 13+ Safari defaults to desktop mode: platform "MacIntel" but a touch
|
|
74
|
+
// screen (maxTouchPoints > 1). A real trackpad Mac reports maxTouchPoints 0.
|
|
75
|
+
return platform === "MacIntel" && navigator.maxTouchPoints > 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
57
79
|
return {
|
|
58
80
|
name: "contextMenu",
|
|
@@ -68,6 +90,9 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
68
90
|
// desktop right-click AND on Android long-press, so its handler branches
|
|
69
91
|
// on this to tell them apart.
|
|
70
92
|
let lastPointerType = "mouse";
|
|
93
|
+
// Computed once: on an Apple touch device, defer a touch contextmenu to
|
|
94
|
+
// native text selection (see isAppleTouchDevice).
|
|
95
|
+
const appleTouch = isAppleTouchDevice();
|
|
71
96
|
// Timestamp until which a document click is swallowed (see SWALLOW_MS).
|
|
72
97
|
let swallowUntil = 0;
|
|
73
98
|
// A native selection was made during the current touch press (tracked via
|
|
@@ -220,6 +245,13 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
220
245
|
if (hasNativeSelection()) {
|
|
221
246
|
return; // native selection toolbar owns Copy over text
|
|
222
247
|
}
|
|
248
|
+
if (appleTouch) {
|
|
249
|
+
// iOS/iPadOS fires contextmenu a beat before the native word-select
|
|
250
|
+
// registers, so hasNativeSelection() can still be false here. Defer
|
|
251
|
+
// anyway so the platform's own selection wins instead of our
|
|
252
|
+
// empty-space menu (which would preventDefault and suppress it).
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
223
255
|
e.preventDefault();
|
|
224
256
|
clearLongPress();
|
|
225
257
|
show(e.clientX, e.clientY, true);
|
|
@@ -47,6 +47,16 @@ export function predictiveEcho(): TerminalFeature {
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
return {
|
|
50
|
+
// A tab switch (kernel notifySwitch -> onDetach) must drop the predicted
|
|
51
|
+
// cursor, so the outgoing session's ghost cursor does not paint on the
|
|
52
|
+
// incoming session's freshly-bound screen until its first server frame
|
|
53
|
+
// re-arms prediction. Mirrors composition.cancelComposition on the same
|
|
54
|
+
// detach: both local-echo paths reset on switch. predict.reset() hides the
|
|
55
|
+
// overlay (its onChange -> push -> setPredictedCursor(0,0,false)); the next
|
|
56
|
+
// wire:screen re-arms via onScreenFrame.
|
|
57
|
+
onDetach() {
|
|
58
|
+
predict.reset();
|
|
59
|
+
},
|
|
50
60
|
teardown() {
|
|
51
61
|
offTransform();
|
|
52
62
|
offObserver();
|