@particle-academy/react-fancy 5.64.0 → 5.65.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.
@@ -1,11 +1,100 @@
1
1
  import { Tooltip } from './chunk-GR2WKBER.js';
2
- import { useInlinePaste, InlinePasteEditor, PastedFilesInput } from './chunk-NILA2LZY.js';
2
+ import { useInlinePaste, InlinePasteEditor, PastedFilesInput, markedIds, expandMarkers } from './chunk-NILA2LZY.js';
3
3
  import { Button } from './chunk-TLRRMYNV.js';
4
4
  import { Icon } from './chunk-J3A4Z2P7.js';
5
5
  import { cn } from './chunk-MMW2JGKZ.js';
6
- import { useState, useRef, useEffect, useMemo } from 'react';
6
+ import { forwardRef, useState, useRef, useCallback, useReducer, useMemo, useEffect, useLayoutEffect, useImperativeHandle } from 'react';
7
7
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
8
8
 
9
+ // src/components/PromptInput/prompt-draft.ts
10
+ var EMPTY_DRAFT = { v: 1, text: "", pills: [], attachments: [] };
11
+ function readFile(raw, id) {
12
+ if (!raw || typeof raw !== "object") return null;
13
+ const f = raw;
14
+ const fileId = id ?? f.id;
15
+ if (typeof fileId !== "string" || typeof f.name !== "string") return null;
16
+ const out = {
17
+ id: fileId,
18
+ name: f.name,
19
+ type: typeof f.type === "string" ? f.type : "",
20
+ bytes: typeof f.bytes === "number" && Number.isFinite(f.bytes) ? f.bytes : 0
21
+ };
22
+ if (typeof f.ref === "string") out.ref = f.ref;
23
+ return out;
24
+ }
25
+ function readPill(raw) {
26
+ if (!raw || typeof raw !== "object") return null;
27
+ const p = raw;
28
+ if (typeof p.id !== "string") return null;
29
+ if (p.kind === "text" && typeof p.text === "string") {
30
+ return { kind: "text", id: p.id, text: p.text };
31
+ }
32
+ if (p.kind === "image" && typeof p.label === "string") {
33
+ const file = readFile(p.file, p.id);
34
+ return file ? { kind: "image", id: p.id, label: p.label, file } : null;
35
+ }
36
+ return null;
37
+ }
38
+ function readPromptDraft(raw, inline) {
39
+ if (!raw || typeof raw !== "object" || raw.v !== 1) return null;
40
+ const d = raw;
41
+ let text = typeof d.text === "string" ? d.text : "";
42
+ let pills = (Array.isArray(d.pills) ? d.pills : []).map(readPill).filter((p) => p !== null);
43
+ let attachments = (Array.isArray(d.attachments) ? d.attachments : []).map((a) => readFile(a)).filter((a) => a !== null);
44
+ const marked = new Set(markedIds(text));
45
+ pills = pills.filter((p) => marked.has(p.id));
46
+ if (!inline && pills.length > 0) {
47
+ const texts = pills.flatMap((p) => p.kind === "text" ? [{ id: p.id, text: p.text }] : []);
48
+ const images = pills.flatMap((p) => p.kind === "image" ? [p] : []);
49
+ text = expandMarkers(
50
+ text,
51
+ texts,
52
+ images.map((p) => ({ id: p.id, label: p.label }))
53
+ );
54
+ attachments = [...attachments, ...images.map((p) => p.file)];
55
+ pills = [];
56
+ }
57
+ return { v: 1, text, pills, attachments };
58
+ }
59
+ function clonePromptDraft(draft) {
60
+ return JSON.parse(JSON.stringify(draft));
61
+ }
62
+ function promptDraftFiles(draft) {
63
+ return [
64
+ ...draft.attachments,
65
+ ...draft.pills.flatMap((p) => p.kind === "image" ? [p.file] : [])
66
+ ];
67
+ }
68
+ function promptFileToDataUrl(file) {
69
+ return new Promise((resolve, reject) => {
70
+ const reader = new FileReader();
71
+ reader.onload = () => resolve(String(reader.result));
72
+ reader.onerror = () => reject(reader.error);
73
+ reader.readAsDataURL(file);
74
+ });
75
+ }
76
+ function promptDataUrlToFile(dataUrl, name, type) {
77
+ if (!dataUrl.startsWith("data:")) return void 0;
78
+ const comma = dataUrl.indexOf(",");
79
+ if (comma < 0) return void 0;
80
+ const meta = dataUrl.slice(5, comma);
81
+ const body = dataUrl.slice(comma + 1);
82
+ const mime = type || meta.split(";")[0] || "application/octet-stream";
83
+ try {
84
+ let bytes;
85
+ if (meta.endsWith(";base64")) {
86
+ const bin = atob(body);
87
+ bytes = new Uint8Array(bin.length);
88
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
89
+ } else {
90
+ bytes = new TextEncoder().encode(decodeURIComponent(body));
91
+ }
92
+ return new File([bytes], name, { type: mime });
93
+ } catch {
94
+ return void 0;
95
+ }
96
+ }
97
+
9
98
  // src/utils/platform.ts
