@particle-academy/fancy-term 0.3.0 → 0.4.1

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
@@ -111,18 +111,120 @@ async function readClipboardText() {
111
111
  }
112
112
  return "";
113
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
+ }
114
138
 
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;
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
+ });
122
217
  }
123
218
 
124
219
  // src/hooks/use-terminal.ts
125
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
+ }
126
228
  function readBuffer(term) {
127
229
  if (!term) return "";
128
230
  const buf = term.buffer.active;
@@ -138,12 +240,16 @@ function useTerminal(containerRef, options = {}) {
138
240
  const fitRef = useRef(null);
139
241
  const optsRef = useRef(options);
140
242
  optsRef.current = options;
243
+ const readyRef = useRef(makeDeferred());
141
244
  const handleRef = useRef(null);
142
245
  if (handleRef.current === null) {
143
246
  handleRef.current = {
144
247
  get xterm() {
145
248
  return xtermRef.current;
146
249
  },
250
+ get ready() {
251
+ return readyRef.current.promise;
252
+ },
147
253
  write: (d) => xtermRef.current?.write(d),
148
254
  writeln: (d) => xtermRef.current?.writeln(d),
149
255
  clear: () => xtermRef.current?.clear(),
@@ -166,10 +272,10 @@ function useTerminal(containerRef, options = {}) {
166
272
  copySelection: async () => {
167
273
  const sel = xtermRef.current?.getSelection() ?? "";
168
274
  if (!sel) return false;
169
- return writeClipboardText(sel);
275
+ return providerWrite(resolveClipboard(optsRef.current.clipboard).provider, sel);
170
276
  },
171
277
  paste: async (text) => {
172
- const data = text ?? await readClipboardText();
278
+ const data = text ?? await providerRead(resolveClipboard(optsRef.current.clipboard).provider);
173
279
  if (data) xtermRef.current?.paste(data);
174
280
  },
175
281
  selectAll: () => xtermRef.current?.selectAll(),
@@ -211,17 +317,54 @@ function useTerminal(containerRef, options = {}) {
211
317
  const resizeSub = term.onResize(
212
318
  ({ cols, rows }) => optsRef.current.onResize?.({ cols, rows })
213
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
+ };
214
326
  term.attachCustomKeyEventHandler((e) => {
215
- if (optsRef.current.clipboard === false) return true;
216
- if (shouldCopyEvent(e, term.hasSelection())) {
217
- void writeClipboardText(term.getSelection());
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();
218
337
  return false;
219
338
  }
220
339
  return true;
221
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);
222
365
  const onPasteEvent = (e) => {
223
366
  const o2 = optsRef.current;
224
- if (o2.clipboard === false) return;
367
+ if (resolveClipboard(o2.clipboard).enabled === false) return;
225
368
  if (o2.readOnly) {
226
369
  e.preventDefault();
227
370
  return;
@@ -232,13 +375,19 @@ function useTerminal(containerRef, options = {}) {
232
375
  el.addEventListener("paste", onPasteEvent, true);
233
376
  if (o.initialOutput) term.write(o.initialOutput);
234
377
  if (o.fit ?? true) handle.fit();
378
+ optsRef.current.onReady?.(term);
379
+ readyRef.current.resolve(term);
235
380
  return () => {
236
381
  el.removeEventListener("paste", onPasteEvent, true);
382
+ el.removeEventListener("mousedown", onMouseDown);
383
+ oscDisposable?.dispose();
384
+ selectionSub.dispose();
237
385
  dataSub.dispose();
238
386
  resizeSub.dispose();
239
387
  term.dispose();
240
388
  xtermRef.current = null;
241
389
  fitRef.current = null;
390
+ readyRef.current = makeDeferred();
242
391
  };
243
392
  }, [containerRef]);
244
393
  useEffect(() => {
@@ -618,6 +767,12 @@ function resolveMenuItems(config, ctx, actions) {
618
767
  const items = config === void 0 || config === true ? defaults : Array.isArray(config) ? config : config(ctx, defaults);
619
768
  return items.length > 0 ? items : null;
620
769
  }
770
+
771
+ // src/selection-snapshot.ts
772
+ function nextSelectionSnapshot(prev, event, liveSelection) {
773
+ if (event.type === "mousedown" && event.button === 0) return "";
774
+ return liveSelection || prev;
775
+ }
621
776
  var Terminal = forwardRef(function Terminal2({
622
777
  output,
623
778
  theme,
@@ -638,6 +793,9 @@ var Terminal = forwardRef(function Terminal2({
638
793
  onShellChange,
639
794
  showShellBar = false,
640
795
  clipboard,
796
+ osc52,
797
+ copyPaste,
798
+ onReady,
641
799
  onPaste,
642
800
  contextMenu,
643
801
  className,
@@ -674,12 +832,32 @@ var Terminal = forwardRef(function Terminal2({
674
832
  onData,
675
833
  onResize,
676
834
  clipboard,
835
+ osc52,
836
+ copyPaste,
837
+ onReady,
677
838
  onPaste
678
839
  });
679
840
  useImperativeHandle(
680
841
  ref,
681
842
  () => ({
682
- ...handle,
843
+ get xterm() {
844
+ return handle.xterm;
845
+ },
846
+ get ready() {
847
+ return handle.ready;
848
+ },
849
+ write: handle.write,
850
+ writeln: handle.writeln,
851
+ clear: handle.clear,
852
+ reset: handle.reset,
853
+ fit: handle.fit,
854
+ focus: handle.focus,
855
+ getBuffer: handle.getBuffer,
856
+ getSelection: handle.getSelection,
857
+ copySelection: handle.copySelection,
858
+ paste: handle.paste,
859
+ selectAll: handle.selectAll,
860
+ clearSelection: handle.clearSelection,
683
861
  setShell: selectShell,
684
862
  getShell: () => shellStateRef.current.shellId
685
863
  }),
@@ -695,25 +873,42 @@ var Terminal = forwardRef(function Terminal2({
695
873
  written.current = output;
696
874
  }, [output, handle]);
697
875
  const [menu, setMenu] = useState(null);
876
+ const selectionSnapshot = useRef("");
877
+ const trackSelection = useCallback(
878
+ (e) => {
879
+ selectionSnapshot.current = nextSelectionSnapshot(
880
+ selectionSnapshot.current,
881
+ { type: e.type, button: e.button },
882
+ handle.getSelection()
883
+ );
884
+ },
885
+ [handle]
886
+ );
698
887
  const openMenu = useCallback(
699
888
  (e) => {
700
889
  if (contextMenu === false) return;
701
890
  e.preventDefault();
702
- const selection = handle.getSelection();
891
+ const selection = handle.getSelection() || selectionSnapshot.current;
703
892
  const ctx = {
704
893
  hasSelection: selection.length > 0,
705
894
  selection,
706
895
  readOnly: !!readOnly
707
896
  };
708
897
  const items = resolveMenuItems(contextMenu, ctx, {
709
- copy: () => void handle.copySelection(),
898
+ // Copy writes the SNAPSHOTTED selection through the active clipboard
899
+ // provider — never a click-time re-read, which copies "" over a TUI
900
+ // (falling back to the live selection only when the snapshot is empty).
901
+ copy: (c) => {
902
+ const text = c.selection || handle.getSelection();
903
+ if (text) void providerWrite(resolveClipboard(clipboard).provider, text);
904
+ },
710
905
  paste: () => void handle.paste(),
711
906
  selectAll: () => handle.selectAll(),
712
907
  clear: () => handle.clear()
713
908
  });
714
909
  if (items) setMenu({ at: { x: e.clientX, y: e.clientY }, items, ctx });
715
910
  },
716
- [contextMenu, handle, readOnly]
911
+ [contextMenu, handle, readOnly, clipboard]
717
912
  );
718
913
  const menuNode = menu ? /* @__PURE__ */ jsx(TerminalContextMenu, { at: menu.at, items: menu.items, ctx: menu.ctx, onClose: () => setMenu(null) }) : null;
719
914
  const surface = /* @__PURE__ */ jsx(
@@ -721,6 +916,8 @@ var Terminal = forwardRef(function Terminal2({
721
916
  {
722
917
  ref: containerRef,
723
918
  "data-fancy-terminal": "",
919
+ onMouseDownCapture: trackSelection,
920
+ onMouseUpCapture: trackSelection,
724
921
  onContextMenu: openMenu,
725
922
  "data-readonly": readOnly ? "" : void 0,
726
923
  "data-fancy-terminal-shell": shellId ?? void 0,
@@ -814,6 +1011,15 @@ function useTerminalSession(options) {
814
1011
  return { output, sendData, append, clear, shell, switchShell };
815
1012
  }
816
1013
 
817
- export { BUILTIN_SHELLS, ShellSwitcher, Terminal, TerminalContextMenu, clampMenuPosition, defaultMenuItems, fancyDarkTheme, isImageFile, resolveMenuItems, resolveShell, shouldCopyEvent, useTerminal, useTerminalFit, useTerminalSession };
1014
+ // src/copy-keybinding.ts
1015
+ function shouldCopyEvent(e, hasSelection) {
1016
+ if (e.type !== "keydown") return false;
1017
+ if (e.key !== "c" && e.key !== "C") return false;
1018
+ if (e.ctrlKey && e.shiftKey && !e.metaKey) return true;
1019
+ if (e.metaKey && !e.ctrlKey && hasSelection) return true;
1020
+ return false;
1021
+ }
1022
+
1023
+ export { BUILTIN_SHELLS, ShellSwitcher, Terminal, TerminalContextMenu, clampMenuPosition, copyPasteBehavior, decodeBase64, defaultMenuItems, encodeBase64, fancyDarkTheme, isImageFile, navigatorClipboard, nextSelectionSnapshot, osc52Response, parseOsc52, registerOsc52, resolveClipboard, resolveKeyAction, resolveMenuItems, resolveShell, shouldCopyEvent, useTerminal, useTerminalFit, useTerminalSession };
818
1024
  //# sourceMappingURL=index.js.map
819
1025
  //# sourceMappingURL=index.js.map