@particle-academy/fancy-term 0.2.2 → 0.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/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,8 +50,181 @@ 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
+ var navigatorClipboard = {
115
+ writeText: (text) => writeClipboardText(text),
116
+ readText: () => readClipboardText()
117
+ };
118
+ function resolveClipboard(clipboard) {
119
+ if (clipboard === false) return { enabled: false, provider: navigatorClipboard };
120
+ if (clipboard && typeof clipboard === "object") return { enabled: true, provider: clipboard };
121
+ return { enabled: true, provider: navigatorClipboard };
122
+ }
123
+ async function providerWrite(provider, text) {
124
+ try {
125
+ await provider.writeText(text);
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+ async function providerRead(provider) {
132
+ try {
133
+ return await provider.readText() ?? "";
134
+ } catch {
135
+ return "";
136
+ }
137
+ }
138
+
139
+ // src/copy-paste-mode.ts
140
+ function copyPasteBehavior(mode) {
141
+ switch (mode) {
142
+ case "linux":
143
+ return { selectToCopy: true, middleClickPaste: true, keyCopy: false, keyPaste: false };
144
+ case "winmac":
145
+ return { selectToCopy: false, middleClickPaste: false, keyCopy: true, keyPaste: true };
146
+ case "contextmenu":
147
+ default:
148
+ return { selectToCopy: false, middleClickPaste: false, keyCopy: false, keyPaste: false };
149
+ }
150
+ }
151
+ function resolveKeyAction(e, hasSelection, behavior) {
152
+ if (e.type !== "keydown") return null;
153
+ const key = e.key.toLowerCase();
154
+ if (key === "c") {
155
+ if (e.ctrlKey && e.shiftKey && !e.metaKey) return "copy";
156
+ if (e.metaKey && !e.ctrlKey && hasSelection) return "copy";
157
+ if (behavior.keyCopy && (e.ctrlKey || e.metaKey) && !e.shiftKey && hasSelection) return "copy";
158
+ return null;
159
+ }
160
+ if (key === "v" && behavior.keyPaste) {
161
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey) return "paste";
162
+ if (e.ctrlKey && e.shiftKey) return "paste";
163
+ return null;
164
+ }
165
+ return null;
166
+ }
167
+
168
+ // src/osc52.ts
169
+ function encodeBase64(text) {
170
+ const bytes = new TextEncoder().encode(text);
171
+ let binary = "";
172
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
173
+ return typeof btoa === "function" ? btoa(binary) : binary;
174
+ }
175
+ function decodeBase64(b64) {
176
+ try {
177
+ const binary = typeof atob === "function" ? atob(b64) : b64;
178
+ const bytes = new Uint8Array(binary.length);
179
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
180
+ return new TextDecoder().decode(bytes);
181
+ } catch {
182
+ return "";
183
+ }
184
+ }
185
+ function parseOsc52(payload) {
186
+ const semi = payload.indexOf(";");
187
+ if (semi < 0) return null;
188
+ const selection = payload.slice(0, semi);
189
+ const pd = payload.slice(semi + 1);
190
+ if (pd === "?") return { selection, kind: "read", data: "" };
191
+ return { selection, kind: "write", data: decodeBase64(pd) };
192
+ }
193
+ function osc52Response(selection, text) {
194
+ return `\x1B]52;${selection};${encodeBase64(text)}\x07`;
195
+ }
196
+ function registerOsc52(term, provider, mode) {
197
+ if (mode() === false) return null;
198
+ return term.parser.registerOscHandler(52, (payload) => {
199
+ const m = mode();
200
+ if (m === false) return true;
201
+ const req = parseOsc52(payload);
202
+ if (!req) return false;
203
+ if (req.kind === "write") {
204
+ if (m === "copy" || m === "both") {
205
+ void Promise.resolve(provider().writeText(req.data)).catch(() => {
206
+ });
207
+ }
208
+ return true;
209
+ }
210
+ if (m === "read" || m === "both") {
211
+ void providerRead(provider()).then((text) => {
212
+ term.write(osc52Response(req.selection, text));
213
+ });
214
+ }
215
+ return true;
216
+ });
217
+ }
218
+
53
219
  // src/hooks/use-terminal.ts
54
220
  var DEFAULT_FONT = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
221
+ function makeDeferred() {
222
+ let resolve;
223
+ const promise = new Promise((r) => {
224
+ resolve = r;
225
+ });
226
+ return { promise, resolve };
227
+ }
55
228
  function readBuffer(term) {
56
229
  if (!term) return "";
57
230
  const buf = term.buffer.active;
@@ -67,12 +240,16 @@ function useTerminal(containerRef, options = {}) {
67
240
  const fitRef = useRef(null);
68
241
  const optsRef = useRef(options);
69
242
  optsRef.current = options;
243
+ const readyRef = useRef(makeDeferred());
70
244
  const handleRef = useRef(null);
71
245
  if (handleRef.current === null) {
72
246
  handleRef.current = {
73
247
  get xterm() {
74
248
  return xtermRef.current;
75
249
  },
250
+ get ready() {
251
+ return readyRef.current.promise;
252
+ },
76
253
  write: (d) => xtermRef.current?.write(d),
77
254
  writeln: (d) => xtermRef.current?.writeln(d),
78
255
  clear: () => xtermRef.current?.clear(),
@@ -92,6 +269,17 @@ function useTerminal(containerRef, options = {}) {
92
269
  focus: () => xtermRef.current?.focus(),
93
270
  getBuffer: () => readBuffer(xtermRef.current),
94
271
  getSelection: () => xtermRef.current?.getSelection() ?? "",
272
+ copySelection: async () => {
273
+ const sel = xtermRef.current?.getSelection() ?? "";
274
+ if (!sel) return false;
275
+ return providerWrite(resolveClipboard(optsRef.current.clipboard).provider, sel);
276
+ },
277
+ paste: async (text) => {
278
+ const data = text ?? await providerRead(resolveClipboard(optsRef.current.clipboard).provider);
279
+ if (data) xtermRef.current?.paste(data);
280
+ },
281
+ selectAll: () => xtermRef.current?.selectAll(),
282
+ clearSelection: () => xtermRef.current?.clearSelection(),
95
283
  // Shell selection is owned by the <Terminal> component layer (it needs the
96
284
  // `shells` list + onShellChange). The headless engine is shell-agnostic, so
97
285
  // these are no-ops here and get overridden when <Terminal> composes them.
@@ -129,14 +317,77 @@ function useTerminal(containerRef, options = {}) {
129
317
  const resizeSub = term.onResize(
130
318
  ({ cols, rows }) => optsRef.current.onResize?.({ cols, rows })
131
319
  );
320
+ const pasteFromClipboard = async () => {
321
+ const oo = optsRef.current;
322
+ if (oo.readOnly) return;
323
+ const text = await providerRead(resolveClipboard(oo.clipboard).provider);
324
+ if (text) xtermRef.current?.paste(text);
325
+ };
326
+ term.attachCustomKeyEventHandler((e) => {
327
+ const oo = optsRef.current;
328
+ const clip = resolveClipboard(oo.clipboard);
329
+ if (!clip.enabled) return true;
330
+ const action = resolveKeyAction(e, term.hasSelection(), copyPasteBehavior(oo.copyPaste));
331
+ if (action === "copy") {
332
+ void providerWrite(clip.provider, term.getSelection());
333
+ return false;
334
+ }
335
+ if (action === "paste") {
336
+ void pasteFromClipboard();
337
+ return false;
338
+ }
339
+ return true;
340
+ });
341
+ const oscDisposable = registerOsc52(
342
+ term,
343
+ () => resolveClipboard(optsRef.current.clipboard).provider,
344
+ () => {
345
+ const oo = optsRef.current;
346
+ return resolveClipboard(oo.clipboard).enabled ? oo.osc52 ?? "copy" : false;
347
+ }
348
+ );
349
+ const selectionSub = term.onSelectionChange(() => {
350
+ const oo = optsRef.current;
351
+ const clip = resolveClipboard(oo.clipboard);
352
+ if (clip.enabled && copyPasteBehavior(oo.copyPaste).selectToCopy && term.hasSelection()) {
353
+ void providerWrite(clip.provider, term.getSelection());
354
+ }
355
+ });
356
+ const onMouseDown = (ev) => {
357
+ const oo = optsRef.current;
358
+ if (ev.button !== 1) return;
359
+ if (!resolveClipboard(oo.clipboard).enabled) return;
360
+ if (!copyPasteBehavior(oo.copyPaste).middleClickPaste) return;
361
+ ev.preventDefault();
362
+ void pasteFromClipboard();
363
+ };
364
+ el.addEventListener("mousedown", onMouseDown);
365
+ const onPasteEvent = (e) => {
366
+ const o2 = optsRef.current;
367
+ if (resolveClipboard(o2.clipboard).enabled === false) return;
368
+ if (o2.readOnly) {
369
+ e.preventDefault();
370
+ return;
371
+ }
372
+ const payload = readDataTransfer(e.clipboardData);
373
+ if (o2.onPaste?.(payload) === false) e.preventDefault();
374
+ };
375
+ el.addEventListener("paste", onPasteEvent, true);
132
376
  if (o.initialOutput) term.write(o.initialOutput);
133
377
  if (o.fit ?? true) handle.fit();
378
+ optsRef.current.onReady?.(term);
379
+ readyRef.current.resolve(term);
134
380
  return () => {
381
+ el.removeEventListener("paste", onPasteEvent, true);
382
+ el.removeEventListener("mousedown", onMouseDown);
383
+ oscDisposable?.dispose();
384
+ selectionSub.dispose();
135
385
  dataSub.dispose();
136
386
  resizeSub.dispose();
137
387
  term.dispose();
138
388
  xtermRef.current = null;
139
389
  fitRef.current = null;
390
+ readyRef.current = makeDeferred();
140
391
  };
141
392
  }, [containerRef]);
142
393
  useEffect(() => {
@@ -362,6 +613,122 @@ function ShellSwitcher({
362
613
  );
363
614
  }
364
615
 
616
+ // src/menu-position.ts
617
+ function clampMenuPosition(at, menu, viewport, margin = 8) {
618
+ const maxX = Math.max(margin, viewport.width - menu.width - margin);
619
+ const maxY = Math.max(margin, viewport.height - menu.height - margin);
620
+ return {
621
+ x: Math.min(Math.max(margin, at.x), maxX),
622
+ y: Math.min(Math.max(margin, at.y), maxY)
623
+ };
624
+ }
625
+ function TerminalContextMenu({
626
+ at,
627
+ items,
628
+ ctx,
629
+ onClose
630
+ }) {
631
+ const ref = useRef(null);
632
+ const [pos, setPos] = useState(at);
633
+ useLayoutEffect(() => {
634
+ const el = ref.current;
635
+ if (!el || typeof window === "undefined") return;
636
+ const r = el.getBoundingClientRect();
637
+ setPos(
638
+ clampMenuPosition(
639
+ at,
640
+ { width: r.width, height: r.height },
641
+ { width: window.innerWidth, height: window.innerHeight }
642
+ )
643
+ );
644
+ }, [at]);
645
+ useEffect(() => {
646
+ const onDown = (e) => {
647
+ if (ref.current && !ref.current.contains(e.target)) onClose();
648
+ };
649
+ const onKey = (e) => {
650
+ if (e.key === "Escape") onClose();
651
+ };
652
+ document.addEventListener("mousedown", onDown, true);
653
+ document.addEventListener("keydown", onKey, true);
654
+ return () => {
655
+ document.removeEventListener("mousedown", onDown, true);
656
+ document.removeEventListener("keydown", onKey, true);
657
+ };
658
+ }, [onClose]);
659
+ return /* @__PURE__ */ jsx(
660
+ "div",
661
+ {
662
+ ref,
663
+ "data-fancy-terminal-menu": "",
664
+ role: "menu",
665
+ style: {
666
+ position: "fixed",
667
+ left: pos.x,
668
+ top: pos.y,
669
+ zIndex: 2147483e3,
670
+ minWidth: 176,
671
+ padding: 4,
672
+ background: "#18181b",
673
+ border: "1px solid #3f3f46",
674
+ borderRadius: 8,
675
+ boxShadow: "0 10px 32px -8px rgba(0,0,0,.6)",
676
+ color: "#e4e4e7",
677
+ font: '13px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
678
+ userSelect: "none"
679
+ },
680
+ children: items.map(
681
+ (it) => it.separator ? /* @__PURE__ */ jsx(
682
+ "div",
683
+ {
684
+ role: "separator",
685
+ style: { height: 1, margin: "4px 6px", background: "#3f3f46" }
686
+ },
687
+ it.id
688
+ ) : /* @__PURE__ */ jsxs(
689
+ "button",
690
+ {
691
+ type: "button",
692
+ role: "menuitem",
693
+ "data-menu-item": it.id,
694
+ disabled: it.disabled,
695
+ onClick: () => {
696
+ if (it.disabled) return;
697
+ it.onSelect?.(ctx);
698
+ onClose();
699
+ },
700
+ onMouseEnter: (e) => {
701
+ if (!it.disabled) e.currentTarget.style.background = "#27272a";
702
+ },
703
+ onMouseLeave: (e) => {
704
+ e.currentTarget.style.background = "transparent";
705
+ },
706
+ style: {
707
+ display: "flex",
708
+ alignItems: "center",
709
+ gap: 9,
710
+ width: "100%",
711
+ textAlign: "left",
712
+ padding: "6px 9px",
713
+ border: "none",
714
+ borderRadius: 5,
715
+ background: "transparent",
716
+ color: it.disabled ? "#52525b" : "inherit",
717
+ cursor: it.disabled ? "default" : "pointer",
718
+ font: "inherit"
719
+ },
720
+ children: [
721
+ /* @__PURE__ */ jsx("span", { style: { width: 16, textAlign: "center", opacity: 0.8 }, children: it.icon ?? "" }),
722
+ /* @__PURE__ */ jsx("span", { style: { flex: 1 }, children: it.label })
723
+ ]
724
+ },
725
+ it.id
726
+ )
727
+ )
728
+ }
729
+ );
730
+ }
731
+
365
732
  // src/output-diff.ts
366
733
  function diffOutput(written, next) {
367
734
  if (next === written) return null;
@@ -378,6 +745,28 @@ function decideShellSelect(shells, current, id) {
378
745
  function decideShellSwitch(current, next) {
379
746
  return { changed: current !== next };
380
747
  }
748
+
749
+ // src/context-menu.ts
750
+ function defaultMenuItems(ctx, actions) {
751
+ const items = [
752
+ { id: "copy", label: "Copy", icon: "\u29C9", disabled: !ctx.hasSelection, onSelect: actions.copy }
753
+ ];
754
+ if (!ctx.readOnly) {
755
+ items.push({ id: "paste", label: "Paste", icon: "\u2398", onSelect: actions.paste });
756
+ }
757
+ items.push({ id: "select-all", label: "Select all", onSelect: actions.selectAll });
758
+ if (!ctx.readOnly) {
759
+ items.push({ id: "sep-default", separator: true });
760
+ items.push({ id: "clear", label: "Clear", onSelect: actions.clear });
761
+ }
762
+ return items;
763
+ }
764
+ function resolveMenuItems(config, ctx, actions) {
765
+ if (config === false) return null;
766
+ const defaults = defaultMenuItems(ctx, actions);
767
+ const items = config === void 0 || config === true ? defaults : Array.isArray(config) ? config : config(ctx, defaults);
768
+ return items.length > 0 ? items : null;
769
+ }
381
770
  var Terminal = forwardRef(function Terminal2({
382
771
  output,
383
772
  theme,
@@ -397,6 +786,12 @@ var Terminal = forwardRef(function Terminal2({
397
786
  activeShell,
398
787
  onShellChange,
399
788
  showShellBar = false,
789
+ clipboard,
790
+ osc52,
791
+ copyPaste,
792
+ onReady,
793
+ onPaste,
794
+ contextMenu,
400
795
  className,
401
796
  style,
402
797
  ...rest
@@ -429,12 +824,34 @@ var Terminal = forwardRef(function Terminal2({
429
824
  // diffing effect below owns the buffer, so don't double-write.
430
825
  initialOutput: output === void 0 ? initialOutput : void 0,
431
826
  onData,
432
- onResize
827
+ onResize,
828
+ clipboard,
829
+ osc52,
830
+ copyPaste,
831
+ onReady,
832
+ onPaste
433
833
  });
434
834
  useImperativeHandle(
435
835
  ref,
436
836
  () => ({
437
- ...handle,
837
+ get xterm() {
838
+ return handle.xterm;
839
+ },
840
+ get ready() {
841
+ return handle.ready;
842
+ },
843
+ write: handle.write,
844
+ writeln: handle.writeln,
845
+ clear: handle.clear,
846
+ reset: handle.reset,
847
+ fit: handle.fit,
848
+ focus: handle.focus,
849
+ getBuffer: handle.getBuffer,
850
+ getSelection: handle.getSelection,
851
+ copySelection: handle.copySelection,
852
+ paste: handle.paste,
853
+ selectAll: handle.selectAll,
854
+ clearSelection: handle.clearSelection,
438
855
  setShell: selectShell,
439
856
  getShell: () => shellStateRef.current.shellId
440
857
  }),
@@ -449,18 +866,46 @@ var Terminal = forwardRef(function Terminal2({
449
866
  handle.write(change.write);
450
867
  written.current = output;
451
868
  }, [output, handle]);
869
+ const [menu, setMenu] = useState(null);
870
+ const openMenu = useCallback(
871
+ (e) => {
872
+ if (contextMenu === false) return;
873
+ e.preventDefault();
874
+ const selection = handle.getSelection();
875
+ const ctx = {
876
+ hasSelection: selection.length > 0,
877
+ selection,
878
+ readOnly: !!readOnly
879
+ };
880
+ const items = resolveMenuItems(contextMenu, ctx, {
881
+ copy: () => void handle.copySelection(),
882
+ paste: () => void handle.paste(),
883
+ selectAll: () => handle.selectAll(),
884
+ clear: () => handle.clear()
885
+ });
886
+ if (items) setMenu({ at: { x: e.clientX, y: e.clientY }, items, ctx });
887
+ },
888
+ [contextMenu, handle, readOnly]
889
+ );
890
+ const menuNode = menu ? /* @__PURE__ */ jsx(TerminalContextMenu, { at: menu.at, items: menu.items, ctx: menu.ctx, onClose: () => setMenu(null) }) : null;
452
891
  const surface = /* @__PURE__ */ jsx(
453
892
  "div",
454
893
  {
455
894
  ref: containerRef,
456
895
  "data-fancy-terminal": "",
896
+ onContextMenu: openMenu,
457
897
  "data-readonly": readOnly ? "" : void 0,
458
898
  "data-fancy-terminal-shell": shellId ?? void 0,
459
899
  style: showShellBar && shells ? { width: "100%", flex: 1, minHeight: 0 } : { width: "100%", height: "100%", ...style },
460
900
  ...showShellBar && shells ? {} : rest
461
901
  }
462
902
  );
463
- if (!showShellBar || !shells) return surface;
903
+ if (!showShellBar || !shells) {
904
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
905
+ surface,
906
+ menuNode
907
+ ] });
908
+ }
464
909
  return /* @__PURE__ */ jsxs(
465
910
  "div",
466
911
  {
@@ -491,7 +936,8 @@ var Terminal = forwardRef(function Terminal2({
491
936
  )
492
937
  }
493
938
  ),
494
- surface
939
+ surface,
940
+ menuNode
495
941
  ]
496
942
  }
497
943
  );
@@ -540,6 +986,15 @@ function useTerminalSession(options) {
540
986
  return { output, sendData, append, clear, shell, switchShell };
541
987
  }
542
988
 
543
- export { BUILTIN_SHELLS, ShellSwitcher, Terminal, fancyDarkTheme, resolveShell, useTerminal, useTerminalFit, useTerminalSession };
989
+ // src/copy-keybinding.ts
990
+ function shouldCopyEvent(e, hasSelection) {
991
+ if (e.type !== "keydown") return false;
992
+ if (e.key !== "c" && e.key !== "C") return false;
993
+ if (e.ctrlKey && e.shiftKey && !e.metaKey) return true;
994
+ if (e.metaKey && !e.ctrlKey && hasSelection) return true;
995
+ return false;
996
+ }
997
+
998
+ export { BUILTIN_SHELLS, ShellSwitcher, Terminal, TerminalContextMenu, clampMenuPosition, copyPasteBehavior, decodeBase64, defaultMenuItems, encodeBase64, fancyDarkTheme, isImageFile, navigatorClipboard, osc52Response, parseOsc52, registerOsc52, resolveClipboard, resolveKeyAction, resolveMenuItems, resolveShell, shouldCopyEvent, useTerminal, useTerminalFit, useTerminalSession };
544
999
  //# sourceMappingURL=index.js.map
545
1000
  //# sourceMappingURL=index.js.map