@particle-academy/fancy-term 0.2.1 → 0.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 +54 -0
- package/dist/index.cjs +288 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +146 -2
- package/dist/index.d.ts +146 -2
- package/dist/index.js +285 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,69 @@ import * as react from 'react';
|
|
|
2
2
|
import { HTMLAttributes, CSSProperties, RefObject } from 'react';
|
|
3
3
|
import { ITheme, Terminal as Terminal$1 } from '@xterm/xterm';
|
|
4
4
|
|
|
5
|
+
/** What a paste / drop carried: plain text plus any files (e.g. pasted images). */
|
|
6
|
+
interface ClipboardPayload {
|
|
7
|
+
/** Plain text from the clipboard (or pasted selection). May be "". */
|
|
8
|
+
text: string;
|
|
9
|
+
/** Files present on the clipboard / drop — typically pasted/dropped images. */
|
|
10
|
+
files: File[];
|
|
11
|
+
/** Convenience: the subset of `files` whose MIME type is `image/*`. */
|
|
12
|
+
images: File[];
|
|
13
|
+
}
|
|
14
|
+
/** True when a file looks like an image (by MIME type). */
|
|
15
|
+
declare function isImageFile(f: {
|
|
16
|
+
type?: string;
|
|
17
|
+
}): boolean;
|
|
18
|
+
|
|
19
|
+
/** Context handed to the menu at right-click time + to custom item builders. */
|
|
20
|
+
interface TerminalContextMenuContext {
|
|
21
|
+
/** Whether there is a non-empty text selection. */
|
|
22
|
+
hasSelection: boolean;
|
|
23
|
+
/** The selected text (or ""). */
|
|
24
|
+
selection: string;
|
|
25
|
+
/** Whether the terminal is read-only (paste / clear are dropped by default). */
|
|
26
|
+
readOnly: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** A single menu entry. `onSelect` runs on click; set `separator` for a divider. */
|
|
29
|
+
interface TerminalContextMenuItem {
|
|
30
|
+
/** Stable id (used as the React key + a `data-menu-item` handle). */
|
|
31
|
+
id: string;
|
|
32
|
+
/** Visible label. Ignored for separators. */
|
|
33
|
+
label?: string;
|
|
34
|
+
/** Optional leading glyph / emoji / short string (JSON-friendly — no ReactNode). */
|
|
35
|
+
icon?: string;
|
|
36
|
+
/** Greyed-out + non-interactive. */
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
/** Render a divider instead of a clickable item. */
|
|
39
|
+
separator?: boolean;
|
|
40
|
+
/** Invoked on click with the menu context. */
|
|
41
|
+
onSelect?: (ctx: TerminalContextMenuContext) => void;
|
|
42
|
+
}
|
|
43
|
+
/** The actions the built-in items dispatch to (wired by the component). */
|
|
44
|
+
interface TerminalMenuActions {
|
|
45
|
+
copy: () => void;
|
|
46
|
+
paste: () => void;
|
|
47
|
+
selectAll: () => void;
|
|
48
|
+
clear: () => void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The `contextMenu` prop shape:
|
|
52
|
+
* - `false` — disable the menu entirely.
|
|
53
|
+
* - `true` / omitted — the default Copy / Paste / Select all / Clear menu.
|
|
54
|
+
* - an array — replace the default items wholesale.
|
|
55
|
+
* - a function — receives the context **and the default items** so you can
|
|
56
|
+
* add/remove/reorder (e.g. append a "Send to agent" item).
|
|
57
|
+
*/
|
|
58
|
+
type TerminalContextMenuConfig = boolean | TerminalContextMenuItem[] | ((ctx: TerminalContextMenuContext, defaults: TerminalContextMenuItem[]) => TerminalContextMenuItem[]);
|
|
59
|
+
/** The built-in Copy / Paste / Select all / Clear items, contextual to selection + readOnly. */
|
|
60
|
+
declare function defaultMenuItems(ctx: TerminalContextMenuContext, actions: TerminalMenuActions): TerminalContextMenuItem[];
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the configured menu into the final item list, or `null` when disabled.
|
|
63
|
+
* Empty results (e.g. a function that filtered everything out) also resolve to
|
|
64
|
+
* `null` so the caller can skip opening an empty menu.
|
|
65
|
+
*/
|
|
66
|
+
declare function resolveMenuItems(config: TerminalContextMenuConfig | undefined, ctx: TerminalContextMenuContext, actions: TerminalMenuActions): TerminalContextMenuItem[] | null;
|
|
67
|
+
|
|
5
68
|
/** xterm.js color theme (re-exported for ergonomic typing). */
|
|
6
69
|
type TerminalTheme = ITheme;
|
|
7
70
|
type CursorStyle = "block" | "underline" | "bar";
|
|
@@ -49,6 +112,14 @@ interface TerminalHandle {
|
|
|
49
112
|
getBuffer: () => string;
|
|
50
113
|
/** The current text selection, or "" when nothing is selected. */
|
|
51
114
|
getSelection: () => string;
|
|
115
|
+
/** Copy the current selection to the system clipboard. Resolves false when there's nothing selected or the clipboard is unavailable. */
|
|
116
|
+
copySelection: () => Promise<boolean>;
|
|
117
|
+
/** Paste text into the terminal. With no argument, reads the system clipboard. Honors xterm's bracketed-paste mode. */
|
|
118
|
+
paste: (text?: string) => Promise<void>;
|
|
119
|
+
/** Select the entire buffer. */
|
|
120
|
+
selectAll: () => void;
|
|
121
|
+
/** Clear the current selection. */
|
|
122
|
+
clearSelection: () => void;
|
|
52
123
|
/**
|
|
53
124
|
* Switch the active shell by id. Resolves the matching {@link ShellProfile}
|
|
54
125
|
* from `shells` and fires `onShellChange`; in uncontrolled mode it also updates
|
|
@@ -88,8 +159,22 @@ interface TerminalOptions {
|
|
|
88
159
|
cols: number;
|
|
89
160
|
rows: number;
|
|
90
161
|
}) => void;
|
|
162
|
+
/**
|
|
163
|
+
* Enable clipboard wiring — the Ctrl+Shift+C / Cmd+C copy chord and the paste
|
|
164
|
+
* interceptor (which surfaces pasted images via {@link onPaste}). Default true.
|
|
165
|
+
* Text paste works regardless; this gates the *enhanced* clipboard behavior.
|
|
166
|
+
*/
|
|
167
|
+
clipboard?: boolean;
|
|
168
|
+
/**
|
|
169
|
+
* Fired on every paste with the clipboard payload — `{ text, files, images }`.
|
|
170
|
+
* Plain text still pastes into the terminal natively; this is where a host
|
|
171
|
+
* receives pasted **images** (a shell can't render them) to upload / hand to an
|
|
172
|
+
* agent / write a path. Return `false` to fully consume the paste (suppress the
|
|
173
|
+
* default text paste — e.g. to transform it, then call `handle.paste(...)`).
|
|
174
|
+
*/
|
|
175
|
+
onPaste?: (payload: ClipboardPayload) => void | boolean;
|
|
91
176
|
}
|
|
92
|
-
interface TerminalProps extends TerminalOptions, Omit<HTMLAttributes<HTMLDivElement>, "onInput" | "onResize" | "children"> {
|
|
177
|
+
interface TerminalProps extends TerminalOptions, Omit<HTMLAttributes<HTMLDivElement>, "onInput" | "onResize" | "onPaste" | "contextMenu" | "children"> {
|
|
93
178
|
/**
|
|
94
179
|
* The controlled output buffer. The component diffs against the previous value
|
|
95
180
|
* and writes only the appended delta, so a host can drive the terminal from
|
|
@@ -112,6 +197,13 @@ interface TerminalProps extends TerminalOptions, Omit<HTMLAttributes<HTMLDivElem
|
|
|
112
197
|
* `shells` is provided. Default `false` — opt-in so existing layout is unchanged.
|
|
113
198
|
*/
|
|
114
199
|
showShellBar?: boolean;
|
|
200
|
+
/**
|
|
201
|
+
* The right-click selection context menu. `false` disables it; `true` / omitted
|
|
202
|
+
* gives the default Copy / Paste / Select all / Clear menu; an array replaces
|
|
203
|
+
* the items; a `(ctx, defaults) => items` function lets you add/remove/reorder
|
|
204
|
+
* (e.g. append a "Send to agent" item). See {@link TerminalContextMenuConfig}.
|
|
205
|
+
*/
|
|
206
|
+
contextMenu?: TerminalContextMenuConfig;
|
|
115
207
|
}
|
|
116
208
|
/**
|
|
117
209
|
* Resolve a shell id to its {@link ShellProfile} within a list. Pure helper —
|
|
@@ -169,6 +261,22 @@ interface ShellSwitcherProps {
|
|
|
169
261
|
*/
|
|
170
262
|
declare function ShellSwitcher({ shells, value, onChange, className, style, disabled, }: ShellSwitcherProps): react.JSX.Element;
|
|
171
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The terminal selection context menu. Deliberately plain React + inline styles
|
|
266
|
+
* (fancy-term has no react-fancy dependency) styled to match the shell bar. It
|
|
267
|
+
* closes on click-outside + Escape, and clamps itself inside the viewport on
|
|
268
|
+
* mount so a right-click near an edge never spills off-screen.
|
|
269
|
+
*/
|
|
270
|
+
declare function TerminalContextMenu({ at, items, ctx, onClose, }: {
|
|
271
|
+
at: {
|
|
272
|
+
x: number;
|
|
273
|
+
y: number;
|
|
274
|
+
};
|
|
275
|
+
items: TerminalContextMenuItem[];
|
|
276
|
+
ctx: TerminalContextMenuContext;
|
|
277
|
+
onClose: () => void;
|
|
278
|
+
}): react.JSX.Element;
|
|
279
|
+
|
|
172
280
|
/**
|
|
173
281
|
* Create + own an xterm.js instance for a container, returning a stable
|
|
174
282
|
* {@link TerminalHandle}. The headless engine layer behind `<Terminal>` — use it
|
|
@@ -257,4 +365,40 @@ declare function useTerminalSession(options: UseTerminalSessionOptions): Termina
|
|
|
257
365
|
*/
|
|
258
366
|
declare const fancyDarkTheme: TerminalTheme;
|
|
259
367
|
|
|
260
|
-
|
|
368
|
+
/** The subset of a KeyboardEvent the copy decision reads. */
|
|
369
|
+
interface CopyKeyEvent {
|
|
370
|
+
type: string;
|
|
371
|
+
key: string;
|
|
372
|
+
ctrlKey: boolean;
|
|
373
|
+
shiftKey: boolean;
|
|
374
|
+
metaKey: boolean;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Whether a key event should copy the current selection to the clipboard.
|
|
378
|
+
*
|
|
379
|
+
* - **Ctrl+Shift+C** (Windows / Linux terminal convention) — always.
|
|
380
|
+
* - **Cmd / Meta+C** (macOS) — only when there is a selection.
|
|
381
|
+
* - **Plain Ctrl+C is never a copy** — it must stay SIGINT.
|
|
382
|
+
*
|
|
383
|
+
* Only fires on `keydown`. When this returns true the caller copies the
|
|
384
|
+
* selection and consumes the event (returns `false` from xterm's
|
|
385
|
+
* `attachCustomKeyEventHandler` so no control byte is sent).
|
|
386
|
+
*/
|
|
387
|
+
declare function shouldCopyEvent(e: CopyKeyEvent, hasSelection: boolean): boolean;
|
|
388
|
+
|
|
389
|
+
interface MenuPoint {
|
|
390
|
+
x: number;
|
|
391
|
+
y: number;
|
|
392
|
+
}
|
|
393
|
+
interface MenuSize {
|
|
394
|
+
width: number;
|
|
395
|
+
height: number;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Clamp a menu's top-left so the whole menu stays inside the viewport (minus a
|
|
399
|
+
* margin). Pushes left/up when it would overflow the right/bottom edge, and never
|
|
400
|
+
* goes past the top-left margin. Pure.
|
|
401
|
+
*/
|
|
402
|
+
declare function clampMenuPosition(at: MenuPoint, menu: MenuSize, viewport: MenuSize, margin?: number): MenuPoint;
|
|
403
|
+
|
|
404
|
+
export { BUILTIN_SHELLS, type ClipboardPayload, type CopyKeyEvent, type CursorStyle, type ShellProfile, ShellSwitcher, type ShellSwitcherProps, Terminal, TerminalContextMenu, type TerminalContextMenuConfig, type TerminalContextMenuContext, type TerminalContextMenuItem, type TerminalHandle, type TerminalMenuActions, type TerminalOptions, type TerminalProps, type TerminalSessionApi, type TerminalSessionTransport, type TerminalTheme, type UseTerminalSessionOptions, clampMenuPosition, defaultMenuItems, fancyDarkTheme, isImageFile, resolveMenuItems, resolveShell, shouldCopyEvent, useTerminal, useTerminalFit, useTerminalSession };
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { forwardRef, useRef, useState, useCallback, useImperativeHandle, useEffect, useId } from 'react';
|
|
1
|
+
import { forwardRef, useRef, useState, useCallback, useImperativeHandle, useEffect, useLayoutEffect, useId } from 'react';
|
|
2
2
|
import { Terminal as Terminal$1 } from '@xterm/xterm';
|
|
3
3
|
import { FitAddon } from '@xterm/addon-fit';
|
|
4
|
-
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
4
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
5
5
|
|
|
6
6
|
// src/components/Terminal.tsx
|
|
7
7
|
|
|
@@ -50,6 +50,77 @@ function useTerminalFit(ref, fit, enabled = true) {
|
|
|
50
50
|
}, [ref, enabled]);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// src/clipboard.ts
|
|
54
|
+
function isImageFile(f) {
|
|
55
|
+
return typeof f.type === "string" && f.type.startsWith("image/");
|
|
56
|
+
}
|
|
57
|
+
function readDataTransfer(dt) {
|
|
58
|
+
const files = [];
|
|
59
|
+
let text = "";
|
|
60
|
+
if (dt) {
|
|
61
|
+
try {
|
|
62
|
+
text = dt.getData("text/plain") ?? "";
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
const items = dt.items;
|
|
66
|
+
if (items) {
|
|
67
|
+
for (let i = 0; i < items.length; i++) {
|
|
68
|
+
const it = items[i];
|
|
69
|
+
if (it && it.kind === "file") {
|
|
70
|
+
const f = it.getAsFile();
|
|
71
|
+
if (f) files.push(f);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (files.length === 0 && dt.files) {
|
|
76
|
+
for (let i = 0; i < dt.files.length; i++) files.push(dt.files[i]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { text, files, images: files.filter(isImageFile) };
|
|
80
|
+
}
|
|
81
|
+
async function writeClipboardText(text) {
|
|
82
|
+
try {
|
|
83
|
+
const nav = typeof navigator !== "undefined" ? navigator : void 0;
|
|
84
|
+
if (nav?.clipboard?.writeText) {
|
|
85
|
+
await nav.clipboard.writeText(text);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
if (typeof document === "undefined") return false;
|
|
92
|
+
const ta = document.createElement("textarea");
|
|
93
|
+
ta.value = text;
|
|
94
|
+
ta.style.position = "fixed";
|
|
95
|
+
ta.style.left = "-9999px";
|
|
96
|
+
ta.style.opacity = "0";
|
|
97
|
+
document.body.appendChild(ta);
|
|
98
|
+
ta.select();
|
|
99
|
+
const ok = document.execCommand("copy");
|
|
100
|
+
document.body.removeChild(ta);
|
|
101
|
+
return ok;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function readClipboardText() {
|
|
107
|
+
try {
|
|
108
|
+
const nav = typeof navigator !== "undefined" ? navigator : void 0;
|
|
109
|
+
if (nav?.clipboard?.readText) return await nav.clipboard.readText();
|
|
110
|
+
} catch {
|
|
111
|
+
}
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/copy-keybinding.ts
|
|
116
|
+
function shouldCopyEvent(e, hasSelection) {
|
|
117
|
+
if (e.type !== "keydown") return false;
|
|
118
|
+
if (e.key !== "c" && e.key !== "C") return false;
|
|
119
|
+
if (e.ctrlKey && e.shiftKey && !e.metaKey) return true;
|
|
120
|
+
if (e.metaKey && !e.ctrlKey && hasSelection) return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
53
124
|
// src/hooks/use-terminal.ts
|
|
54
125
|
var DEFAULT_FONT = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
|
|
55
126
|
function readBuffer(term) {
|
|
@@ -92,6 +163,17 @@ function useTerminal(containerRef, options = {}) {
|
|
|
92
163
|
focus: () => xtermRef.current?.focus(),
|
|
93
164
|
getBuffer: () => readBuffer(xtermRef.current),
|
|
94
165
|
getSelection: () => xtermRef.current?.getSelection() ?? "",
|
|
166
|
+
copySelection: async () => {
|
|
167
|
+
const sel = xtermRef.current?.getSelection() ?? "";
|
|
168
|
+
if (!sel) return false;
|
|
169
|
+
return writeClipboardText(sel);
|
|
170
|
+
},
|
|
171
|
+
paste: async (text) => {
|
|
172
|
+
const data = text ?? await readClipboardText();
|
|
173
|
+
if (data) xtermRef.current?.paste(data);
|
|
174
|
+
},
|
|
175
|
+
selectAll: () => xtermRef.current?.selectAll(),
|
|
176
|
+
clearSelection: () => xtermRef.current?.clearSelection(),
|
|
95
177
|
// Shell selection is owned by the <Terminal> component layer (it needs the
|
|
96
178
|
// `shells` list + onShellChange). The headless engine is shell-agnostic, so
|
|
97
179
|
// these are no-ops here and get overridden when <Terminal> composes them.
|
|
@@ -107,8 +189,11 @@ function useTerminal(containerRef, options = {}) {
|
|
|
107
189
|
const o = optsRef.current;
|
|
108
190
|
const term = new Terminal$1({
|
|
109
191
|
theme: o.theme ?? fancyDarkTheme,
|
|
110
|
-
rows
|
|
111
|
-
cols
|
|
192
|
+
// Only pass rows/cols when explicitly set. xterm validates these and
|
|
193
|
+
// console-errors "rows/cols must be numeric" if handed `undefined`, rather
|
|
194
|
+
// than falling back to its 80×24 default. Omit the keys instead.
|
|
195
|
+
...typeof o.rows === "number" ? { rows: o.rows } : {},
|
|
196
|
+
...typeof o.cols === "number" ? { cols: o.cols } : {},
|
|
112
197
|
disableStdin: o.readOnly ?? false,
|
|
113
198
|
cursorBlink: o.cursorBlink ?? true,
|
|
114
199
|
cursorStyle: o.cursorStyle ?? "block",
|
|
@@ -126,9 +211,29 @@ function useTerminal(containerRef, options = {}) {
|
|
|
126
211
|
const resizeSub = term.onResize(
|
|
127
212
|
({ cols, rows }) => optsRef.current.onResize?.({ cols, rows })
|
|
128
213
|
);
|
|
214
|
+
term.attachCustomKeyEventHandler((e) => {
|
|
215
|
+
if (optsRef.current.clipboard === false) return true;
|
|
216
|
+
if (shouldCopyEvent(e, term.hasSelection())) {
|
|
217
|
+
void writeClipboardText(term.getSelection());
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
});
|
|
222
|
+
const onPasteEvent = (e) => {
|
|
223
|
+
const o2 = optsRef.current;
|
|
224
|
+
if (o2.clipboard === false) return;
|
|
225
|
+
if (o2.readOnly) {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const payload = readDataTransfer(e.clipboardData);
|
|
230
|
+
if (o2.onPaste?.(payload) === false) e.preventDefault();
|
|
231
|
+
};
|
|
232
|
+
el.addEventListener("paste", onPasteEvent, true);
|
|
129
233
|
if (o.initialOutput) term.write(o.initialOutput);
|
|
130
234
|
if (o.fit ?? true) handle.fit();
|
|
131
235
|
return () => {
|
|
236
|
+
el.removeEventListener("paste", onPasteEvent, true);
|
|
132
237
|
dataSub.dispose();
|
|
133
238
|
resizeSub.dispose();
|
|
134
239
|
term.dispose();
|
|
@@ -359,6 +464,122 @@ function ShellSwitcher({
|
|
|
359
464
|
);
|
|
360
465
|
}
|
|
361
466
|
|
|
467
|
+
// src/menu-position.ts
|
|
468
|
+
function clampMenuPosition(at, menu, viewport, margin = 8) {
|
|
469
|
+
const maxX = Math.max(margin, viewport.width - menu.width - margin);
|
|
470
|
+
const maxY = Math.max(margin, viewport.height - menu.height - margin);
|
|
471
|
+
return {
|
|
472
|
+
x: Math.min(Math.max(margin, at.x), maxX),
|
|
473
|
+
y: Math.min(Math.max(margin, at.y), maxY)
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function TerminalContextMenu({
|
|
477
|
+
at,
|
|
478
|
+
items,
|
|
479
|
+
ctx,
|
|
480
|
+
onClose
|
|
481
|
+
}) {
|
|
482
|
+
const ref = useRef(null);
|
|
483
|
+
const [pos, setPos] = useState(at);
|
|
484
|
+
useLayoutEffect(() => {
|
|
485
|
+
const el = ref.current;
|
|
486
|
+
if (!el || typeof window === "undefined") return;
|
|
487
|
+
const r = el.getBoundingClientRect();
|
|
488
|
+
setPos(
|
|
489
|
+
clampMenuPosition(
|
|
490
|
+
at,
|
|
491
|
+
{ width: r.width, height: r.height },
|
|
492
|
+
{ width: window.innerWidth, height: window.innerHeight }
|
|
493
|
+
)
|
|
494
|
+
);
|
|
495
|
+
}, [at]);
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
const onDown = (e) => {
|
|
498
|
+
if (ref.current && !ref.current.contains(e.target)) onClose();
|
|
499
|
+
};
|
|
500
|
+
const onKey = (e) => {
|
|
501
|
+
if (e.key === "Escape") onClose();
|
|
502
|
+
};
|
|
503
|
+
document.addEventListener("mousedown", onDown, true);
|
|
504
|
+
document.addEventListener("keydown", onKey, true);
|
|
505
|
+
return () => {
|
|
506
|
+
document.removeEventListener("mousedown", onDown, true);
|
|
507
|
+
document.removeEventListener("keydown", onKey, true);
|
|
508
|
+
};
|
|
509
|
+
}, [onClose]);
|
|
510
|
+
return /* @__PURE__ */ jsx(
|
|
511
|
+
"div",
|
|
512
|
+
{
|
|
513
|
+
ref,
|
|
514
|
+
"data-fancy-terminal-menu": "",
|
|
515
|
+
role: "menu",
|
|
516
|
+
style: {
|
|
517
|
+
position: "fixed",
|
|
518
|
+
left: pos.x,
|
|
519
|
+
top: pos.y,
|
|
520
|
+
zIndex: 2147483e3,
|
|
521
|
+
minWidth: 176,
|
|
522
|
+
padding: 4,
|
|
523
|
+
background: "#18181b",
|
|
524
|
+
border: "1px solid #3f3f46",
|
|
525
|
+
borderRadius: 8,
|
|
526
|
+
boxShadow: "0 10px 32px -8px rgba(0,0,0,.6)",
|
|
527
|
+
color: "#e4e4e7",
|
|
528
|
+
font: '13px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
529
|
+
userSelect: "none"
|
|
530
|
+
},
|
|
531
|
+
children: items.map(
|
|
532
|
+
(it) => it.separator ? /* @__PURE__ */ jsx(
|
|
533
|
+
"div",
|
|
534
|
+
{
|
|
535
|
+
role: "separator",
|
|
536
|
+
style: { height: 1, margin: "4px 6px", background: "#3f3f46" }
|
|
537
|
+
},
|
|
538
|
+
it.id
|
|
539
|
+
) : /* @__PURE__ */ jsxs(
|
|
540
|
+
"button",
|
|
541
|
+
{
|
|
542
|
+
type: "button",
|
|
543
|
+
role: "menuitem",
|
|
544
|
+
"data-menu-item": it.id,
|
|
545
|
+
disabled: it.disabled,
|
|
546
|
+
onClick: () => {
|
|
547
|
+
if (it.disabled) return;
|
|
548
|
+
it.onSelect?.(ctx);
|
|
549
|
+
onClose();
|
|
550
|
+
},
|
|
551
|
+
onMouseEnter: (e) => {
|
|
552
|
+
if (!it.disabled) e.currentTarget.style.background = "#27272a";
|
|
553
|
+
},
|
|
554
|
+
onMouseLeave: (e) => {
|
|
555
|
+
e.currentTarget.style.background = "transparent";
|
|
556
|
+
},
|
|
557
|
+
style: {
|
|
558
|
+
display: "flex",
|
|
559
|
+
alignItems: "center",
|
|
560
|
+
gap: 9,
|
|
561
|
+
width: "100%",
|
|
562
|
+
textAlign: "left",
|
|
563
|
+
padding: "6px 9px",
|
|
564
|
+
border: "none",
|
|
565
|
+
borderRadius: 5,
|
|
566
|
+
background: "transparent",
|
|
567
|
+
color: it.disabled ? "#52525b" : "inherit",
|
|
568
|
+
cursor: it.disabled ? "default" : "pointer",
|
|
569
|
+
font: "inherit"
|
|
570
|
+
},
|
|
571
|
+
children: [
|
|
572
|
+
/* @__PURE__ */ jsx("span", { style: { width: 16, textAlign: "center", opacity: 0.8 }, children: it.icon ?? "" }),
|
|
573
|
+
/* @__PURE__ */ jsx("span", { style: { flex: 1 }, children: it.label })
|
|
574
|
+
]
|
|
575
|
+
},
|
|
576
|
+
it.id
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
362
583
|
// src/output-diff.ts
|
|
363
584
|
function diffOutput(written, next) {
|
|
364
585
|
if (next === written) return null;
|
|
@@ -375,6 +596,28 @@ function decideShellSelect(shells, current, id) {
|
|
|
375
596
|
function decideShellSwitch(current, next) {
|
|
376
597
|
return { changed: current !== next };
|
|
377
598
|
}
|
|
599
|
+
|
|
600
|
+
// src/context-menu.ts
|
|
601
|
+
function defaultMenuItems(ctx, actions) {
|
|
602
|
+
const items = [
|
|
603
|
+
{ id: "copy", label: "Copy", icon: "\u29C9", disabled: !ctx.hasSelection, onSelect: actions.copy }
|
|
604
|
+
];
|
|
605
|
+
if (!ctx.readOnly) {
|
|
606
|
+
items.push({ id: "paste", label: "Paste", icon: "\u2398", onSelect: actions.paste });
|
|
607
|
+
}
|
|
608
|
+
items.push({ id: "select-all", label: "Select all", onSelect: actions.selectAll });
|
|
609
|
+
if (!ctx.readOnly) {
|
|
610
|
+
items.push({ id: "sep-default", separator: true });
|
|
611
|
+
items.push({ id: "clear", label: "Clear", onSelect: actions.clear });
|
|
612
|
+
}
|
|
613
|
+
return items;
|
|
614
|
+
}
|
|
615
|
+
function resolveMenuItems(config, ctx, actions) {
|
|
616
|
+
if (config === false) return null;
|
|
617
|
+
const defaults = defaultMenuItems(ctx, actions);
|
|
618
|
+
const items = config === void 0 || config === true ? defaults : Array.isArray(config) ? config : config(ctx, defaults);
|
|
619
|
+
return items.length > 0 ? items : null;
|
|
620
|
+
}
|
|
378
621
|
var Terminal = forwardRef(function Terminal2({
|
|
379
622
|
output,
|
|
380
623
|
theme,
|
|
@@ -394,6 +637,9 @@ var Terminal = forwardRef(function Terminal2({
|
|
|
394
637
|
activeShell,
|
|
395
638
|
onShellChange,
|
|
396
639
|
showShellBar = false,
|
|
640
|
+
clipboard,
|
|
641
|
+
onPaste,
|
|
642
|
+
contextMenu,
|
|
397
643
|
className,
|
|
398
644
|
style,
|
|
399
645
|
...rest
|
|
@@ -426,7 +672,9 @@ var Terminal = forwardRef(function Terminal2({
|
|
|
426
672
|
// diffing effect below owns the buffer, so don't double-write.
|
|
427
673
|
initialOutput: output === void 0 ? initialOutput : void 0,
|
|
428
674
|
onData,
|
|
429
|
-
onResize
|
|
675
|
+
onResize,
|
|
676
|
+
clipboard,
|
|
677
|
+
onPaste
|
|
430
678
|
});
|
|
431
679
|
useImperativeHandle(
|
|
432
680
|
ref,
|
|
@@ -446,18 +694,46 @@ var Terminal = forwardRef(function Terminal2({
|
|
|
446
694
|
handle.write(change.write);
|
|
447
695
|
written.current = output;
|
|
448
696
|
}, [output, handle]);
|
|
697
|
+
const [menu, setMenu] = useState(null);
|
|
698
|
+
const openMenu = useCallback(
|
|
699
|
+
(e) => {
|
|
700
|
+
if (contextMenu === false) return;
|
|
701
|
+
e.preventDefault();
|
|
702
|
+
const selection = handle.getSelection();
|
|
703
|
+
const ctx = {
|
|
704
|
+
hasSelection: selection.length > 0,
|
|
705
|
+
selection,
|
|
706
|
+
readOnly: !!readOnly
|
|
707
|
+
};
|
|
708
|
+
const items = resolveMenuItems(contextMenu, ctx, {
|
|
709
|
+
copy: () => void handle.copySelection(),
|
|
710
|
+
paste: () => void handle.paste(),
|
|
711
|
+
selectAll: () => handle.selectAll(),
|
|
712
|
+
clear: () => handle.clear()
|
|
713
|
+
});
|
|
714
|
+
if (items) setMenu({ at: { x: e.clientX, y: e.clientY }, items, ctx });
|
|
715
|
+
},
|
|
716
|
+
[contextMenu, handle, readOnly]
|
|
717
|
+
);
|
|
718
|
+
const menuNode = menu ? /* @__PURE__ */ jsx(TerminalContextMenu, { at: menu.at, items: menu.items, ctx: menu.ctx, onClose: () => setMenu(null) }) : null;
|
|
449
719
|
const surface = /* @__PURE__ */ jsx(
|
|
450
720
|
"div",
|
|
451
721
|
{
|
|
452
722
|
ref: containerRef,
|
|
453
723
|
"data-fancy-terminal": "",
|
|
724
|
+
onContextMenu: openMenu,
|
|
454
725
|
"data-readonly": readOnly ? "" : void 0,
|
|
455
726
|
"data-fancy-terminal-shell": shellId ?? void 0,
|
|
456
727
|
style: showShellBar && shells ? { width: "100%", flex: 1, minHeight: 0 } : { width: "100%", height: "100%", ...style },
|
|
457
728
|
...showShellBar && shells ? {} : rest
|
|
458
729
|
}
|
|
459
730
|
);
|
|
460
|
-
if (!showShellBar || !shells)
|
|
731
|
+
if (!showShellBar || !shells) {
|
|
732
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
733
|
+
surface,
|
|
734
|
+
menuNode
|
|
735
|
+
] });
|
|
736
|
+
}
|
|
461
737
|
return /* @__PURE__ */ jsxs(
|
|
462
738
|
"div",
|
|
463
739
|
{
|
|
@@ -488,7 +764,8 @@ var Terminal = forwardRef(function Terminal2({
|
|
|
488
764
|
)
|
|
489
765
|
}
|
|
490
766
|
),
|
|
491
|
-
surface
|
|
767
|
+
surface,
|
|
768
|
+
menuNode
|
|
492
769
|
]
|
|
493
770
|
}
|
|
494
771
|
);
|
|
@@ -537,6 +814,6 @@ function useTerminalSession(options) {
|
|
|
537
814
|
return { output, sendData, append, clear, shell, switchShell };
|
|
538
815
|
}
|
|
539
816
|
|
|
540
|
-
export { BUILTIN_SHELLS, ShellSwitcher, Terminal, fancyDarkTheme, resolveShell, useTerminal, useTerminalFit, useTerminalSession };
|
|
817
|
+
export { BUILTIN_SHELLS, ShellSwitcher, Terminal, TerminalContextMenu, clampMenuPosition, defaultMenuItems, fancyDarkTheme, isImageFile, resolveMenuItems, resolveShell, shouldCopyEvent, useTerminal, useTerminalFit, useTerminalSession };
|
|
541
818
|
//# sourceMappingURL=index.js.map
|
|
542
819
|
//# sourceMappingURL=index.js.map
|