@djangocfg/ui-tools 2.1.409 → 2.1.412
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/package.json +13 -13
- package/src/{tools/Chat/highlight → lib/browser-bridge}/README.md +46 -18
- package/src/lib/browser-bridge/commands/chat.ts +42 -0
- package/src/lib/browser-bridge/commands/highlight.ts +70 -0
- package/src/lib/browser-bridge/commands/index.ts +15 -0
- package/src/lib/browser-bridge/commands/inspect.ts +31 -0
- package/src/lib/browser-bridge/commands/scroll.ts +31 -0
- package/src/lib/browser-bridge/commands/write.ts +45 -0
- package/src/lib/browser-bridge/directive-bus.ts +120 -0
- package/src/lib/browser-bridge/index.ts +56 -0
- package/src/lib/browser-bridge/logger.ts +27 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/HighlightOverlay.tsx +14 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/HighlightOverlay.test.tsx +52 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/__tests__/resolveRef.test.ts +39 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/index.ts +8 -5
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/resolveRef.ts +5 -0
- package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/useHighlightTargets.ts +58 -27
- package/src/lib/browser-bridge/overlay/waitForVisible.ts +70 -0
- package/src/lib/browser-bridge/registry.ts +41 -0
- package/src/lib/browser-bridge/setBridgeResolver.ts +42 -0
- package/src/lib/browser-bridge/window.ts +76 -0
- package/src/lib/page-snapshot/capture/walk.ts +13 -5
- package/src/lib/page-snapshot/engine.ts +9 -4
- package/src/lib/page-snapshot/index.ts +5 -0
- package/src/lib/page-snapshot/react/provider.tsx +70 -3
- package/src/lib/page-snapshot/react/use-page-snapshot.ts +10 -0
- package/src/lib/page-snapshot/refs/__tests__/locator.test.ts +94 -0
- package/src/lib/page-snapshot/refs/__tests__/registry.test.ts +59 -3
- package/src/lib/page-snapshot/refs/locator.ts +218 -0
- package/src/lib/page-snapshot/refs/registry.ts +29 -14
- package/src/tools/Chat/README.md +1 -1
- package/src/tools/Chat/composer/AttachContext.tsx +22 -0
- package/src/tools/Chat/composer/Composer.tsx +108 -6
- package/src/tools/Chat/composer/ComposerMenuButton.tsx +39 -2
- package/src/tools/Chat/composer/fileToAttachment.ts +53 -0
- package/src/tools/Chat/composer/index.ts +16 -1
- package/src/tools/Chat/composer/types.ts +71 -0
- package/src/tools/Chat/composer/useComposerAttach.tsx +218 -0
- package/src/tools/Chat/constants.ts +24 -1
- package/src/tools/Chat/context/ChatProvider.tsx +17 -2
- package/src/tools/Chat/core/logger.ts +15 -2
- package/src/tools/Chat/hooks/useChat.ts +32 -0
- package/src/tools/Chat/hooks/useChatComposer.ts +13 -0
- package/src/tools/Chat/index.ts +34 -2
- package/src/tools/Chat/launcher/ChatDock.tsx +13 -3
- package/src/tools/Chat/launcher/ChatFAB.tsx +4 -2
- package/src/tools/Chat/launcher/ChatGreeting.tsx +3 -2
- package/src/tools/Chat/launcher/ChatLauncher.tsx +42 -7
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +3 -2
- package/src/tools/Chat/launcher/header/ChatHeader.tsx +2 -0
- package/src/tools/Chat/launcher/header/ChatHeaderActionButton.tsx +2 -0
- package/src/tools/Chat/launcher/header/ChatHeaderLanguageButton.tsx +2 -2
- package/src/tools/Chat/launcher/header/HeaderSlots.tsx +16 -9
- package/src/tools/Chat/lazy.tsx +34 -2
- package/src/tools/Chat/messages/MessageBubble.tsx +1 -1
- package/src/tools/Chat/public.ts +17 -0
- package/src/tools/Chat/settings/README.md +87 -0
- package/src/tools/Chat/settings/__tests__/useChatSettings.test.tsx +84 -0
- package/src/tools/Chat/settings/__tests__/useLocalStorage.test.tsx +138 -0
- package/src/tools/Chat/settings/index.ts +23 -0
- package/src/tools/Chat/settings/types.ts +108 -0
- package/src/tools/Chat/settings/useChatSettings.ts +168 -0
- package/src/tools/Chat/types/events.ts +50 -0
- package/src/tools/Chat/types/index.ts +1 -1
- package/src/tools/Chat/types/message.ts +5 -0
- package/src/tools/CronScheduler/CronScheduler.client.tsx +42 -15
- package/src/tools/CronScheduler/components/CustomInput.tsx +26 -7
- package/src/tools/CronScheduler/components/DayChips.tsx +20 -7
- package/src/tools/CronScheduler/components/MonthDayGrid.tsx +35 -10
- package/src/tools/CronScheduler/components/SchedulePreview.tsx +8 -5
- package/src/tools/CronScheduler/components/ScheduleTypeSelector.tsx +12 -3
- package/src/tools/CronScheduler/components/TimeSelector.tsx +36 -13
- package/src/tools/CronScheduler/context/CronSchedulerContext.tsx +4 -0
- package/src/tools/CronScheduler/context/hooks.ts +8 -0
- package/src/tools/CronScheduler/context/index.ts +1 -0
- package/src/tools/CronScheduler/index.tsx +2 -0
- package/src/tools/CronScheduler/lazy.tsx +1 -0
- package/src/tools/CronScheduler/types/index.ts +18 -1
- package/src/tools/Map/lazy.tsx +11 -4
- package/src/tools/Uploader/hooks/useClipboardPaste.ts +3 -1
- package/src/tools/index.ts +2 -0
- /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/SpotlightCanvas.tsx +0 -0
- /package/src/{tools/Chat/highlight → lib/browser-bridge/overlay}/types.ts +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { forwardRef, useEffect, useRef } from 'react';
|
|
3
|
+
import { forwardRef, useEffect, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { Textarea } from '@djangocfg/ui-core/components';
|
|
6
6
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
@@ -8,13 +8,16 @@ import { cn } from '@djangocfg/ui-core/lib';
|
|
|
8
8
|
import { useChatContextOptional } from '../context';
|
|
9
9
|
import type { UseChatComposerReturn } from '../hooks/useChatComposer';
|
|
10
10
|
import { Attachments } from '../messages/Attachments';
|
|
11
|
+
import { AttachProvider } from './AttachContext';
|
|
11
12
|
import { ComposerActionBar } from './ComposerActionBar';
|
|
12
13
|
import { ComposerButton } from './ComposerButton';
|
|
13
14
|
import { ComposerFooter } from './ComposerFooter';
|
|
14
15
|
import { ComposerSizeProvider } from './size-context';
|
|
15
16
|
import { useComposerActions } from './useComposerActions';
|
|
17
|
+
import { useComposerAttach } from './useComposerAttach';
|
|
16
18
|
import type {
|
|
17
19
|
ComposerAppearance,
|
|
20
|
+
ComposerAttachConfig,
|
|
18
21
|
ComposerFooterProps,
|
|
19
22
|
ComposerLayout,
|
|
20
23
|
ComposerSize,
|
|
@@ -29,7 +32,16 @@ export interface ComposerProps {
|
|
|
29
32
|
composer: UseChatComposerReturn;
|
|
30
33
|
placeholder?: string;
|
|
31
34
|
disabled?: boolean;
|
|
35
|
+
/** Render the paperclip attach button. The composer ships a built-in
|
|
36
|
+
* file picker — paperclip, `+` menu, drag-drop and Ctrl+V paste all
|
|
37
|
+
* funnel through one validated pipeline. */
|
|
32
38
|
showAttachmentButton?: boolean;
|
|
39
|
+
/** Tune the built-in attach pipeline — accepted types, size / count
|
|
40
|
+
* caps, paste, and the optional upload transport. */
|
|
41
|
+
attach?: ComposerAttachConfig;
|
|
42
|
+
/** Override the built-in file picker. When set, the paperclip and the
|
|
43
|
+
* `+`-menu attach item call this instead of opening the native picker
|
|
44
|
+
* — for hosts that drive their own (e.g. a Wails native dialog). */
|
|
33
45
|
onPickFiles?: () => void;
|
|
34
46
|
className?: string;
|
|
35
47
|
textareaClassName?: string;
|
|
@@ -130,6 +142,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
130
142
|
placeholder = 'Type a message...',
|
|
131
143
|
disabled,
|
|
132
144
|
showAttachmentButton = false,
|
|
145
|
+
attach,
|
|
133
146
|
onPickFiles,
|
|
134
147
|
className,
|
|
135
148
|
textareaClassName,
|
|
@@ -220,13 +233,60 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
220
233
|
editable.focus();
|
|
221
234
|
};
|
|
222
235
|
|
|
236
|
+
// ── Attach pipeline ────────────────────────────────────────────────────
|
|
237
|
+
// The built-in file picker is mounted whenever the host renders the
|
|
238
|
+
// paperclip OR passes an `attach` config. `onPickFiles` (if given)
|
|
239
|
+
// overrides it — a host driving its own picker (e.g. a Wails dialog).
|
|
240
|
+
const attachEnabled = showAttachmentButton || attach != null;
|
|
241
|
+
const attachHandle = useComposerAttach({
|
|
242
|
+
composer,
|
|
243
|
+
config: attach ?? {},
|
|
244
|
+
disabled: isDisabled,
|
|
245
|
+
pasteScopeRef: surfaceRef,
|
|
246
|
+
});
|
|
247
|
+
// The resolved click handler: host override wins, else the built-in
|
|
248
|
+
// picker. `undefined` when attaching is not enabled at all.
|
|
249
|
+
const pickFiles = !attachEnabled
|
|
250
|
+
? undefined
|
|
251
|
+
: (onPickFiles ?? attachHandle.openPicker);
|
|
252
|
+
|
|
253
|
+
// Drag-drop onto the input surface — same validated path as the picker.
|
|
254
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
255
|
+
const dragDepth = useRef(0);
|
|
256
|
+
const handleDragEnter = (e: React.DragEvent) => {
|
|
257
|
+
if (!attachEnabled || isDisabled) return;
|
|
258
|
+
if (!e.dataTransfer.types.includes('Files')) return;
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
dragDepth.current += 1;
|
|
261
|
+
setIsDragging(true);
|
|
262
|
+
};
|
|
263
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
264
|
+
if (attachEnabled && !isDisabled && e.dataTransfer.types.includes('Files')) {
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
const handleDragLeave = (e: React.DragEvent) => {
|
|
269
|
+
if (!attachEnabled || isDisabled) return;
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
272
|
+
if (dragDepth.current === 0) setIsDragging(false);
|
|
273
|
+
};
|
|
274
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
275
|
+
if (!attachEnabled || isDisabled) return;
|
|
276
|
+
e.preventDefault();
|
|
277
|
+
dragDepth.current = 0;
|
|
278
|
+
setIsDragging(false);
|
|
279
|
+
const dropped = Array.from(e.dataTransfer.files ?? []);
|
|
280
|
+
if (dropped.length > 0) attachHandle.attachFiles(dropped);
|
|
281
|
+
};
|
|
282
|
+
|
|
223
283
|
// Merge built-in send/stop/attach descriptors with host arrays.
|
|
224
284
|
const { actionsStart, actionsEnd } = useComposerActions({
|
|
225
285
|
composer,
|
|
226
286
|
isStreaming,
|
|
227
287
|
isDisabled,
|
|
228
288
|
showAttachmentButton,
|
|
229
|
-
onPickFiles,
|
|
289
|
+
onPickFiles: pickFiles,
|
|
230
290
|
actionsStart: composerSlots?.actionsStart,
|
|
231
291
|
actionsEnd: composerSlots?.actionsEnd,
|
|
232
292
|
onSend: () => void composer.submit(),
|
|
@@ -311,11 +371,19 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
311
371
|
<AttachSlot
|
|
312
372
|
disabled={isDisabled}
|
|
313
373
|
size={size}
|
|
314
|
-
onClick={() =>
|
|
374
|
+
onClick={() => pickFiles?.()}
|
|
315
375
|
{...slotProps?.attach}
|
|
316
376
|
/>
|
|
317
377
|
) : null;
|
|
318
378
|
|
|
379
|
+
// Hidden native file input — the picker the paperclip + `+` menu open.
|
|
380
|
+
// Rendered once per composer; only present when the pipeline is active
|
|
381
|
+
// and no host override is in play (an override owns its own picker).
|
|
382
|
+
const hiddenFileInput =
|
|
383
|
+
attachEnabled && !onPickFiles ? (
|
|
384
|
+
<input {...attachHandle.inputProps} />
|
|
385
|
+
) : null;
|
|
386
|
+
|
|
319
387
|
// ── Footer ─────────────────────────────────────────────────────────────
|
|
320
388
|
const footerNode =
|
|
321
389
|
footer === false ? null : (
|
|
@@ -331,25 +399,53 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
331
399
|
(composer.attachments.length > 0 ? (
|
|
332
400
|
<Attachments
|
|
333
401
|
attachments={composer.attachments}
|
|
334
|
-
|
|
402
|
+
// Route through the attach pipeline so a removed attachment's
|
|
403
|
+
// object-URL is revoked; falls back to a plain remove when the
|
|
404
|
+
// pipeline is not mounted (host-managed attachments).
|
|
405
|
+
onRemove={(a) =>
|
|
406
|
+
attachEnabled
|
|
407
|
+
? attachHandle.removeAttachment(a.id)
|
|
408
|
+
: composer.removeAttachment(a.id)
|
|
409
|
+
}
|
|
335
410
|
/>
|
|
336
411
|
) : null);
|
|
337
412
|
|
|
338
413
|
// ── Inline (compact single-row) layout ─────────────────────────────────
|
|
414
|
+
// Drag-drop handlers shared by both layouts. No-op when attaching is
|
|
415
|
+
// disabled — the handlers themselves early-return.
|
|
416
|
+
const dragProps = {
|
|
417
|
+
onDragEnter: handleDragEnter,
|
|
418
|
+
onDragOver: handleDragOver,
|
|
419
|
+
onDragLeave: handleDragLeave,
|
|
420
|
+
onDrop: handleDrop,
|
|
421
|
+
};
|
|
422
|
+
// A faint accent wash + ring while a file is dragged over the composer.
|
|
423
|
+
const dragOverlay =
|
|
424
|
+
isDragging ? (
|
|
425
|
+
<div
|
|
426
|
+
aria-hidden
|
|
427
|
+
className="pointer-events-none absolute inset-0 z-10 rounded-[inherit] bg-primary/5 ring-2 ring-inset ring-primary/40"
|
|
428
|
+
/>
|
|
429
|
+
) : null;
|
|
430
|
+
|
|
339
431
|
// Start cluster sits left of the textarea, end cluster right of it —
|
|
340
432
|
// `[📎][actionsStart] [textarea] [actionsEnd][🎙][▶]` (§3.6).
|
|
341
433
|
if (layout === 'inline') {
|
|
342
434
|
return (
|
|
343
435
|
<ComposerSizeProvider value={size}>
|
|
436
|
+
<AttachProvider value={attachHandle}>
|
|
344
437
|
<div
|
|
345
438
|
ref={ref}
|
|
439
|
+
{...dragProps}
|
|
346
440
|
className={cn(
|
|
347
|
-
'border-t border-border bg-background/95',
|
|
441
|
+
'relative border-t border-border bg-background/95',
|
|
348
442
|
sz.containerPadding,
|
|
349
443
|
ap.containerPadding,
|
|
350
444
|
className,
|
|
351
445
|
)}
|
|
352
446
|
>
|
|
447
|
+
{hiddenFileInput}
|
|
448
|
+
{dragOverlay}
|
|
353
449
|
{trayNode ? <div className="mb-1.5">{trayNode}</div> : null}
|
|
354
450
|
<div
|
|
355
451
|
className={cn(
|
|
@@ -385,6 +481,7 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
385
481
|
</div>
|
|
386
482
|
{footerNode}
|
|
387
483
|
</div>
|
|
484
|
+
</AttachProvider>
|
|
388
485
|
</ComposerSizeProvider>
|
|
389
486
|
);
|
|
390
487
|
}
|
|
@@ -453,19 +550,24 @@ export const Composer = forwardRef<HTMLDivElement, ComposerProps>(function Compo
|
|
|
453
550
|
|
|
454
551
|
return (
|
|
455
552
|
<ComposerSizeProvider value={size}>
|
|
553
|
+
<AttachProvider value={attachHandle}>
|
|
456
554
|
<div
|
|
457
555
|
ref={ref}
|
|
556
|
+
{...dragProps}
|
|
458
557
|
className={cn(
|
|
459
|
-
'border-t border-border bg-background/95',
|
|
558
|
+
'relative border-t border-border bg-background/95',
|
|
460
559
|
sz.containerPadding,
|
|
461
560
|
ap.containerPadding,
|
|
462
561
|
className,
|
|
463
562
|
)}
|
|
464
563
|
>
|
|
564
|
+
{hiddenFileInput}
|
|
565
|
+
{dragOverlay}
|
|
465
566
|
{trayNode ? <div className="mb-2">{trayNode}</div> : null}
|
|
466
567
|
{surfaceWrapper}
|
|
467
568
|
{footerNode}
|
|
468
569
|
</div>
|
|
570
|
+
</AttachProvider>
|
|
469
571
|
</ComposerSizeProvider>
|
|
470
572
|
);
|
|
471
573
|
});
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Plus } from 'lucide-react';
|
|
3
|
+
import { Paperclip, Plus } from 'lucide-react';
|
|
4
4
|
|
|
5
5
|
import { MenuBuilder, type MenuItem } from '@djangocfg/ui-core/components';
|
|
6
6
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
7
|
|
|
8
|
+
import { useComposerAttachContext } from './AttachContext';
|
|
8
9
|
import { BUTTON_SIZE } from './ComposerButton';
|
|
9
10
|
import { useResolvedComposerSize } from './size-context';
|
|
10
11
|
import type { ComposerSize } from './types';
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Built-in "Attach" menu item config. When `attachItem` is set, the
|
|
15
|
+
* menu button prepends a row wired to the composer's attach pipeline —
|
|
16
|
+
* the SAME `openPicker` the paperclip button uses. Pass `true` for
|
|
17
|
+
* defaults, or an object to override the label / icon.
|
|
18
|
+
*/
|
|
19
|
+
export type ComposerMenuAttachItem =
|
|
20
|
+
| boolean
|
|
21
|
+
| { label?: string };
|
|
22
|
+
|
|
12
23
|
export interface ComposerMenuButtonProps {
|
|
13
24
|
/** Declarative menu tree (see `MenuBuilder`). */
|
|
14
25
|
items: MenuItem[];
|
|
26
|
+
/** Prepend a built-in "Attach" row wired to the composer's attach
|
|
27
|
+
* pipeline. Requires an attach-enabled `<Composer>` ancestor;
|
|
28
|
+
* silently omitted otherwise. */
|
|
29
|
+
attachItem?: ComposerMenuAttachItem;
|
|
15
30
|
/** Omit to inherit the composer's size. */
|
|
16
31
|
size?: ComposerSize;
|
|
17
32
|
disabled?: boolean;
|
|
@@ -30,15 +45,37 @@ export interface ComposerMenuButtonProps {
|
|
|
30
45
|
*/
|
|
31
46
|
export function ComposerMenuButton({
|
|
32
47
|
items,
|
|
48
|
+
attachItem,
|
|
33
49
|
size,
|
|
34
50
|
disabled,
|
|
35
51
|
label = 'Add files and more',
|
|
36
52
|
icon,
|
|
37
53
|
}: ComposerMenuButtonProps) {
|
|
38
54
|
const sz = BUTTON_SIZE[useResolvedComposerSize(size)];
|
|
55
|
+
const attach = useComposerAttachContext();
|
|
56
|
+
|
|
57
|
+
// Prepend the built-in "Attach" row when requested AND an attach
|
|
58
|
+
// pipeline is in scope — it reuses the exact same `openPicker` as the
|
|
59
|
+
// paperclip button, so both inputs are one unified path.
|
|
60
|
+
const attachLabel =
|
|
61
|
+
typeof attachItem === 'object' ? (attachItem.label ?? 'Attach files') : 'Attach files';
|
|
62
|
+
const resolvedItems: MenuItem[] =
|
|
63
|
+
attachItem && attach
|
|
64
|
+
? [
|
|
65
|
+
{
|
|
66
|
+
kind: 'item',
|
|
67
|
+
id: '__composer_attach',
|
|
68
|
+
label: attachLabel,
|
|
69
|
+
icon: Paperclip,
|
|
70
|
+
disabled: attach.disabled,
|
|
71
|
+
onSelect: () => attach.openPicker(),
|
|
72
|
+
},
|
|
73
|
+
...items,
|
|
74
|
+
]
|
|
75
|
+
: items;
|
|
39
76
|
|
|
40
77
|
return (
|
|
41
|
-
<MenuBuilder items={
|
|
78
|
+
<MenuBuilder items={resolvedItems} side="top" align="start">
|
|
42
79
|
<button
|
|
43
80
|
type="button"
|
|
44
81
|
disabled={disabled}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `File → ChatAttachment` — the engine-side producer that was missing.
|
|
3
|
+
*
|
|
4
|
+
* Before this, every host re-implemented the conversion (see the fake
|
|
5
|
+
* stub in `apps/storybook/.../SpeechAndAttachments`). It lives here so
|
|
6
|
+
* the picker, drag-drop and paste paths all mint identical attachments.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createId } from '../core/ids';
|
|
10
|
+
import { getAssetTypeFromMime } from '../../Uploader';
|
|
11
|
+
import type { ChatAttachment } from '../types';
|
|
12
|
+
|
|
13
|
+
/** Map a MIME type to the `ChatAttachment.type` discriminant. */
|
|
14
|
+
function attachmentTypeFromMime(mime: string): ChatAttachment['type'] {
|
|
15
|
+
if (mime.startsWith('image/')) return 'image';
|
|
16
|
+
if (mime.startsWith('audio/')) return 'audio';
|
|
17
|
+
if (mime.startsWith('video/')) return 'video';
|
|
18
|
+
// getAssetTypeFromMime returns 'document' for everything else; the
|
|
19
|
+
// ChatAttachment union has no 'document' member — collapse to 'file'.
|
|
20
|
+
const asset = getAssetTypeFromMime(mime);
|
|
21
|
+
return asset === 'document' ? 'file' : asset;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convert a picked/dropped/pasted `File` into a `ChatAttachment`.
|
|
26
|
+
*
|
|
27
|
+
* The `url` is an object-URL (`URL.createObjectURL`) — instantly usable
|
|
28
|
+
* for previews. When a host uploads the file via `uploadFn`, it replaces
|
|
29
|
+
* `url` with the remote URL and flips `status` to `'ready'`.
|
|
30
|
+
*
|
|
31
|
+
* `status` defaults to `'ready'`: with no `uploadFn` the attachment is
|
|
32
|
+
* immediately usable (current behaviour). The attach pipeline overrides
|
|
33
|
+
* it to `'uploading'` when an upload is in flight.
|
|
34
|
+
*/
|
|
35
|
+
export function fileToAttachment(file: File): ChatAttachment {
|
|
36
|
+
const mime = file.type || 'application/octet-stream';
|
|
37
|
+
return {
|
|
38
|
+
id: createId('att'),
|
|
39
|
+
type: attachmentTypeFromMime(mime),
|
|
40
|
+
url: URL.createObjectURL(file),
|
|
41
|
+
name: file.name,
|
|
42
|
+
mimeType: mime,
|
|
43
|
+
sizeBytes: file.size,
|
|
44
|
+
status: 'ready',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Revoke an attachment's object-URL, if it holds one. Call on remove. */
|
|
49
|
+
export function revokeAttachmentUrl(attachment: Pick<ChatAttachment, 'url'>): void {
|
|
50
|
+
if (attachment.url.startsWith('blob:')) {
|
|
51
|
+
URL.revokeObjectURL(attachment.url);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -9,7 +9,11 @@ export {
|
|
|
9
9
|
type ComposerAppearance,
|
|
10
10
|
} from './Composer';
|
|
11
11
|
export { ComposerButton, BUTTON_SIZE, type ComposerButtonProps } from './ComposerButton';
|
|
12
|
-
export {
|
|
12
|
+
export {
|
|
13
|
+
ComposerMenuButton,
|
|
14
|
+
type ComposerMenuButtonProps,
|
|
15
|
+
type ComposerMenuAttachItem,
|
|
16
|
+
} from './ComposerMenuButton';
|
|
13
17
|
export { ComposerRichTextarea, type ComposerRichTextareaProps } from './ComposerRichTextarea';
|
|
14
18
|
export { ComposerActionBar } from './ComposerActionBar';
|
|
15
19
|
export { ComposerToolPill, type ComposerToolPillProps } from './ComposerToolPill';
|
|
@@ -30,6 +34,12 @@ export {
|
|
|
30
34
|
type UseComposerActionsParams,
|
|
31
35
|
type ComposerActionClusters,
|
|
32
36
|
} from './useComposerActions';
|
|
37
|
+
export {
|
|
38
|
+
useComposerAttach,
|
|
39
|
+
type UseComposerAttachParams,
|
|
40
|
+
} from './useComposerAttach';
|
|
41
|
+
export { useComposerAttachContext } from './AttachContext';
|
|
42
|
+
export { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
|
|
33
43
|
export type {
|
|
34
44
|
ComposerAction,
|
|
35
45
|
ComposerActionVisibility,
|
|
@@ -42,4 +52,9 @@ export type {
|
|
|
42
52
|
AttachButtonProps,
|
|
43
53
|
ComposerTextareaProps,
|
|
44
54
|
ActionBarProps,
|
|
55
|
+
ComposerAcceptType,
|
|
56
|
+
ComposerAttachConfig,
|
|
57
|
+
ComposerAttachHandle,
|
|
58
|
+
ComposerUploadFn,
|
|
59
|
+
ComposerUploadResult,
|
|
45
60
|
} from './types';
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { ComponentType, ReactNode } from 'react';
|
|
4
4
|
|
|
5
5
|
import type { UseChatComposerReturn } from '../hooks/useChatComposer';
|
|
6
|
+
import type { ChatAttachment } from '../types';
|
|
6
7
|
|
|
7
8
|
/** Composer visual size — shared across the `<Composer>` API. */
|
|
8
9
|
export type ComposerSize = 'sm' | 'md' | 'lg';
|
|
@@ -141,3 +142,73 @@ export interface ComposerFooterProps {
|
|
|
141
142
|
size?: ComposerSize;
|
|
142
143
|
className?: string;
|
|
143
144
|
}
|
|
145
|
+
|
|
146
|
+
// ── Attach pipeline ─────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** Asset categories the picker accepts. Maps to MIME prefixes / extensions. */
|
|
149
|
+
export type ComposerAcceptType = 'image' | 'audio' | 'video' | 'document';
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Result a host's `uploadFn` resolves to — the remote location the
|
|
153
|
+
* attachment's `url` is rewritten to once the upload completes.
|
|
154
|
+
*/
|
|
155
|
+
export interface ComposerUploadResult {
|
|
156
|
+
url: string;
|
|
157
|
+
thumbnailUrl?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Optional upload transport. The single environment-specific seam:
|
|
162
|
+
* - omitted → attachments stay local (`blob:` object-URL, `status:'ready'`).
|
|
163
|
+
* - web → multipart POST to a CDN.
|
|
164
|
+
* - Wails → a Wails-bound upload method.
|
|
165
|
+
*
|
|
166
|
+
* `onProgress` receives 0..1; call it to drive the staging-tray progress.
|
|
167
|
+
*/
|
|
168
|
+
export type ComposerUploadFn = (
|
|
169
|
+
file: File,
|
|
170
|
+
onProgress: (fraction: number) => void,
|
|
171
|
+
) => Promise<ComposerUploadResult>;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Declarative config for the built-in attach pipeline. When present (or
|
|
175
|
+
* when `showAttachmentButton` is set), the composer mounts its own file
|
|
176
|
+
* picker — paperclip, `+` menu, drag-drop and paste all funnel through it.
|
|
177
|
+
*/
|
|
178
|
+
export interface ComposerAttachConfig {
|
|
179
|
+
/** Accepted asset categories. Default: all four. */
|
|
180
|
+
accept?: ComposerAcceptType[];
|
|
181
|
+
/** Max files per message. Falls back to the composer's `maxAttachments`. */
|
|
182
|
+
maxFiles?: number;
|
|
183
|
+
/** Per-file size cap in bytes. Default: unlimited. */
|
|
184
|
+
maxSizeBytes?: number;
|
|
185
|
+
/** Allow selecting more than one file at once. Default `true`. */
|
|
186
|
+
multiple?: boolean;
|
|
187
|
+
/** Upload transport — see {@link ComposerUploadFn}. */
|
|
188
|
+
uploadFn?: ComposerUploadFn;
|
|
189
|
+
/** Enable Ctrl+V / Cmd+V paste-to-attach. Default `true`. */
|
|
190
|
+
pasteEnabled?: boolean;
|
|
191
|
+
/** Called when a file is rejected (size / type / count). */
|
|
192
|
+
onReject?: (file: File, reason: 'size' | 'type' | 'count') => void;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Imperative handle the attach pipeline exposes to the composer + menu. */
|
|
196
|
+
export interface ComposerAttachHandle {
|
|
197
|
+
/** Open the native file picker. */
|
|
198
|
+
openPicker: () => void;
|
|
199
|
+
/** Funnel already-resolved files (drop / paste) through validation. */
|
|
200
|
+
attachFiles: (files: File[]) => void;
|
|
201
|
+
/** Remove a staged attachment, revoking its object-URL first. */
|
|
202
|
+
removeAttachment: (id: string) => void;
|
|
203
|
+
/** True when attaching is unavailable (disabled / at file cap). */
|
|
204
|
+
disabled: boolean;
|
|
205
|
+
/** Hidden `<input type=file>` props — the composer renders this node. */
|
|
206
|
+
inputProps: {
|
|
207
|
+
ref: React.RefObject<HTMLInputElement | null>;
|
|
208
|
+
type: 'file';
|
|
209
|
+
accept: string;
|
|
210
|
+
multiple: boolean;
|
|
211
|
+
hidden: true;
|
|
212
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
|
|
4
|
+
|
|
5
|
+
import { buildAcceptString, useClipboardPaste } from '../../Uploader';
|
|
6
|
+
import type { UseChatComposerReturn } from '../hooks/useChatComposer';
|
|
7
|
+
import { fileToAttachment, revokeAttachmentUrl } from './fileToAttachment';
|
|
8
|
+
import type {
|
|
9
|
+
ComposerAcceptType,
|
|
10
|
+
ComposerAttachConfig,
|
|
11
|
+
ComposerAttachHandle,
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
const ALL_ACCEPT: ComposerAcceptType[] = ['image', 'audio', 'video', 'document'];
|
|
15
|
+
|
|
16
|
+
/** True when a file's MIME is allowed by the accepted asset categories. */
|
|
17
|
+
function isAcceptedType(file: File, accept: ComposerAcceptType[]): boolean {
|
|
18
|
+
const mime = file.type;
|
|
19
|
+
if (mime.startsWith('image/')) return accept.includes('image');
|
|
20
|
+
if (mime.startsWith('audio/')) return accept.includes('audio');
|
|
21
|
+
if (mime.startsWith('video/')) return accept.includes('video');
|
|
22
|
+
// No / other MIME — treat as a document.
|
|
23
|
+
return accept.includes('document');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UseComposerAttachParams {
|
|
27
|
+
composer: UseChatComposerReturn;
|
|
28
|
+
config: ComposerAttachConfig;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
/** Paste listener scope. Defaults to `document` when omitted. */
|
|
31
|
+
pasteScopeRef?: RefObject<HTMLElement | null>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The unified attach pipeline. One validated path that the paperclip
|
|
36
|
+
* button, the `+` menu, drag-drop and Ctrl+V paste all funnel into:
|
|
37
|
+
*
|
|
38
|
+
* openPicker() / attachFiles(File[])
|
|
39
|
+
* → validate (size / type / count)
|
|
40
|
+
* → fileToAttachment()
|
|
41
|
+
* → composer.addAttachment()
|
|
42
|
+
*
|
|
43
|
+
* Phase 2 stops at `status:'ready'` (object-URL). The `uploadFn`
|
|
44
|
+
* lifecycle lands in phase 5 — `config.uploadFn` is accepted here but
|
|
45
|
+
* not yet consumed.
|
|
46
|
+
*
|
|
47
|
+
* Reuses `useClipboardPaste` + `buildAcceptString` from the Uploader
|
|
48
|
+
* tool rather than re-implementing clipboard / mime handling.
|
|
49
|
+
*/
|
|
50
|
+
export function useComposerAttach({
|
|
51
|
+
composer,
|
|
52
|
+
config,
|
|
53
|
+
disabled = false,
|
|
54
|
+
pasteScopeRef,
|
|
55
|
+
}: UseComposerAttachParams): ComposerAttachHandle {
|
|
56
|
+
const {
|
|
57
|
+
accept = ALL_ACCEPT,
|
|
58
|
+
maxFiles,
|
|
59
|
+
maxSizeBytes,
|
|
60
|
+
multiple = true,
|
|
61
|
+
pasteEnabled = true,
|
|
62
|
+
uploadFn,
|
|
63
|
+
onReject,
|
|
64
|
+
} = config;
|
|
65
|
+
|
|
66
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
67
|
+
|
|
68
|
+
// Stable refs so the paste listener / handlers never go stale without
|
|
69
|
+
// forcing the consumer to memoize anything.
|
|
70
|
+
const composerRef = useRef(composer);
|
|
71
|
+
composerRef.current = composer;
|
|
72
|
+
const onRejectRef = useRef(onReject);
|
|
73
|
+
onRejectRef.current = onReject;
|
|
74
|
+
const uploadFnRef = useRef(uploadFn);
|
|
75
|
+
uploadFnRef.current = uploadFn;
|
|
76
|
+
|
|
77
|
+
const acceptString = useMemo(() => {
|
|
78
|
+
// buildAcceptString expects the Uploader's AssetType — same string
|
|
79
|
+
// union as ComposerAcceptType, so the cast is safe.
|
|
80
|
+
return buildAcceptString(accept as Parameters<typeof buildAcceptString>[0]);
|
|
81
|
+
}, [accept]);
|
|
82
|
+
|
|
83
|
+
// Drive one file through the host `uploadFn`: flip the attachment to
|
|
84
|
+
// `uploading`, stream progress, then settle on `ready` (url rewritten
|
|
85
|
+
// to the remote location) or `error`. Fire-and-forget — failures are
|
|
86
|
+
// surfaced on the attachment, never thrown.
|
|
87
|
+
const runUpload = useCallback(
|
|
88
|
+
(file: File, attachmentId: string, localUrl: string) => {
|
|
89
|
+
const fn = uploadFnRef.current;
|
|
90
|
+
if (!fn) return;
|
|
91
|
+
const c = composerRef.current;
|
|
92
|
+
c.updateAttachment(attachmentId, { status: 'uploading', progress: 0 });
|
|
93
|
+
fn(file, (fraction) => {
|
|
94
|
+
composerRef.current.updateAttachment(attachmentId, {
|
|
95
|
+
progress: Math.max(0, Math.min(1, fraction)),
|
|
96
|
+
});
|
|
97
|
+
})
|
|
98
|
+
.then((result) => {
|
|
99
|
+
composerRef.current.updateAttachment(attachmentId, {
|
|
100
|
+
status: 'ready',
|
|
101
|
+
progress: 1,
|
|
102
|
+
url: result.url,
|
|
103
|
+
thumbnailUrl: result.thumbnailUrl,
|
|
104
|
+
});
|
|
105
|
+
// The remote URL replaced the object-URL — free it.
|
|
106
|
+
revokeAttachmentUrl({ url: localUrl });
|
|
107
|
+
})
|
|
108
|
+
.catch(() => {
|
|
109
|
+
composerRef.current.updateAttachment(attachmentId, { status: 'error' });
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
[],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// The single validated entry point. Drop / paste / picker all land here.
|
|
116
|
+
const attachFiles = useCallback(
|
|
117
|
+
(files: File[]) => {
|
|
118
|
+
if (disabled || files.length === 0) return;
|
|
119
|
+
const c = composerRef.current;
|
|
120
|
+
const cap = maxFiles ?? Number.POSITIVE_INFINITY;
|
|
121
|
+
|
|
122
|
+
let slots = cap - c.attachments.length;
|
|
123
|
+
for (const file of files) {
|
|
124
|
+
if (slots <= 0) {
|
|
125
|
+
onRejectRef.current?.(file, 'count');
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (maxSizeBytes != null && file.size > maxSizeBytes) {
|
|
129
|
+
onRejectRef.current?.(file, 'size');
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!isAcceptedType(file, accept)) {
|
|
133
|
+
onRejectRef.current?.(file, 'type');
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const attachment = fileToAttachment(file);
|
|
137
|
+
c.addAttachment(attachment);
|
|
138
|
+
// With an uploadFn the attachment starts local then uploads in
|
|
139
|
+
// the background; without one it stays `ready` immediately.
|
|
140
|
+
if (uploadFnRef.current) {
|
|
141
|
+
runUpload(file, attachment.id, attachment.url);
|
|
142
|
+
}
|
|
143
|
+
slots -= 1;
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[disabled, maxFiles, maxSizeBytes, accept, runUpload],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const openPicker = useCallback(() => {
|
|
150
|
+
if (disabled) return;
|
|
151
|
+
inputRef.current?.click();
|
|
152
|
+
}, [disabled]);
|
|
153
|
+
|
|
154
|
+
// Remove a staged attachment, freeing its object-URL first so a
|
|
155
|
+
// discarded blob does not leak.
|
|
156
|
+
const removeAttachment = useCallback((id: string) => {
|
|
157
|
+
const c = composerRef.current;
|
|
158
|
+
const found = c.attachments.find((a) => a.id === id);
|
|
159
|
+
if (found) revokeAttachmentUrl(found);
|
|
160
|
+
c.removeAttachment(id);
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
const onInputChange = useCallback(
|
|
164
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
165
|
+
const picked = e.target.files;
|
|
166
|
+
if (picked && picked.length > 0) attachFiles(Array.from(picked));
|
|
167
|
+
// Reset so picking the same file twice still fires `change`.
|
|
168
|
+
e.target.value = '';
|
|
169
|
+
},
|
|
170
|
+
[attachFiles],
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Ctrl+V / Cmd+V — reuse the Uploader's clipboard resolver. It already
|
|
174
|
+
// skips text fields when the clipboard carries text, so pasting an
|
|
175
|
+
// image into the composer textarea attaches without hijacking typing.
|
|
176
|
+
const pasteAccept = useMemo(
|
|
177
|
+
() => accept.filter((a) => a !== 'document'),
|
|
178
|
+
[accept],
|
|
179
|
+
);
|
|
180
|
+
useClipboardPaste(
|
|
181
|
+
{
|
|
182
|
+
enabled: pasteEnabled && !disabled,
|
|
183
|
+
acceptTypes: pasteAccept.length > 0 ? pasteAccept : undefined,
|
|
184
|
+
maxBytes: maxSizeBytes,
|
|
185
|
+
onFiles: attachFiles,
|
|
186
|
+
},
|
|
187
|
+
pasteScopeRef,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// On unmount, free any object-URLs still held by staged attachments
|
|
191
|
+
// (drafts abandoned without sending). `composerRef` gives the latest
|
|
192
|
+
// list without re-running the effect on every attach.
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
return () => {
|
|
195
|
+
for (const a of composerRef.current.attachments) {
|
|
196
|
+
revokeAttachmentUrl(a);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
const atCap =
|
|
202
|
+
maxFiles != null && composer.attachments.length >= maxFiles;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
openPicker,
|
|
206
|
+
attachFiles,
|
|
207
|
+
removeAttachment,
|
|
208
|
+
disabled: disabled || atCap,
|
|
209
|
+
inputProps: {
|
|
210
|
+
ref: inputRef,
|
|
211
|
+
type: 'file',
|
|
212
|
+
accept: acceptString,
|
|
213
|
+
multiple,
|
|
214
|
+
hidden: true,
|
|
215
|
+
onChange: onInputChange,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|