10
99
  var APPLE_UA_DATA_PLATFORMS = /* @__PURE__ */ new Set(["macos", "ios", "ipados"]);
11
100
  var APPLE_FREE_TEXT = /\b(mac|macintosh|iphone|ipad|ipod|darwin)/i;
@@ -32,7 +121,15 @@ var DEFAULT_MENTION_COLOR = {
32
121
  file: "#10b981",
33
122
  person: "#3b82f6"
34
123
  };
35
- function PromptInput({
124
+ var PromptInput = forwardRef(function PromptInput2({
125
+ value: controlledValue,
126
+ defaultValue,
127
+ onChange,
128
+ draft: controlledDraft,
129
+ defaultDraft,
130
+ onDraftChange,
131
+ persistAttachment,
132
+ restoreAttachment,
36
133
  budgetTokens,
37
134
  commands = [],
38
135
  mentions = [],
@@ -58,9 +155,146 @@ function PromptInput({
58
155
  meterShowAt = 0.75,
59
156
  meterWarnAt = 0.9,
60
157
  ...rest
61
- }) {
62
- const [text, setText] = useState("");
158
+ }, ref) {
63
159
  const inline = pasteThreshold !== void 0 || pasteImages;
160
+ const draftControlled = controlledDraft !== void 0;
161
+ const [innerDraft, setInnerDraft] = useState(
162
+ () => defaultDraft && readPromptDraft(defaultDraft, inline) || {
163
+ ...EMPTY_DRAFT,
164
+ text: defaultValue ?? ""
165
+ }
166
+ );
167
+ const baseDraft = draftControlled ? controlledDraft : innerDraft;
168
+ const model = !draftControlled && controlledValue !== void 0 && controlledValue !== baseDraft.text ? { ...baseDraft, text: controlledValue } : baseDraft;
169
+ const modelRef = useRef(model);
170
+ modelRef.current = model;
171
+ const text = model.text;
172
+ const onChangeRef = useRef(onChange);
173
+ onChangeRef.current = onChange;
174
+ const onDraftChangeRef = useRef(onDraftChange);
175
+ onDraftChangeRef.current = onDraftChange;
176
+ const persistRef = useRef(persistAttachment);
177
+ persistRef.current = persistAttachment;
178
+ const commit = useCallback(
179
+ (update, notify = true) => {
180
+ const prev = modelRef.current;
181
+ const next = update(prev);
182
+ if (next === prev) return;
183
+ modelRef.current = next;
184
+ setInnerDraft(next);
185
+ if (!notify) return;
186
+ if (next.text !== prev.text) onChangeRef.current?.(next.text);
187
+ onDraftChangeRef.current?.(next);
188
+ },
189
+ []
190
+ );
191
+ const filesRef = useRef(/* @__PURE__ */ new Map());
192
+ const placeholdersRef = useRef(/* @__PURE__ */ new Map());
193
+ const requestedRef = useRef(/* @__PURE__ */ new Set());
194
+ const [filesVersion, bumpFiles] = useReducer((n) => n + 1, 0);
195
+ const setFileRef = useCallback(
196
+ (id, fileRef2) => commit((m) => {
197
+ const withRef = (f) => f.id === id ? { ...f, ref: fileRef2 } : f;
198
+ if (!promptDraftFiles(m).some((f) => f.id === id)) return m;
199
+ return {
200
+ ...m,
201
+ attachments: m.attachments.map(withRef),
202
+ pills: m.pills.map((p) => p.kind === "image" ? { ...p, file: withRef(p.file) } : p)
203
+ };
204
+ }),
205
+ [commit]
206
+ );
207
+ const persist = useCallback(
208
+ (file, entry) => {
209
+ const store = persistRef.current;
210
+ if (!store) return void 0;
211
+ let out;
212
+ try {
213
+ out = store(file, entry);
214
+ } catch (error) {
215
+ console.error("PromptInput: persistAttachment threw", error);
216
+ return void 0;
217
+ }
218
+ if (typeof out === "string") return out;
219
+ if (out && typeof out.then === "function") {
220
+ out.then(
221
+ (fileRef2) => {
222
+ if (typeof fileRef2 === "string") setFileRef(entry.id, fileRef2);
223
+ },
224
+ (error) => console.error("PromptInput: persistAttachment rejected", error)
225
+ );
226
+ }
227
+ return void 0;
228
+ },
229
+ [setFileRef]
230
+ );
231
+ const fileMeta = (id, file) => ({
232
+ id,
233
+ name: file.name,
234
+ type: file.type,
235
+ bytes: file.size
236
+ });
237
+ const fileFor = useCallback((entry) => {
238
+ const real = filesRef.current.get(entry.id);
239
+ if (real) return real;
240
+ let stand = placeholdersRef.current.get(entry.id);
241
+ if (!stand) {
242
+ stand = new File([], entry.name, { type: entry.type });
243
+ placeholdersRef.current.set(entry.id, stand);
244
+ }
245
+ return stand;
246
+ }, []);
247
+ const heldPastes = useMemo(
248
+ () => model.pills.flatMap((p) => p.kind === "text" ? [{ id: p.id, text: p.text }] : []),
249
+ [model.pills]
250
+ );
251
+ const heldImages = useMemo(
252
+ () => model.pills.flatMap(
253
+ (p) => p.kind === "image" ? [{ id: p.id, label: p.label, file: fileFor(p.file) }] : []
254
+ ),
255
+ // filesVersion: a restored File arrived, so the stand-in can be swapped out.
256
+ // eslint-disable-next-line react-hooks/exhaustive-deps
257
+ [model.pills, fileFor, filesVersion]
258
+ );
259
+ const onHeldPastesChange = useCallback(
260
+ (next) => commit((m) => {
261
+ const current = m.pills.filter((p) => p.kind === "text");
262
+ if (current.length === next.length && current.every((p, i) => p.id === next[i].id && p.kind === "text" && p.text === next[i].text)) {
263
+ return m;
264
+ }
265
+ return {
266
+ ...m,
267
+ pills: [
268
+ ...next.map((p) => ({ kind: "text", id: p.id, text: p.text })),
269
+ ...m.pills.filter((p) => p.kind === "image")
270
+ ]
271
+ };
272
+ }),
273
+ [commit]
274
+ );
275
+ const onHeldImagesChange = useCallback(
276
+ (next) => commit((m) => {
277
+ const before = new Map(
278
+ m.pills.flatMap((p) => p.kind === "image" ? [[p.id, p]] : [])
279
+ );
280
+ if (before.size === next.length && next.every((i) => before.has(i.id))) return m;
281
+ const images = next.map((image) => {
282
+ const known = before.get(image.id);
283
+ if (known && known.kind === "image") return known;
284
+ filesRef.current.set(image.id, image.file);
285
+ const entry = fileMeta(image.id, image.file);
286
+ const fileRef2 = persist(image.file, entry);
287
+ return {
288
+ kind: "image",
289
+ id: image.id,
290
+ label: image.label,
291
+ file: fileRef2 === void 0 ? entry : { ...entry, ref: fileRef2 }
292
+ };
293
+ });
294
+ return { ...m, pills: [...m.pills.filter((p) => p.kind === "text"), ...images] };
295
+ }),
296
+ [commit, persist]
297
+ );
64
298
  const editorRef = useRef(null);
65
299
  const paste = useInlinePaste({
66
300
  pasteThreshold,
@@ -69,9 +303,55 @@ function PromptInput({
69
303
  renderPastePreview,
70
304
  classNames: pasteClassNames,
71
305
  disabled: false,
306
+ heldPastes,
307
+ onHeldPastesChange,
308
+ heldImages,
309
+ onHeldImagesChange,
72
310
  editorRef
73
311
  });
74
- const [attachments, setAttachments] = useState([]);
312
+ const attachments = useMemo(
313
+ () => model.attachments.map((a) => ({
314
+ id: a.id,
315
+ name: a.name,
316
+ bytes: a.bytes,
317
+ file: filesRef.current.get(a.id),
318
+ type: a.type,
319
+ ...a.ref !== void 0 ? { ref: a.ref } : {}
320
+ })),
321
+ // eslint-disable-next-line react-hooks/exhaustive-deps
322
+ [model.attachments, filesVersion]
323
+ );
324
+ const restoreRef = useRef(restoreAttachment);
325
+ restoreRef.current = restoreAttachment;
326
+ useEffect(() => {
327
+ const restore = restoreRef.current;
328
+ if (!restore) return;
329
+ for (const entry of promptDraftFiles(model)) {
330
+ if (filesRef.current.has(entry.id) || requestedRef.current.has(entry.id)) continue;
331
+ requestedRef.current.add(entry.id);
332
+ const accept = (file) => {
333
+ if (!(file instanceof Blob)) return;
334
+ filesRef.current.set(entry.id, file);
335
+ bumpFiles();
336
+ };
337
+ let out;
338
+ try {
339
+ out = restore(entry);
340
+ } catch (error) {
341
+ console.error("PromptInput: restoreAttachment threw", error);
342
+ continue;
343
+ }
344
+ if (out && typeof out.then === "function") {
345
+ out.then(
346
+ accept,
347
+ (error) => console.error("PromptInput: restoreAttachment rejected", error)
348
+ );
349
+ } else {
350
+ accept(out);
351
+ }
352
+ }
353
+ }, [model]);
354
+ const setText = (next) => commit((m) => m.text === next ? m : { ...m, text: next });
75
355
  const [picker, setPicker] = useState(null);
76
356
  const taRef = useRef(null);
77
357
  const fileRef = useRef(null);
@@ -160,22 +440,68 @@ function PromptInput({
160
440
  const submit = () => {
161
441
  if (!text.trim() && attachments.length === 0) return;
162
442
  if (inline) {
163
- const pastedImages = paste.imagesIn(text).map((image) => ({
164
- id: image.id,
165
- name: image.file.name,
166
- bytes: image.file.size,
167
- file: image.file,
168
- type: image.file.type
169
- }));
443
+ const pastedImages = paste.imagesIn(text).map((image) => {
444
+ const entry = model.pills.find((p) => p.id === image.id);
445
+ const meta = entry?.kind === "image" ? entry.file : fileMeta(image.id, image.file);
446
+ return {
447
+ id: image.id,
448
+ name: meta.name,
449
+ bytes: meta.bytes,
450
+ file: filesRef.current.get(image.id),
451
+ type: meta.type,
452
+ ...meta.ref !== void 0 ? { ref: meta.ref } : {}
453
+ };
454
+ });
170
455
  onSubmit(paste.expand(text), [...attachments, ...pastedImages]);
171
- paste.clear();
172
456
  } else {
173
457
  onSubmit(text, attachments);
174
458
  }
175
- setText("");
176
- setAttachments([]);
459
+ commit(() => EMPTY_DRAFT);
460
+ filesRef.current.clear();
461
+ placeholdersRef.current.clear();
462
+ requestedRef.current.clear();
177
463
  setPicker(null);
178
464
  };
465
+ const pendingFocus = useRef(false);
466
+ const focusInput = useCallback(() => {
467
+ if (inline) {
468
+ editorRef.current?.focus();
469
+ return;
470
+ }
471
+ const ta = taRef.current;
472
+ if (!ta) return;
473
+ ta.focus();
474
+ ta.setSelectionRange(ta.value.length, ta.value.length);
475
+ }, [inline]);
476
+ useLayoutEffect(() => {
477
+ if (!pendingFocus.current) return;
478
+ pendingFocus.current = false;
479
+ focusInput();
480
+ });
481
+ useImperativeHandle(
482
+ ref,
483
+ () => ({
484
+ getDraft: () => clonePromptDraft(modelRef.current),
485
+ setDraft(next, options) {
486
+ const read = readPromptDraft(next, inline);
487
+ if (!read) {
488
+ console.warn(
489
+ "PromptInput: setDraft ignored a draft it does not recognise (expected { v: 1, ... }).",
490
+ next
491
+ );
492
+ return;
493
+ }
494
+ for (const entry of promptDraftFiles(read)) {
495
+ if (!filesRef.current.has(entry.id)) requestedRef.current.delete(entry.id);
496
+ }
497
+ setPicker(null);
498
+ commit(() => read, false);
499
+ if (options?.focus) pendingFocus.current = true;
500
+ },
501
+ focus: focusInput
502
+ }),
503
+ [inline, commit, focusInput]
504
+ );
179
505
  const onKeyDown = (e) => {
180
506
  if (picker) {
181
507
  if (e.key === "ArrowDown") {
@@ -209,16 +535,14 @@ function PromptInput({
209
535
  const attach = (list) => {
210
536
  const files = Array.from(list ?? []);
211
537
  if (files.length === 0) return;
212
- setAttachments((cur) => [
213
- ...cur,
214
- ...files.map((f) => ({
215
- id: crypto.randomUUID(),
216
- name: f.name,
217
- bytes: f.size,
218
- file: f,
219
- type: f.type
220
- }))
221
- ]);
538
+ const added = files.map((f) => {
539
+ const id = crypto.randomUUID();
540
+ filesRef.current.set(id, f);
541
+ const entry = fileMeta(id, f);
542
+ const fileRef2 = persist(f, entry);
543
+ return fileRef2 === void 0 ? entry : { ...entry, ref: fileRef2 };
544
+ });
545
+ commit((m) => ({ ...m, attachments: [...m.attachments, ...added] }));
222
546
  };
223
547
  const onDrop = (e) => {
224
548
  e.preventDefault();
@@ -254,6 +578,7 @@ function PromptInput({
254
578
  attachments.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1.5 border-b border-zinc-200 px-3 py-2 dark:border-zinc-800", children: attachments.map((a) => /* @__PURE__ */ jsxs(
255
579
  "span",
256
580
  {
581
+ "data-react-fancy-prompt-input-attachment": a.id,
257
582
  className: "inline-flex items-center gap-1.5 rounded-md bg-[color:var(--fancy-surface-muted,var(--color-zinc-100))] px-2 py-0.5 text-[11px] dark:bg-[color:var(--fancy-surface-muted-dark,var(--color-zinc-800))]",
258
583
  children: [
259
584
  /* @__PURE__ */ jsx(Icon, { name: "paperclip", size: "xs", className: "text-zinc-400" }),
@@ -262,7 +587,10 @@ function PromptInput({
262
587
  /* @__PURE__ */ jsx(
263
588
  "button",
264
589
  {
265
- onClick: () => setAttachments((cur) => cur.filter((x) => x.id !== a.id)),
590
+ onClick: () => commit((m) => ({
591
+ ...m,
592
+ attachments: m.attachments.filter((x) => x.id !== a.id)
593
+ })),
266
594
  className: "opacity-50 hover:opacity-100",
267
595
  "aria-label": "Remove attachment",
268
596
  children: "\xD7"
@@ -304,7 +632,7 @@ function PromptInput({
304
632
  PastedFilesInput,
305
633
  {
306
634
  name: pasteFilesName,
307
- files: paste.imagesIn(text).map((i) => i.file)
635
+ files: paste.imagesIn(text).flatMap((i) => filesRef.current.has(i.id) ? [i.file] : [])
308
636
  }
309
637
  )
310
638
  ] }) : /* @__PURE__ */ jsx(
@@ -469,7 +797,7 @@ function PromptInput({
469
797
  ]
470
798
  }
471
799
  );
472
- }
800
+ });
473
801
  function fmtTokens(n) {
474
802
  if (n < 1e3) return `${n}`;
475
803
  return `${(n / 1e3).toFixed(1)}k`;
@@ -480,6 +808,6 @@ function fmtSize(b) {
480
808
  return `${(b / 1024 / 1024).toFixed(1)} MB`;
481
809
  }
482
810
 
483
- export { PromptInput, browserPlatformHints, detectPlatform, modifierKeyLabel };
484
- //# sourceMappingURL=chunk-BTNF3QBR.js.map
485
- //# sourceMappingURL=chunk-BTNF3QBR.js.map
811
+ export { PromptInput, browserPlatformHints, detectPlatform, modifierKeyLabel, promptDataUrlToFile, promptFileToDataUrl };
812
+ //# sourceMappingURL=chunk-COXHFZR6.js.map
813
+ //# sourceMappingURL=chunk-COXHFZR6.js.map