@cplieger/web-terminal-ui 7.3.2 → 8.0.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 +114 -18
- package/css/30-tabs.css +21 -0
- package/css/31-switcher.css +15 -0
- package/css/32-split.css +176 -0
- package/css/MANIFEST +1 -0
- package/css/MANIFEST.tabbed +1 -0
- package/package.json +13 -12
- package/src/composition.ts +165 -237
- package/src/features/activity-monitor.ts +20 -55
- package/src/features/animations.ts +12 -30
- package/src/features/clipboard.ts +36 -64
- package/src/features/connection-banner.ts +10 -19
- package/src/features/context-menu.ts +70 -145
- package/src/features/dom.ts +44 -54
- package/src/features/menu-position.ts +24 -28
- package/src/features/mobile-toolbar.ts +32 -62
- package/src/features/predictive-echo.ts +15 -49
- package/src/features/scroll-to-bottom.ts +12 -24
- package/src/features/tabs/index.ts +1398 -1806
- package/src/features/tabs/ink-centre.ts +45 -54
- package/src/features/tabs/model.ts +301 -383
- package/src/features/tabs/strip.ts +45 -97
- package/src/features/tabs/switcher.ts +70 -48
- package/src/index.ts +13 -13
- package/src/kernel/a11y.ts +106 -27
- package/src/kernel/attention.ts +157 -0
- package/src/kernel/conn-state.ts +28 -22
- package/src/kernel/fatal.ts +76 -0
- package/src/kernel/feature-host.ts +136 -0
- package/src/kernel/gesture.ts +9 -13
- package/src/kernel/kernel.ts +57 -2139
- package/src/kernel/layout-policy.ts +44 -0
- package/src/kernel/loading-status.ts +30 -88
- package/src/kernel/navigation.ts +11 -17
- package/src/kernel/notify.ts +158 -0
- package/src/kernel/pane.ts +1415 -0
- package/src/kernel/realm.ts +11 -0
- package/src/kernel/regions.ts +8 -11
- package/src/kernel/scrollback.ts +88 -168
- package/src/kernel/selection.ts +25 -0
- package/src/kernel/shell.ts +1434 -0
- package/src/kernel/split-handle.ts +280 -0
- package/src/kernel/status-share.ts +100 -0
- package/src/kernel/types.ts +457 -441
- package/src/predict.ts +144 -170
- package/src/viewport.ts +124 -197
- package/src/features/tabs/attention.ts +0 -250
- package/src/features/tabs/notify.ts +0 -265
|
@@ -1,53 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* and OSC 52 mirroring. Exposes a typed API so contextMenu
|
|
4
|
-
* can offer Copy/Paste through it (ctx.use). With this feature absent, inbound
|
|
5
|
-
* OSC 52 has no subscriber and is intentionally a no-op.
|
|
6
|
-
*
|
|
7
|
-
* @module
|
|
8
|
-
*/
|
|
9
|
-
|
|
1
|
+
import { windowOf } from "../kernel/realm.js";
|
|
2
|
+
import { selectionTextWithin } from "../kernel/selection.js";
|
|
10
3
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
11
4
|
|
|
12
|
-
/** The value a peer
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* and their outcome is reported to the user as a toast rather than returned, so a
|
|
17
|
-
* caller cannot await or branch on success. Both require a secure context
|
|
18
|
-
* (`navigator.clipboard` is absent on a plain-HTTP non-loopback host, a supported
|
|
19
|
-
* web-terminal-server deployment), where they toast "Clipboard unavailable" and
|
|
20
|
-
* do nothing. */
|
|
5
|
+
/** The value a peer reads through `ctx.use(clipboard())`. Both members are
|
|
6
|
+
* fire-and-forget: the outcome reaches the user as a toast, never the caller.
|
|
7
|
+
* Outside a secure context (`navigator.clipboard` absent) they toast
|
|
8
|
+
* "Clipboard unavailable" and do nothing. */
|
|
21
9
|
export interface ClipboardApi {
|
|
22
|
-
/** Write text to the system clipboard
|
|
10
|
+
/** Write text to the system clipboard. */
|
|
23
11
|
copy(text: string): void;
|
|
24
12
|
/** Read the system clipboard and paste it through the sanitizing funnel. */
|
|
25
13
|
paste(): void;
|
|
26
14
|
}
|
|
27
15
|
|
|
28
|
-
/** Build the clipboard feature
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* event reaches the hidden textarea instead of being mapped to `\x16`), the
|
|
34
|
-
* `wire:clipboard` event that mirrors an application's OSC 52 copy to the system
|
|
35
|
-
* clipboard, and a document `copy` listener that toasts "Copied" for a selection
|
|
36
|
-
* inside the terminal surface only. Teardown releases all three. */
|
|
16
|
+
/** Build the clipboard feature: Ctrl+Shift+C copies the browser selection,
|
|
17
|
+
* Ctrl+Shift+V pastes, a plain Ctrl+V is consumed WITHOUT preventDefault so the
|
|
18
|
+
* browser's own paste event reaches the hidden textarea, an application's OSC 52
|
|
19
|
+
* copy is mirrored to the system clipboard, and a native copy of a selection
|
|
20
|
+
* inside the terminal toasts "Copied". Absent, inbound OSC 52 is a no-op. */
|
|
37
21
|
export function clipboard(): TerminalFeature<ClipboardApi> {
|
|
38
22
|
return {
|
|
39
23
|
name: "clipboard",
|
|
40
24
|
setup(ctx) {
|
|
25
|
+
const surface = ctx.surface();
|
|
26
|
+
const doc = ctx.shell.root.ownerDocument;
|
|
27
|
+
const win = windowOf(doc);
|
|
41
28
|
function copy(text: string): void {
|
|
42
|
-
// navigator.clipboard
|
|
43
|
-
// non-loopback host, a supported web-terminal-server deployment), where a
|
|
44
|
-
// property access on it throws synchronously. Feature-detect first.
|
|
29
|
+
// A property access on navigator.clipboard throws outside a secure context.
|
|
45
30
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- undefined outside secure contexts
|
|
46
|
-
if (!navigator.clipboard) {
|
|
31
|
+
if (!win.navigator.clipboard) {
|
|
47
32
|
ctx.toast("Clipboard unavailable");
|
|
48
33
|
return;
|
|
49
34
|
}
|
|
50
|
-
navigator.clipboard
|
|
35
|
+
win.navigator.clipboard
|
|
51
36
|
.writeText(text)
|
|
52
37
|
.then(() => {
|
|
53
38
|
ctx.toast("Copied");
|
|
@@ -59,11 +44,11 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
|
|
|
59
44
|
|
|
60
45
|
function paste(): void {
|
|
61
46
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- undefined outside secure contexts
|
|
62
|
-
if (!navigator.clipboard) {
|
|
47
|
+
if (!win.navigator.clipboard) {
|
|
63
48
|
ctx.toast("Clipboard unavailable");
|
|
64
49
|
return;
|
|
65
50
|
}
|
|
66
|
-
navigator.clipboard
|
|
51
|
+
win.navigator.clipboard
|
|
67
52
|
.readText()
|
|
68
53
|
.then((text) => {
|
|
69
54
|
ctx.paste(text);
|
|
@@ -73,23 +58,17 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
|
|
|
73
58
|
});
|
|
74
59
|
}
|
|
75
60
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
// hidden textarea (the kernel's insertFromPaste path sends it through the
|
|
81
|
-
// sanitizing funnel). Consuming the key WITHOUT preventDefault stops the
|
|
82
|
-
// kernel mapping Ctrl+V to \x16 while leaving the native paste intact — so
|
|
83
|
-
// no navigator.clipboard.readText(), hence no Firefox clipboard-read
|
|
84
|
-
// popup. (Cmd+V on macOS already pastes natively and never reached the
|
|
85
|
-
// \x16 mapping.)
|
|
61
|
+
ctx.registerKeydown((ev) => {
|
|
62
|
+
// Consuming plain Ctrl+V without preventDefault stops the kernel mapping
|
|
63
|
+
// it to \x16 while the native paste still flows into the textarea, so no
|
|
64
|
+
// clipboard read and no Firefox clipboard-read popup.
|
|
86
65
|
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.code === "KeyV") {
|
|
87
|
-
return true;
|
|
66
|
+
return true;
|
|
88
67
|
}
|
|
89
68
|
if (ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey) {
|
|
90
69
|
if (ev.code === "KeyC") {
|
|
91
|
-
const sel =
|
|
92
|
-
if (sel) {
|
|
70
|
+
const sel = selectionTextWithin(surface);
|
|
71
|
+
if (sel !== "") {
|
|
93
72
|
copy(sel);
|
|
94
73
|
}
|
|
95
74
|
ev.preventDefault();
|
|
@@ -104,32 +83,25 @@ export function clipboard(): TerminalFeature<ClipboardApi> {
|
|
|
104
83
|
return false;
|
|
105
84
|
});
|
|
106
85
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
const offClip = ctx.on("wire:clipboard", (text) => {
|
|
86
|
+
// An OSC 52 copy is an async push, not a user gesture, so some browsers
|
|
87
|
+
// reject it and copy() toasts "Copy failed".
|
|
88
|
+
ctx.on("wire:clipboard", (text) => {
|
|
111
89
|
copy(text);
|
|
112
90
|
});
|
|
113
91
|
|
|
114
|
-
// Native copy (e.g. Cmd/Ctrl+C on a selection) feedback toast, scoped to a
|
|
115
|
-
// selection inside the terminal surface so an embedding host's copies of
|
|
116
|
-
// its own (non-terminal) content do not raise a spurious "Copied".
|
|
117
|
-
const surface = ctx.surface();
|
|
118
92
|
const onCopy = (): void => {
|
|
119
|
-
|
|
120
|
-
if (node && surface.contains(node)) {
|
|
93
|
+
if (selectionTextWithin(surface) !== "") {
|
|
121
94
|
ctx.toast("Copied");
|
|
122
95
|
}
|
|
123
96
|
};
|
|
124
|
-
|
|
97
|
+
doc.addEventListener("copy", onCopy);
|
|
98
|
+
ctx.defer(() => {
|
|
99
|
+
doc.removeEventListener("copy", onCopy);
|
|
100
|
+
});
|
|
125
101
|
|
|
126
102
|
return {
|
|
127
103
|
api: { copy, paste },
|
|
128
|
-
teardown()
|
|
129
|
-
offKey();
|
|
130
|
-
offClip();
|
|
131
|
-
document.removeEventListener("copy", onCopy);
|
|
132
|
-
},
|
|
104
|
+
teardown: () => undefined,
|
|
133
105
|
};
|
|
134
106
|
},
|
|
135
107
|
};
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* lifecycle, and the give-up hook are kernel-owned; this feature is a pure
|
|
5
|
-
* renderer subscribing to connection:state. It uses the kernel's single
|
|
6
|
-
* announcer (ctx.announce) rather than spawning its own aria-live region.
|
|
7
|
-
*
|
|
8
|
-
* @module
|
|
9
|
-
*/
|
|
1
|
+
// The state machine, the loading lifecycle and the give-up hook are the kernel's;
|
|
2
|
+
// this feature only renders connection:state, and announces through the kernel's
|
|
3
|
+
// one announcer rather than an aria-live region of its own.
|
|
10
4
|
|
|
11
5
|
import type { ConnState, TerminalFeature } from "../kernel/types.js";
|
|
12
6
|
|
|
@@ -21,21 +15,18 @@ const TEXT: Partial<Record<ConnState, string>> = {
|
|
|
21
15
|
incompatible: "Terminal protocol mismatch; update the server or reload this page",
|
|
22
16
|
};
|
|
23
17
|
|
|
24
|
-
/** Build the connectionBanner feature. Exposes no API
|
|
25
|
-
* the
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* cannot wait out. Each change is announced once through `ctx.announce`, so a
|
|
31
|
-
* flaky link re-entering the same state does not repeat itself to a screen
|
|
32
|
-
* reader. Teardown unsubscribes and removes the element. */
|
|
18
|
+
/** Build the connectionBanner feature. Exposes no API: it owns one element in
|
|
19
|
+
* the "banner" region. Only the states with copy above are shown, so it is a
|
|
20
|
+
* transient surface, not a status line; `restarted`, `ended` and
|
|
21
|
+
* `incompatible` are the terminal ones a user cannot wait out. Each change is
|
|
22
|
+
* announced once, so a flaky link re-entering a state does not repeat itself
|
|
23
|
+
* to a screen reader. Teardown unsubscribes and removes the element. */
|
|
33
24
|
export function connectionBanner(): TerminalFeature {
|
|
34
25
|
return {
|
|
35
26
|
name: "connectionBanner",
|
|
36
27
|
setup(ctx) {
|
|
37
28
|
const slot = ctx.region("banner", "status");
|
|
38
|
-
const banner =
|
|
29
|
+
const banner = ctx.shell.root.ownerDocument.createElement("div");
|
|
39
30
|
banner.className = "wt-conn-banner";
|
|
40
31
|
slot.appendChild(banner);
|
|
41
32
|
|
|
@@ -1,151 +1,69 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
* WHY THIS MENU EXISTS
|
|
9
|
-
* The terminal output is real DOM text, so the browser already does the hard
|
|
10
|
-
* part: drag-select with a mouse, long-press word-select plus the OS copy callout
|
|
11
|
-
* on touch. What no platform can offer is PASTE — the keyboard target is a 1x1
|
|
12
|
-
* pointer-events:none textarea, so there is no editable surface under the pointer
|
|
13
|
-
* for a native paste item to attach to. This menu is the paste path. It carries
|
|
14
|
-
* Copy and Select All because a menu appearing where the platform's would have
|
|
15
|
-
* appeared should not be missing them.
|
|
16
|
-
*
|
|
17
|
-
* THE MODEL: one owner per gesture, and nothing decided mid-gesture.
|
|
18
|
-
*
|
|
19
|
-
* Mouse / pen — `contextmenu` is an already-classified request for a menu:
|
|
20
|
-
* preventDefault and open at the pointer. No timers, no heuristics, no
|
|
21
|
-
* platform branches.
|
|
22
|
-
*
|
|
23
|
-
* Touch — the platform owns the press while the finger is down. We run no hold
|
|
24
|
-
* timer against it, open nothing before it finishes, and never cancel its
|
|
25
|
-
* gesture on WebKit. We classify ONCE, on `touchend`, when every fact is
|
|
26
|
-
* settled: a single-finger, stationary press held past the tap ceiling
|
|
27
|
-
* (kernel/gesture.ts, shared with the kernel's tap-to-focus so the two cannot
|
|
28
|
-
* both claim one press) that selected nothing and did not start on a link is a
|
|
29
|
-
* press the platform declined — so it is ours, and it is the paste path. A
|
|
30
|
-
* press that produced a selection belongs to the OS callout; a press on a link
|
|
31
|
-
* belongs to the platform's link preview; a shorter press is the kernel's tap.
|
|
32
|
-
*
|
|
33
|
-
* WHY AT RELEASE RATHER THAN DURING THE HOLD
|
|
34
|
-
* Opening from a ~550ms hold timer put this feature in a race it cannot win: the
|
|
35
|
-
* timer has to beat the platform's own long-press threshold and then GUESS
|
|
36
|
-
* whether a selection is still coming (iOS 26 registers a word selection well
|
|
37
|
-
* after 550ms). Four mechanisms existed only to referee that guess — a hit test
|
|
38
|
-
* for glyphs under the finger, a selectionchange watch to retract a menu that
|
|
39
|
-
* opened too early, a device sniff, and per-platform contextmenu branching,
|
|
40
|
-
* because WebKit reads preventDefault on a touch contextmenu as "cancel every
|
|
41
|
-
* remaining default of this gesture", the not-yet-registered selection included.
|
|
42
|
-
* Deciding at release deletes all four: the outcome is observed, not predicted.
|
|
43
|
-
*
|
|
44
|
-
* It also fixes the symptom that prompted the rewrite. A touch long-press emits a
|
|
45
|
-
* trailing click on release, and the swallow window that covers that click was
|
|
46
|
-
* armed when the menu OPENED — ~550ms into the press, expiring 350ms later — so
|
|
47
|
-
* holding a beat longer meant the release click landed as an outside click and
|
|
48
|
-
* dismissed the menu the instant the finger lifted. Opened BY the release, the
|
|
49
|
-
* window can only ever start at the release edge.
|
|
50
|
-
*
|
|
51
|
-
* @module
|
|
52
|
-
*/
|
|
1
|
+
// Exists for PASTE: the keyboard target is a 1x1 pointer-events:none textarea, so
|
|
2
|
+
// no platform can attach a native paste item under the pointer. A touch press is
|
|
3
|
+
// classified ONCE, at touchend, when every fact is settled (a stationary
|
|
4
|
+
// single-finger press past the tap ceiling that selected nothing and did not start
|
|
5
|
+
// on a link is one the platform declined), and deciding at release is what lets
|
|
6
|
+
// the trailing-click swallow start at the release edge.
|
|
53
7
|
|
|
8
|
+
import { windowOf } from "../kernel/realm.js";
|
|
9
|
+
import { selectionTextWithin } from "../kernel/selection.js";
|
|
54
10
|
import type { TerminalFeature } from "../kernel/types.js";
|
|
55
11
|
import { TAP_MAX_MS, TAP_MOVEMENT_PX, isLinkTarget } from "../kernel/gesture.js";
|
|
56
12
|
import type { ClipboardApi } from "./clipboard.js";
|
|
57
13
|
import { createClickSwallow, placeMenuAt } from "./menu-position.js";
|
|
58
14
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
/** Options for the contextMenu feature.
|
|
64
|
-
*
|
|
65
|
-
* Pass the SAME clipboard feature value the composition includes, not a second
|
|
66
|
-
* `clipboard()` call: `ctx.use` resolves a value to the instance the kernel set
|
|
67
|
-
* up, so a value that is not in the feature list resolves to nothing and the
|
|
68
|
-
* menu silently loses Copy and Paste. `presetSingle` shows the shape. */
|
|
15
|
+
/** Options for the contextMenu feature. Pass the SAME clipboard feature value
|
|
16
|
+
* the composition includes, not a second `clipboard()` call: `ctx.use` resolves
|
|
17
|
+
* a value that is not in the feature list to nothing, and the menu silently
|
|
18
|
+
* loses Copy and Paste. */
|
|
69
19
|
export interface ContextMenuOptions {
|
|
70
|
-
/** The clipboard feature value
|
|
71
|
-
* API (ctx.use). Omitted: the menu shows only Select All. */
|
|
20
|
+
/** The clipboard feature value. Omitted: the menu shows only Select All. */
|
|
72
21
|
clipboard?: TerminalFeature<ClipboardApi>;
|
|
73
22
|
}
|
|
74
23
|
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
* contextmenu) could. Everywhere else, cancelling is how we stop the platform's
|
|
84
|
-
* menu appearing alongside ours. It no longer decides whether or when our menu
|
|
85
|
-
* opens, so a wrong answer here costs at most a duplicated menu on an unusual
|
|
86
|
-
* device, never a broken selection and never a lost paste. */
|
|
87
|
-
function isAppleTouchDevice(): boolean {
|
|
88
|
-
// Dead in every runtime that reaches this, and left in place deliberately.
|
|
89
|
-
// Measured on this repo's Node (v24.18.0, the version ts-ci pins): `typeof
|
|
90
|
-
// navigator` is "object", with userAgent "Node.js/24", platform
|
|
91
|
-
// "Linux x86_64" and maxTouchPoints undefined — so the fall-through already
|
|
92
|
-
// answers false there, by the same route a browser on a non-Apple device
|
|
93
|
-
// takes. A Web Worker has a WorkerNavigator, so it does not reach this
|
|
94
|
-
// either. What the guard still buys is totality: without it the bare
|
|
95
|
-
// `navigator` reads below would throw a ReferenceError in a runtime that has
|
|
96
|
-
// no navigator at all. It answers the same `false` the fall-through would, so
|
|
97
|
-
// nothing depends on reaching it, and no test can reach it without stubbing.
|
|
98
|
-
if (typeof navigator === "undefined") {
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
|
-
const ua = navigator.userAgent || "";
|
|
102
|
-
const platform = navigator.platform || "";
|
|
24
|
+
/** Whether a touch `contextmenu` may be cancelled. WebKit reads preventDefault
|
|
25
|
+
* there as "cancel every remaining default of this gesture", the platform's
|
|
26
|
+
* not-yet-registered word selection included, which once left an iPad unable to
|
|
27
|
+
* select text; everywhere else cancelling keeps the platform's menu from
|
|
28
|
+
* appearing beside ours. */
|
|
29
|
+
function isAppleTouchDevice(nav: Navigator): boolean {
|
|
30
|
+
const ua = nav.userAgent || "";
|
|
31
|
+
const platform = nav.platform || "";
|
|
103
32
|
if (/iP(hone|ad|od)/.test(ua) || /iP(hone|ad|od)/.test(platform)) {
|
|
104
33
|
return true;
|
|
105
34
|
}
|
|
106
|
-
// iPadOS
|
|
107
|
-
//
|
|
108
|
-
return platform === "MacIntel" &&
|
|
35
|
+
// iPadOS Safari's desktop mode reports MacIntel with a touch screen; a
|
|
36
|
+
// trackpad Mac reports maxTouchPoints 0.
|
|
37
|
+
return platform === "MacIntel" && nav.maxTouchPoints > 1;
|
|
109
38
|
}
|
|
110
39
|
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** True when an event target sits inside a hyperlink. A long-press on a link
|
|
118
|
-
* raises the platform's own link preview/menu without ever making a selection,
|
|
119
|
-
* so the selection test alone would not keep us out of its way. */
|
|
120
|
-
const onLink = isLinkTarget;
|
|
121
|
-
|
|
122
|
-
/** Build the contextMenu feature. Exposes no API — the menu is opened by the
|
|
123
|
-
* gestures described above, never programmatically by a peer.
|
|
124
|
-
*
|
|
125
|
-
* Hand it the clipboard value the composition also includes (see
|
|
126
|
-
* ContextMenuOptions) or it degrades to a Select All-only menu; its own position in
|
|
127
|
-
* the feature list is otherwise free, since nothing reads it through `ctx.use`. Its
|
|
128
|
-
* items act on the browser selection inside the terminal surface and on the
|
|
129
|
-
* kernel's paste funnel, so it holds no session state and survives a tab switch
|
|
130
|
-
* unchanged. Teardown removes the menu element and releases the keydown intercept
|
|
131
|
-
* along with every surface and document listener. */
|
|
40
|
+
/** Build the contextMenu feature. Exposes no API. Without the clipboard value of
|
|
41
|
+
* ContextMenuOptions it degrades to a Select All-only menu. Its items act on the
|
|
42
|
+
* browser selection inside the surface and on the kernel's paste funnel, so it
|
|
43
|
+
* holds no session state. */
|
|
132
44
|
export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
133
45
|
return {
|
|
134
46
|
name: "contextMenu",
|
|
135
47
|
setup(ctx) {
|
|
136
48
|
const surface = ctx.surface();
|
|
137
|
-
const
|
|
49
|
+
const doc = ctx.shell.root.ownerDocument;
|
|
50
|
+
const win = windowOf(doc);
|
|
51
|
+
const menu = doc.createElement("div");
|
|
138
52
|
menu.className = "wt-ctx-menu";
|
|
139
53
|
ctx.region("overlay", "menu").appendChild(menu);
|
|
54
|
+
// A long-press on a link raises the platform's own preview without ever
|
|
55
|
+
// making a selection, so the selection test alone would not keep us out of
|
|
56
|
+
// its way.
|
|
57
|
+
const onLink = (target: EventTarget | null): boolean => isLinkTarget(win, target);
|
|
140
58
|
|
|
141
59
|
// The device that started the current interaction: `contextmenu` fires on a
|
|
142
60
|
// desktop right-click AND on an Android long-press, so its handler branches
|
|
143
61
|
// on this to tell them apart.
|
|
144
62
|
let lastPointerType = "mouse";
|
|
145
63
|
// Computed once: may a touch contextmenu be cancelled (see isAppleTouchDevice).
|
|
146
|
-
const appleTouch = isAppleTouchDevice();
|
|
64
|
+
const appleTouch = isAppleTouchDevice(win.navigator);
|
|
147
65
|
// Swallows the trailing click a touch long-press emits on release.
|
|
148
|
-
const swallow = createClickSwallow();
|
|
66
|
+
const swallow = createClickSwallow(win);
|
|
149
67
|
|
|
150
68
|
// The in-flight single-finger press, all of it read at `touchend`.
|
|
151
69
|
// pressLive goes false the moment the gesture stops being a candidate (a
|
|
@@ -169,7 +87,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
169
87
|
}
|
|
170
88
|
|
|
171
89
|
function hide(refocus = true): void {
|
|
172
|
-
const shouldRefocus = refocus && menu.contains(
|
|
90
|
+
const shouldRefocus = refocus && menu.contains(doc.activeElement);
|
|
173
91
|
menu.classList.remove("visible");
|
|
174
92
|
menu.replaceChildren();
|
|
175
93
|
if (shouldRefocus) {
|
|
@@ -181,7 +99,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
181
99
|
// NOT refocus the textarea, or Firefox collapses the just-made selection
|
|
182
100
|
// when focus leaves the output.
|
|
183
101
|
function addButton(label: string, onClick: () => void, refocus = true): void {
|
|
184
|
-
const b =
|
|
102
|
+
const b = doc.createElement("button");
|
|
185
103
|
b.type = "button";
|
|
186
104
|
b.textContent = label;
|
|
187
105
|
b.addEventListener("click", () => {
|
|
@@ -196,7 +114,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
196
114
|
function show(x: number, y: number): void {
|
|
197
115
|
hide();
|
|
198
116
|
const clipboard = clip();
|
|
199
|
-
const sel =
|
|
117
|
+
const sel = selectionTextWithin(surface);
|
|
200
118
|
if (clipboard && sel) {
|
|
201
119
|
addButton("Copy", () => {
|
|
202
120
|
clipboard.copy(sel);
|
|
@@ -205,7 +123,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
205
123
|
addButton(
|
|
206
124
|
"Select All",
|
|
207
125
|
() => {
|
|
208
|
-
const s =
|
|
126
|
+
const s = doc.getSelection();
|
|
209
127
|
const output = surface.querySelector(".term-output");
|
|
210
128
|
if (s && output) {
|
|
211
129
|
s.selectAllChildren(output);
|
|
@@ -229,6 +147,9 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
229
147
|
lastPointerType = e.pointerType;
|
|
230
148
|
};
|
|
231
149
|
surface.addEventListener("pointerdown", onPointerDown, { passive: true });
|
|
150
|
+
ctx.defer(() => {
|
|
151
|
+
surface.removeEventListener("pointerdown", onPointerDown);
|
|
152
|
+
});
|
|
232
153
|
|
|
233
154
|
const onContextMenu = (e: MouseEvent): void => {
|
|
234
155
|
if (lastPointerType !== "touch") {
|
|
@@ -236,20 +157,21 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
236
157
|
show(e.clientX, e.clientY);
|
|
237
158
|
return;
|
|
238
159
|
}
|
|
239
|
-
// Touch:
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
// the platform's own to show, so its menu and ours cannot both appear.
|
|
245
|
-
if (!appleTouch && !onLink(e.target) && selectionText() === "") {
|
|
160
|
+
// Touch: the touchend classifier owns our menu; the question here is whether
|
|
161
|
+
// the PLATFORM's may proceed. WebKit must keep it (cancelling cancels the
|
|
162
|
+
// gesture's remaining defaults, the word selection included); elsewhere it is
|
|
163
|
+
// cancelled when the press has nothing of the platform's to show.
|
|
164
|
+
if (!appleTouch && !onLink(e.target) && selectionTextWithin(surface) === "") {
|
|
246
165
|
e.preventDefault();
|
|
247
166
|
}
|
|
248
167
|
};
|
|
249
168
|
surface.addEventListener("contextmenu", onContextMenu);
|
|
169
|
+
ctx.defer(() => {
|
|
170
|
+
surface.removeEventListener("contextmenu", onContextMenu);
|
|
171
|
+
});
|
|
250
172
|
|
|
251
173
|
// Escape closes the menu without also sending ESC to the PTY.
|
|
252
|
-
|
|
174
|
+
ctx.registerKeydown((ev) => {
|
|
253
175
|
if (ev.key === "Escape" && menu.classList.contains("visible")) {
|
|
254
176
|
ev.preventDefault();
|
|
255
177
|
hide();
|
|
@@ -263,7 +185,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
263
185
|
// menu with the correct refocus behaviour (Select All must not refocus
|
|
264
186
|
// the input, or Firefox collapses the selection it just made). Reaching
|
|
265
187
|
// hide() from here as well would override that choice.
|
|
266
|
-
if (e.target instanceof Node && menu.contains(e.target)) {
|
|
188
|
+
if (e.target instanceof win.Node && menu.contains(e.target)) {
|
|
267
189
|
return;
|
|
268
190
|
}
|
|
269
191
|
// The release click of the long-press that just opened the menu is that
|
|
@@ -273,7 +195,10 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
273
195
|
}
|
|
274
196
|
hide();
|
|
275
197
|
};
|
|
276
|
-
|
|
198
|
+
doc.addEventListener("click", onDocClick);
|
|
199
|
+
ctx.defer(() => {
|
|
200
|
+
doc.removeEventListener("click", onDocClick);
|
|
201
|
+
});
|
|
277
202
|
// A right-click outside the terminal surface (a tab, its menu, elsewhere,
|
|
278
203
|
// or a native browser menu) dismisses this menu. A right-click on the
|
|
279
204
|
// surface is handled by onContextMenu (which reopens it) and fires first.
|
|
@@ -282,7 +207,10 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
282
207
|
hide();
|
|
283
208
|
}
|
|
284
209
|
};
|
|
285
|
-
|
|
210
|
+
doc.addEventListener("contextmenu", onDocContextMenu);
|
|
211
|
+
ctx.defer(() => {
|
|
212
|
+
doc.removeEventListener("contextmenu", onDocContextMenu);
|
|
213
|
+
});
|
|
286
214
|
|
|
287
215
|
const onTouchStart = (e: TouchEvent): void => {
|
|
288
216
|
const t = e.touches.length === 1 ? e.touches[0] : undefined;
|
|
@@ -295,7 +223,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
295
223
|
pressX = t.clientX;
|
|
296
224
|
pressY = t.clientY;
|
|
297
225
|
pressOnLink = onLink(e.target);
|
|
298
|
-
pressSelection =
|
|
226
|
+
pressSelection = selectionTextWithin(surface);
|
|
299
227
|
};
|
|
300
228
|
const onTouchMove = (e: TouchEvent): void => {
|
|
301
229
|
if (!pressLive) {
|
|
@@ -325,7 +253,7 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
325
253
|
if (e.timeStamp - pressStart <= TAP_MAX_MS) {
|
|
326
254
|
return; // a tap: the kernel focuses the input / clears the selection
|
|
327
255
|
}
|
|
328
|
-
const sel =
|
|
256
|
+
const sel = selectionTextWithin(surface);
|
|
329
257
|
if (sel !== "" && sel !== pressSelection) {
|
|
330
258
|
return; // this press selected text; the OS callout owns it
|
|
331
259
|
}
|
|
@@ -343,18 +271,15 @@ export function contextMenu(opts: ContextMenuOptions = {}): TerminalFeature {
|
|
|
343
271
|
surface.addEventListener("touchmove", onTouchMove, { passive: true });
|
|
344
272
|
surface.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
345
273
|
surface.addEventListener("touchcancel", onTouchCancel, { passive: true });
|
|
274
|
+
ctx.defer(() => {
|
|
275
|
+
surface.removeEventListener("touchstart", onTouchStart);
|
|
276
|
+
surface.removeEventListener("touchmove", onTouchMove);
|
|
277
|
+
surface.removeEventListener("touchend", onTouchEnd);
|
|
278
|
+
surface.removeEventListener("touchcancel", onTouchCancel);
|
|
279
|
+
});
|
|
346
280
|
|
|
347
281
|
return {
|
|
348
282
|
teardown() {
|
|
349
|
-
offKey();
|
|
350
|
-
surface.removeEventListener("pointerdown", onPointerDown);
|
|
351
|
-
surface.removeEventListener("contextmenu", onContextMenu);
|
|
352
|
-
document.removeEventListener("click", onDocClick);
|
|
353
|
-
document.removeEventListener("contextmenu", onDocContextMenu);
|
|
354
|
-
surface.removeEventListener("touchstart", onTouchStart);
|
|
355
|
-
surface.removeEventListener("touchmove", onTouchMove);
|
|
356
|
-
surface.removeEventListener("touchend", onTouchEnd);
|
|
357
|
-
surface.removeEventListener("touchcancel", onTouchCancel);
|
|
358
283
|
menu.remove();
|
|
359
284
|
},
|
|
360
285
|
};
|