@cplieger/web-terminal-ui 3.0.3 → 3.1.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 +6 -0
- package/css/00-tokens.css +13 -1
- package/css/02-app.css +24 -120
- package/css/03-primitives.css +82 -0
- package/css/04-feedback.css +166 -0
- package/css/05-tabs.css +310 -0
- package/css/06-mobile.css +335 -0
- package/css/07-animations.css +102 -0
- package/css/MANIFEST +7 -1
- package/package.json +2 -2
- package/src/features/clipboard.ts +10 -0
- package/src/features/connection-banner.ts +1 -1
- package/src/features/context-menu.ts +174 -42
- package/src/features/mobile-toolbar.ts +59 -1
- package/src/features/scroll-to-bottom.ts +22 -6
- package/src/features/tabs.ts +1305 -206
- package/src/kernel/kernel.ts +65 -4
- package/src/kernel/types.ts +8 -0
- package/src/presets.ts +36 -6
- package/src/viewport.ts +16 -1
- package/css/03-v3.css +0 -629
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/* ===== Animations (animations feature) =====
|
|
2
|
+
Every bit of motion in the UI lives here, gated on the root .wt-animate class
|
|
3
|
+
that the animations feature sets only when the OS is not in reduced-motion.
|
|
4
|
+
Without the feature (or under prefers-reduced-motion) the UI is instant: base
|
|
5
|
+
rules in 03-06 carry the resting state, and these rules add the transitions
|
|
6
|
+
and keyframes on top. Keeping motion in one file is the design-system seam for
|
|
7
|
+
"does this UI animate, and how". */
|
|
8
|
+
|
|
9
|
+
/* Fade transient surfaces in/out (display allow-discrete plays the fade before
|
|
10
|
+
the discrete display:none on hide). */
|
|
11
|
+
.wt-animate .wt-toast,
|
|
12
|
+
.wt-animate .wt-conn-banner,
|
|
13
|
+
.wt-animate .wt-scroll-bottom,
|
|
14
|
+
.wt-animate .wt-ctx-menu,
|
|
15
|
+
.wt-animate .wt-tab-menu {
|
|
16
|
+
transition:
|
|
17
|
+
opacity var(--dur-standard) var(--ease-standard),
|
|
18
|
+
display var(--dur-standard) allow-discrete;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* Tab chrome micro-transitions (hover/active color shifts). */
|
|
22
|
+
.wt-animate .wt-tab,
|
|
23
|
+
.wt-animate .wt-tab-close,
|
|
24
|
+
.wt-animate .wt-tab-new {
|
|
25
|
+
transition:
|
|
26
|
+
background-color var(--dur-micro) var(--ease-standard),
|
|
27
|
+
color var(--dur-micro) var(--ease-standard);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* New tab enters with a fade + scale-in. Only runtime-added tabs get
|
|
31
|
+
.wt-tab-enter (the tabs feature's `started` gate). A closing tab is removed
|
|
32
|
+
immediately with no exit animation, so the strip reflows in one frame (a
|
|
33
|
+
delayed exit made the "+" teleport and put a last-tab replacement in the wrong
|
|
34
|
+
slot). scale() is a transform, so the enter never shifts the "+". */
|
|
35
|
+
.wt-animate .wt-tab-enter {
|
|
36
|
+
animation: wt-tab-in var(--dur-enter) var(--ease-enter);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@keyframes wt-tab-in {
|
|
40
|
+
from {
|
|
41
|
+
opacity: 0;
|
|
42
|
+
transform: scale(0.82);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Tab list expand/collapse: the mobile bar grows upward to reveal the other
|
|
47
|
+
tabs and shrinks back. max-height and the vertical padding animate together (0
|
|
48
|
+
to the expanded values and back), so the whole tray opens/closes as one motion
|
|
49
|
+
— animating only max-height let the padding snap away instantly at the start
|
|
50
|
+
of a collapse (a choppy, two-step close). The tabs feature clears the rows
|
|
51
|
+
after the collapse. */
|
|
52
|
+
.wt-animate .wt-switcher-list {
|
|
53
|
+
transition:
|
|
54
|
+
max-height var(--dur-enter) var(--ease-standard),
|
|
55
|
+
padding var(--dur-enter) var(--ease-standard);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* When the tab list expands, the keyboard button collapses to zero width and
|
|
59
|
+
fades (the key grid is closed in JS); the active row flex-grows into the freed
|
|
60
|
+
space in lockstep as the button's width animates. */
|
|
61
|
+
.wt-animate .wt-switcher-kb {
|
|
62
|
+
transition:
|
|
63
|
+
width var(--dur-enter) var(--ease-standard),
|
|
64
|
+
margin var(--dur-enter) var(--ease-standard),
|
|
65
|
+
opacity var(--dur-standard) var(--ease-standard);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Switch motion: a subtle content fade on switch, so the cache-first repaint
|
|
69
|
+
reads as a change rather than a flicker. A swipe additionally slides the
|
|
70
|
+
content in from the swipe's direction (next from the right, prev from the
|
|
71
|
+
left, WS5). */
|
|
72
|
+
.wt-animate .term.wt-switching .term-output {
|
|
73
|
+
animation: wt-switch-in var(--dur-standard) var(--ease-standard);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.wt-animate .term.wt-switching-next .term-output {
|
|
77
|
+
animation: wt-switch-next var(--dur-standard) var(--ease-standard);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.wt-animate .term.wt-switching-prev .term-output {
|
|
81
|
+
animation: wt-switch-prev var(--dur-standard) var(--ease-standard);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@keyframes wt-switch-in {
|
|
85
|
+
from {
|
|
86
|
+
opacity: 0.55;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@keyframes wt-switch-next {
|
|
91
|
+
from {
|
|
92
|
+
opacity: 0.3;
|
|
93
|
+
transform: translateX(18%);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@keyframes wt-switch-prev {
|
|
98
|
+
from {
|
|
99
|
+
opacity: 0.3;
|
|
100
|
+
transform: translateX(-18%);
|
|
101
|
+
}
|
|
102
|
+
}
|
package/css/MANIFEST
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# web-terminal-ui CSS bundle manifest
|
|
2
2
|
# Files concatenated in declared order to produce the served style.css.
|
|
3
|
+
# The order IS the cascade: tokens + reset + terminal core, then the v3 design
|
|
4
|
+
# system (shared primitives, then per-feature chrome), then all motion last.
|
|
3
5
|
00-tokens.css
|
|
4
6
|
01-reset.css
|
|
5
7
|
02-app.css
|
|
6
|
-
03-
|
|
8
|
+
03-primitives.css
|
|
9
|
+
04-feedback.css
|
|
10
|
+
05-tabs.css
|
|
11
|
+
06-mobile.css
|
|
12
|
+
07-animations.css
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.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",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"eslint": "10.6.0",
|
|
35
35
|
"fast-check": "4.8.0",
|
|
36
36
|
"happy-dom": "20.10.6",
|
|
37
|
-
"html-validate": "11.5.
|
|
37
|
+
"html-validate": "11.5.5",
|
|
38
38
|
"knip": "6.23.0",
|
|
39
39
|
"prettier": "3.9.4",
|
|
40
40
|
"stylelint": "17.14.0",
|
|
@@ -54,6 +54,16 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
|
|
|
54
54
|
// Desktop clipboard shortcuts, handled before the kernel's key mapping so
|
|
55
55
|
// they take the browser selection/clipboard rather than server-bound bytes.
|
|
56
56
|
const offKey = ctx.registerKeydown((ev) => {
|
|
57
|
+
// Plain Ctrl+V: let the browser's NATIVE paste event flow into the
|
|
58
|
+
// hidden textarea (the kernel's insertFromPaste path sends it through the
|
|
59
|
+
// sanitizing funnel). Consuming the key WITHOUT preventDefault stops the
|
|
60
|
+
// kernel mapping Ctrl+V to \x16 while leaving the native paste intact — so
|
|
61
|
+
// no navigator.clipboard.readText(), hence no Firefox clipboard-read
|
|
62
|
+
// popup. (Cmd+V on macOS already pastes natively and never reached the
|
|
63
|
+
// \x16 mapping.)
|
|
64
|
+
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.code === "KeyV") {
|
|
65
|
+
return true; // do NOT preventDefault — the browser pastes natively
|
|
66
|
+
}
|
|
57
67
|
if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
|
58
68
|
if (ev.code === "KeyC") {
|
|
59
69
|
const sel = window.getSelection()?.toString();
|
|
@@ -10,7 +10,7 @@ const TEXT: Partial<Record<ConnState, string>> = {
|
|
|
10
10
|
connecting: "Reconnecting\u2026",
|
|
11
11
|
reconnecting: "Reconnecting\u2026",
|
|
12
12
|
offline: "Offline",
|
|
13
|
-
restarted: "Server restarted
|
|
13
|
+
restarted: "Server restarted; recent input may be lost",
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
export function connectionBanner(): TerminalFeature {
|
|
@@ -2,26 +2,50 @@
|
|
|
2
2
|
// Paste) rendered into the overlay region (design section 22.4). Copy/Paste are
|
|
3
3
|
// offered only when the clipboard feature is present (ctx.use), routing Paste
|
|
4
4
|
// through the kernel's sanitizing funnel. Escape-to-close goes through the
|
|
5
|
-
// kernel keydown intercept; outside-click
|
|
5
|
+
// kernel keydown intercept; outside-click is a document gesture.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
// terminal
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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).
|
|
19
|
+
//
|
|
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 — native always wins over
|
|
26
|
+
// text. The platforms deliver the long-press differently: iOS fires NO
|
|
27
|
+
// contextmenu (a hold timer detects it), Android DOES (we handle it and
|
|
28
|
+
// preventDefault it when showing our own, so Android's native menu doesn't also
|
|
29
|
+
// appear — the "both menus" case). Both paths are gated by the same selection
|
|
30
|
+
// check, so the outcome is consistent across platforms.
|
|
14
31
|
|
|
15
32
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
16
33
|
import type { ClipboardApi } from "./clipboard.js";
|
|
17
34
|
|
|
18
|
-
|
|
35
|
+
// Slightly longer than the browser's ~500ms native long-press so a native word
|
|
36
|
+
// selection (which we defer to) has registered — and fired selectionchange —
|
|
37
|
+
// before this fires.
|
|
38
|
+
const LONG_PRESS_MS = 550;
|
|
19
39
|
const LONG_PRESS_MOVE_PX = 10;
|
|
20
40
|
const EDGE_MARGIN = 8;
|
|
21
41
|
// When the menu can't fit below the anchor (a long-press near the bottom edge or
|
|
22
42
|
// above the keyboard), it opens above the point. This gap lifts it clear of the
|
|
23
43
|
// fingertip so the touch does not cover the menu items.
|
|
24
44
|
const MENU_GAP = 16;
|
|
45
|
+
// After a touch long-press opens the menu, swallow the trailing click/tap that
|
|
46
|
+
// the same gesture emits on finger-release for this window, so the menu does not
|
|
47
|
+
// immediately dismiss itself (the classic contextmenu-then-touchend race).
|
|
48
|
+
const SWALLOW_MS = 350;
|
|
25
49
|
|
|
26
50
|
export interface ContextMenuOptions {
|
|
27
51
|
/** The clipboard feature value, so the menu can offer Copy/Paste through its
|
|
@@ -40,6 +64,18 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
40
64
|
|
|
41
65
|
let longPressTimer = 0;
|
|
42
66
|
let longPressOrigin = { x: 0, y: 0 };
|
|
67
|
+
// The device that started the current interaction: contextmenu fires on
|
|
68
|
+
// desktop right-click AND on Android long-press, so its handler branches
|
|
69
|
+
// on this to tell them apart.
|
|
70
|
+
let lastPointerType = "mouse";
|
|
71
|
+
// Timestamp until which a document click is swallowed (see SWALLOW_MS).
|
|
72
|
+
let swallowUntil = 0;
|
|
73
|
+
// A native selection was made during the current touch press (tracked via
|
|
74
|
+
// selectionchange). Gates the touch menu: a selected long-press belongs to
|
|
75
|
+
// the native callout, an empty one to us (Paste).
|
|
76
|
+
let pressSelectedText = false;
|
|
77
|
+
// A touch press is in progress, so selectionchange is relevant (armed).
|
|
78
|
+
let touchActive = false;
|
|
43
79
|
|
|
44
80
|
const clip = (): ClipboardApi | undefined =>
|
|
45
81
|
opts.clipboard ? ctx.use(opts.clipboard) : undefined;
|
|
@@ -48,45 +84,74 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
48
84
|
surface.querySelector<HTMLElement>(".term-input")?.focus({ preventScroll: true });
|
|
49
85
|
}
|
|
50
86
|
|
|
51
|
-
|
|
52
|
-
|
|
87
|
+
const clearLongPress = (): void => {
|
|
88
|
+
if (longPressTimer) {
|
|
89
|
+
clearTimeout(longPressTimer);
|
|
90
|
+
longPressTimer = 0;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// True when text is (or just got) selected, so the OS callout owns Copy and
|
|
95
|
+
// our menu stays out of the way. pressSelectedText catches a native
|
|
96
|
+
// selection the instant it happens (selectionchange), avoiding a race with
|
|
97
|
+
// a fixed-delay read; the live check is the fallback for when selectionchange
|
|
98
|
+
// did not arm (a desktop right-click on an existing selection).
|
|
99
|
+
const hasNativeSelection = (): boolean => {
|
|
100
|
+
if (pressSelectedText) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
const s = window.getSelection();
|
|
104
|
+
return s !== null && !s.isCollapsed && s.toString().length > 0;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function hide(refocus = true): void {
|
|
108
|
+
const shouldRefocus = refocus && menu.contains(document.activeElement);
|
|
53
109
|
menu.classList.remove("visible");
|
|
54
110
|
menu.replaceChildren();
|
|
55
|
-
if (
|
|
111
|
+
if (shouldRefocus) {
|
|
56
112
|
focusInput();
|
|
57
113
|
}
|
|
58
114
|
}
|
|
59
115
|
|
|
60
|
-
|
|
116
|
+
// refocus=false suppresses the return-focus-to-input step: Select All must
|
|
117
|
+
// NOT refocus the textarea, or Firefox collapses the just-made selection
|
|
118
|
+
// when focus leaves the output.
|
|
119
|
+
function addButton(label: string, onClick: () => void, refocus = true): void {
|
|
61
120
|
const b = document.createElement("button");
|
|
62
121
|
b.type = "button";
|
|
63
122
|
b.textContent = label;
|
|
64
123
|
b.addEventListener("click", () => {
|
|
65
124
|
onClick();
|
|
66
|
-
hide();
|
|
125
|
+
hide(refocus);
|
|
67
126
|
});
|
|
68
127
|
menu.appendChild(b);
|
|
69
128
|
}
|
|
70
129
|
|
|
71
|
-
function show(x: number, y: number): void {
|
|
130
|
+
function show(x: number, y: number, touch: boolean): void {
|
|
72
131
|
hide();
|
|
73
132
|
const clipboard = clip();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (s && output) {
|
|
84
|
-
s.selectAllChildren(output);
|
|
133
|
+
// On touch we only reach here over empty space (no selection), so Copy
|
|
134
|
+
// has nothing to act on; the native callout owns Copy over selected
|
|
135
|
+
// text. On desktop, offer Copy when there is a selection.
|
|
136
|
+
if (!touch) {
|
|
137
|
+
const sel = window.getSelection()?.toString();
|
|
138
|
+
if (sel) {
|
|
139
|
+
addButton("Copy", () => {
|
|
140
|
+
clipboard?.copy(sel);
|
|
141
|
+
});
|
|
85
142
|
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
143
|
+
}
|
|
144
|
+
addButton(
|
|
145
|
+
"Select All",
|
|
146
|
+
() => {
|
|
147
|
+
const s = window.getSelection();
|
|
148
|
+
const output = surface.querySelector(".term-output");
|
|
149
|
+
if (s && output) {
|
|
150
|
+
s.selectAllChildren(output);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
false,
|
|
154
|
+
);
|
|
90
155
|
if (clipboard) {
|
|
91
156
|
addButton("Paste", () => {
|
|
92
157
|
clipboard.paste();
|
|
@@ -125,14 +190,61 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
125
190
|
);
|
|
126
191
|
menu.style.left = `${String(left)}px`;
|
|
127
192
|
menu.style.top = `${String(top)}px`;
|
|
193
|
+
// A touch long-press is immediately followed by a synthetic click on
|
|
194
|
+
// release; swallow it so the menu stays open (see SWALLOW_MS).
|
|
195
|
+
if (touch) {
|
|
196
|
+
swallowUntil = performance.now() + SWALLOW_MS;
|
|
197
|
+
}
|
|
128
198
|
}
|
|
129
199
|
|
|
200
|
+
const onPointerDown = (e: PointerEvent): void => {
|
|
201
|
+
lastPointerType = e.pointerType;
|
|
202
|
+
};
|
|
203
|
+
surface.addEventListener("pointerdown", onPointerDown, { passive: true });
|
|
204
|
+
|
|
205
|
+
// contextmenu fires on desktop right-click AND on Android long-press (iOS
|
|
206
|
+
// fires none). Desktop: always our menu. Touch (Android): our menu only
|
|
207
|
+
// when nothing is selected — and preventDefault so Android's native menu
|
|
208
|
+
// does not ALSO appear (the "both menus" bug); with a selection, defer to
|
|
209
|
+
// the native selection toolbar (don't preventDefault).
|
|
130
210
|
const onContextMenu = (e: MouseEvent): void => {
|
|
211
|
+
if (lastPointerType !== "touch") {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
show(e.clientX, e.clientY, false);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (menu.classList.contains("visible")) {
|
|
217
|
+
e.preventDefault(); // the hold timer already opened it; just suppress native
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (hasNativeSelection()) {
|
|
221
|
+
return; // native selection toolbar owns Copy over text
|
|
222
|
+
}
|
|
131
223
|
e.preventDefault();
|
|
132
|
-
|
|
224
|
+
clearLongPress();
|
|
225
|
+
show(e.clientX, e.clientY, true);
|
|
133
226
|
};
|
|
134
227
|
surface.addEventListener("contextmenu", onContextMenu);
|
|
135
228
|
|
|
229
|
+
// Track a native selection made during the current touch press, so the
|
|
230
|
+
// long-press decision never races a fixed-delay read. Armed only while a
|
|
231
|
+
// touch is down (touchActive) so unrelated selection changes (Select All,
|
|
232
|
+
// typing) don't trip it.
|
|
233
|
+
const onSelectionChange = (): void => {
|
|
234
|
+
if (!touchActive) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const s = window.getSelection();
|
|
238
|
+
if (s && !s.isCollapsed && s.toString().length > 0) {
|
|
239
|
+
pressSelectedText = true;
|
|
240
|
+
clearLongPress(); // native selection is happening; don't open our menu
|
|
241
|
+
if (menu.classList.contains("visible")) {
|
|
242
|
+
hide(false); // a native selection appeared after we opened; native wins
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
document.addEventListener("selectionchange", onSelectionChange);
|
|
247
|
+
|
|
136
248
|
// Escape closes the menu without also sending ESC to the PTY.
|
|
137
249
|
const offKey = ctx.registerKeydown((ev) => {
|
|
138
250
|
if (ev.key === "Escape" && menu.classList.contains("visible")) {
|
|
@@ -144,17 +256,26 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
144
256
|
});
|
|
145
257
|
|
|
146
258
|
const onDocClick = (): void => {
|
|
259
|
+
// Swallow the release click of the long-press that just opened the menu.
|
|
260
|
+
if (performance.now() < swallowUntil) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
147
263
|
hide();
|
|
148
264
|
};
|
|
149
265
|
document.addEventListener("click", onDocClick);
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
266
|
+
// A right-click outside the terminal surface (a tab, its menu, elsewhere,
|
|
267
|
+
// or a native browser menu) dismisses this menu. A right-click on the
|
|
268
|
+
// surface is handled by onContextMenu (which reopens it) and fires first.
|
|
269
|
+
const onDocContextMenu = (e: MouseEvent): void => {
|
|
270
|
+
if (!surface.contains(e.target as Node)) {
|
|
271
|
+
hide();
|
|
156
272
|
}
|
|
157
273
|
};
|
|
274
|
+
document.addEventListener("contextmenu", onDocContextMenu);
|
|
275
|
+
|
|
276
|
+
// Touch long-press — the iOS path (iOS fires no contextmenu). Open the menu
|
|
277
|
+
// only when nothing got natively selected during the hold; if a word/line
|
|
278
|
+
// was selected, the OS callout owns it and we stay out.
|
|
158
279
|
const onTouchStart = (e: TouchEvent): void => {
|
|
159
280
|
if (e.touches.length !== 1) {
|
|
160
281
|
clearLongPress();
|
|
@@ -164,10 +285,14 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
164
285
|
if (!t) {
|
|
165
286
|
return;
|
|
166
287
|
}
|
|
288
|
+
touchActive = true;
|
|
289
|
+
pressSelectedText = false;
|
|
167
290
|
longPressOrigin = { x: t.clientX, y: t.clientY };
|
|
168
291
|
longPressTimer = window.setTimeout(() => {
|
|
169
292
|
longPressTimer = 0;
|
|
170
|
-
|
|
293
|
+
if (!hasNativeSelection()) {
|
|
294
|
+
show(longPressOrigin.x, longPressOrigin.y, true);
|
|
295
|
+
}
|
|
171
296
|
}, LONG_PRESS_MS);
|
|
172
297
|
};
|
|
173
298
|
const onTouchMove = (e: TouchEvent): void => {
|
|
@@ -184,21 +309,28 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
184
309
|
clearLongPress();
|
|
185
310
|
}
|
|
186
311
|
};
|
|
312
|
+
const onTouchEnd = (): void => {
|
|
313
|
+
clearLongPress();
|
|
314
|
+
touchActive = false;
|
|
315
|
+
};
|
|
187
316
|
surface.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
188
317
|
surface.addEventListener("touchmove", onTouchMove, { passive: true });
|
|
189
|
-
surface.addEventListener("touchend",
|
|
190
|
-
surface.addEventListener("touchcancel",
|
|
318
|
+
surface.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
319
|
+
surface.addEventListener("touchcancel", onTouchEnd, { passive: true });
|
|
191
320
|
|
|
192
321
|
return {
|
|
193
322
|
teardown() {
|
|
194
323
|
clearLongPress();
|
|
195
324
|
offKey();
|
|
325
|
+
surface.removeEventListener("pointerdown", onPointerDown);
|
|
196
326
|
surface.removeEventListener("contextmenu", onContextMenu);
|
|
327
|
+
document.removeEventListener("selectionchange", onSelectionChange);
|
|
197
328
|
document.removeEventListener("click", onDocClick);
|
|
329
|
+
document.removeEventListener("contextmenu", onDocContextMenu);
|
|
198
330
|
surface.removeEventListener("touchstart", onTouchStart);
|
|
199
331
|
surface.removeEventListener("touchmove", onTouchMove);
|
|
200
|
-
surface.removeEventListener("touchend",
|
|
201
|
-
surface.removeEventListener("touchcancel",
|
|
332
|
+
surface.removeEventListener("touchend", onTouchEnd);
|
|
333
|
+
surface.removeEventListener("touchcancel", onTouchEnd);
|
|
202
334
|
menu.remove();
|
|
203
335
|
},
|
|
204
336
|
};
|
|
@@ -11,6 +11,31 @@ import { fromHTML } from "./dom.js";
|
|
|
11
11
|
|
|
12
12
|
const { bindMobileToolbar } = keyboard;
|
|
13
13
|
|
|
14
|
+
/** mobileToolbar's public API, so a peer feature (tabs) can drive the key grid
|
|
15
|
+
* from a button it owns instead of the toolbar's own toggle. */
|
|
16
|
+
export interface MobileToolbarApi {
|
|
17
|
+
/** Show/hide the key grid (flips the toolbar's collapsed state). */
|
|
18
|
+
toggle(): void;
|
|
19
|
+
/** Whether the key grid is currently open. */
|
|
20
|
+
isOpen(): boolean;
|
|
21
|
+
/** Whether sticky-Ctrl is currently armed (a pending Ctrl press). */
|
|
22
|
+
isCtrlArmed(): boolean;
|
|
23
|
+
/** Subscribe to sticky-Ctrl arm/disarm; returns an unsubscribe. Lets a peer
|
|
24
|
+
* (the tab bar's keyboard button) surface the pending modifier even while the
|
|
25
|
+
* key grid — and its own Ctrl button — is closed. */
|
|
26
|
+
onCtrlArmedChange(fn: (armed: boolean) => void): () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Options for the mobileToolbar feature. */
|
|
30
|
+
export interface MobileToolbarOptions {
|
|
31
|
+
/** When true, the toolbar hides its own top-right toggle button and positions
|
|
32
|
+
* the key grid to open above the mobile tab bar. The grid is then driven
|
|
33
|
+
* externally through the returned API (tabs renders a keyboard button in the
|
|
34
|
+
* switcher bar and calls toggle()). The tabbed presets set this; presetTouch
|
|
35
|
+
* leaves it off and keeps the self-contained top-right toggle. */
|
|
36
|
+
externalToggle?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
14
39
|
// The key grid, without the scroll-to-bottom button (that is the scrollToBottom
|
|
15
40
|
// feature, composed into the same region's "scroll" slot).
|
|
16
41
|
const TOOLBAR_HTML = `
|
|
@@ -26,20 +51,34 @@ const TOOLBAR_HTML = `
|
|
|
26
51
|
<button type="button" id="kb-right" class="kb-key kb-r2c4" aria-label="Right"><svg viewBox="0 0 24 24"><polyline points="9 6 15 12 9 18"/></svg></button>
|
|
27
52
|
</div>`;
|
|
28
53
|
|
|
29
|
-
export function mobileToolbar(): TerminalFeature {
|
|
54
|
+
export function mobileToolbar(opts: MobileToolbarOptions = {}): TerminalFeature<MobileToolbarApi> {
|
|
30
55
|
return {
|
|
31
56
|
name: "mobileToolbar",
|
|
32
57
|
setup(ctx) {
|
|
33
58
|
const encoder = new TextEncoder();
|
|
34
59
|
const decoder = new TextDecoder();
|
|
35
60
|
const toolbar = fromHTML(TOOLBAR_HTML);
|
|
61
|
+
// Externally-driven mode (the tabbed presets): the toolbar hides its own
|
|
62
|
+
// toggle and opens the grid above the mobile tab bar; tabs drives it via
|
|
63
|
+
// the returned API. The class gates both behaviors in CSS.
|
|
64
|
+
if (opts.externalToggle) {
|
|
65
|
+
toolbar.classList.add("wt-toolbar-external");
|
|
66
|
+
}
|
|
36
67
|
ctx.region("bottom-inset-end", "keys").appendChild(toolbar);
|
|
37
68
|
|
|
69
|
+
// Fan sticky-Ctrl arm/disarm out to subscribers (the tab bar's keyboard
|
|
70
|
+
// button mirrors it, so a pending Ctrl is visible with the grid closed).
|
|
71
|
+
const armedListeners = new Set<(armed: boolean) => void>();
|
|
38
72
|
const ctrl = bindMobileToolbar({
|
|
39
73
|
toolbar,
|
|
40
74
|
send: (bytes) => {
|
|
41
75
|
ctx.send(encoder.encode(bytes));
|
|
42
76
|
},
|
|
77
|
+
onCtrlChange: (armed) => {
|
|
78
|
+
for (const fn of [...armedListeners]) {
|
|
79
|
+
fn(armed);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
43
82
|
});
|
|
44
83
|
|
|
45
84
|
// Sticky-Ctrl as an outbound-byte transform: when armed, a single typed
|
|
@@ -63,6 +102,24 @@ export function mobileToolbar(): TerminalFeature {
|
|
|
63
102
|
});
|
|
64
103
|
|
|
65
104
|
return {
|
|
105
|
+
// Expose grid open/close so a peer (tabs) can drive it from the tab bar.
|
|
106
|
+
// Toggling the same `.collapsed` class the engine's own kb-toggle flips
|
|
107
|
+
// keeps a single source of truth for the grid's visibility.
|
|
108
|
+
api: {
|
|
109
|
+
toggle() {
|
|
110
|
+
toolbar.classList.toggle("collapsed");
|
|
111
|
+
},
|
|
112
|
+
isOpen() {
|
|
113
|
+
return !toolbar.classList.contains("collapsed");
|
|
114
|
+
},
|
|
115
|
+
isCtrlArmed() {
|
|
116
|
+
return ctrl.isCtrlArmed();
|
|
117
|
+
},
|
|
118
|
+
onCtrlArmedChange(fn) {
|
|
119
|
+
armedListeners.add(fn);
|
|
120
|
+
return () => armedListeners.delete(fn);
|
|
121
|
+
},
|
|
122
|
+
},
|
|
66
123
|
onDetach() {
|
|
67
124
|
// A tab switch disarms a latched sticky-Ctrl so a pending Ctrl does
|
|
68
125
|
// not fire against the incoming session (e.g. an accidental Ctrl+C to
|
|
@@ -73,6 +130,7 @@ export function mobileToolbar(): TerminalFeature {
|
|
|
73
130
|
},
|
|
74
131
|
teardown() {
|
|
75
132
|
offTransform();
|
|
133
|
+
armedListeners.clear();
|
|
76
134
|
ctrl.dispose();
|
|
77
135
|
toolbar.remove();
|
|
78
136
|
},
|
|
@@ -20,18 +20,34 @@ export function scrollToBottom(): TerminalFeature {
|
|
|
20
20
|
const btn = fromHTML(BUTTON_HTML);
|
|
21
21
|
slot.appendChild(btn);
|
|
22
22
|
|
|
23
|
+
// Jump to the bottom. With motion allowed, smooth-scroll the surface so
|
|
24
|
+
// the jump is animated rather than an instant teleport; the engine's
|
|
25
|
+
// scroll controller re-derives its follow state from the scroll position
|
|
26
|
+
// (its listener sees the animation land at the bottom), so following
|
|
27
|
+
// re-engages with no programmatic flag, consistent with the engine's
|
|
28
|
+
// position-only model. Under prefers-reduced-motion, fall back to the
|
|
29
|
+
// engine's instant scrollToBottom (which re-engages following synchronously).
|
|
30
|
+
const jump = (): void => {
|
|
31
|
+
const reduce =
|
|
32
|
+
typeof window.matchMedia === "function" &&
|
|
33
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
34
|
+
if (reduce) {
|
|
35
|
+
ctx.scroll.scrollToBottom();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const surface = ctx.surface();
|
|
39
|
+
surface.scrollTo({ top: surface.scrollHeight, behavior: "smooth" });
|
|
40
|
+
};
|
|
41
|
+
|
|
23
42
|
// pointerdown (like the toolbar keys) so touch devices show press
|
|
24
43
|
// feedback; preventDefault keeps focus on the terminal. click is kept for
|
|
25
|
-
// keyboard activation.
|
|
44
|
+
// keyboard activation. jump is idempotent, so the pair is safe.
|
|
26
45
|
const onDown = (e: PointerEvent): void => {
|
|
27
46
|
e.preventDefault();
|
|
28
|
-
|
|
29
|
-
};
|
|
30
|
-
const onClick = (): void => {
|
|
31
|
-
ctx.scroll.scrollToBottom();
|
|
47
|
+
jump();
|
|
32
48
|
};
|
|
33
49
|
btn.addEventListener("pointerdown", onDown);
|
|
34
|
-
btn.addEventListener("click",
|
|
50
|
+
btn.addEventListener("click", jump);
|
|
35
51
|
|
|
36
52
|
// Visible only while scrolled up; the region reflects it via a class.
|
|
37
53
|
const off = ctx.on("scroll:state", ({ scrolledUp }) => {
|