@startsimpli/ui 0.4.24 → 0.4.26

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,8 +1,15 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import { SendHorizonal } from 'lucide-react'
4
+ import { Paperclip, RotateCw, SendHorizonal, X } from 'lucide-react'
5
5
  import { cn } from '../../lib/utils'
6
+ import type { PendingAttachment } from './types'
7
+ import { formatBytes, isImageName } from './attachment-utils'
8
+ import {
9
+ useComposerUploads,
10
+ type AttachmentUploader,
11
+ type UploadedAttachmentRef,
12
+ } from './useComposerUploads'
6
13
 
7
14
  export interface ChatComposerProps {
8
15
  /** Controlled value. Omit for uncontrolled usage. */
@@ -18,12 +25,63 @@ export interface ChatComposerProps {
18
25
  submitLabel?: string
19
26
  /** Max rows the textarea grows to before scrolling. Default 10. */
20
27
  maxRows?: number
28
+ /**
29
+ * Controlled list of pending attachments rendered as chips above the textarea.
30
+ * The APP owns upload state; the composer is presentation-only here.
31
+ */
32
+ attachments?: PendingAttachment[]
33
+ /**
34
+ * Fired with the selected files from the attach button, a drag-and-drop onto
35
+ * the composer, or a clipboard paste of image/file data. Presence of this
36
+ * handler enables the attach affordances.
37
+ */
38
+ onAttachFiles?: (files: File[]) => void
39
+ /** Remove a pending attachment chip by id. */
40
+ onRemoveAttachment?: (id: string) => void
41
+ /** Retry a failed attachment chip by id (shows a retry control on error chips). */
42
+ onRetryAttachment?: (id: string) => void
43
+ /**
44
+ * SELF-MANAGED attachment mode. When provided, the composer drives the entire
45
+ * upload lifecycle itself (queued/uploading/ready/error chips, downscale,
46
+ * retry, remove + server-delete, new-deck queue-then-flush-on-`documentId`) by
47
+ * routing the attach button / drag-drop / paste files through this uploader.
48
+ * The app's `@startsimpli/api` `AttachmentsApi` structurally satisfies it.
49
+ *
50
+ * In this mode the controlled props (`attachments`/`onAttachFiles`/
51
+ * `onRemoveAttachment`/`onRetryAttachment`) are ignored — the composer owns
52
+ * the chip state. `onSubmit` still carries text only.
53
+ */
54
+ uploader?: AttachmentUploader
55
+ /**
56
+ * Document the uploads land on (self-managed mode). May be absent/'new' for a
57
+ * brand-new document: files are held client-side and auto-upload the moment a
58
+ * real id arrives.
59
+ */
60
+ documentId?: string
61
+ /** Upload purpose tag for self-managed uploads. Default 'reference'. */
62
+ attachmentPurpose?: string
63
+ /** Self-managed mode: fired with the uploaded attachment refs when they change. */
64
+ onAttachmentsChange?: (refs: UploadedAttachmentRef[]) => void
21
65
  className?: string
22
66
  }
23
67
 
24
68
  /**
25
69
  * Chat input with auto-growing textarea. Enter submits, Shift+Enter inserts a
26
70
  * newline, and large pastes are accepted as-is. Controlled or uncontrolled.
71
+ *
72
+ * Reference attachments are supported two ways (submit always carries text only):
73
+ *
74
+ * - SELF-MANAGED (recommended, one line): pass an `uploader` (+ `documentId`).
75
+ * The composer drives the full upload lifecycle internally via
76
+ * `useComposerUploads` — downscale, progress, retry, remove + server-delete,
77
+ * and new-deck queue-then-flush — routing attach button / drag-drop / paste
78
+ * files through the uploader. The app wires nothing else.
79
+ *
80
+ * - CONTROLLED (legacy): the app owns upload state and feeds `attachments`
81
+ * down; the composer renders chips and surfaces new files via `onAttachFiles`
82
+ * (`onRemoveAttachment`/`onRetryAttachment` for chip actions).
83
+ *
84
+ * Providing `uploader` switches to self-managed and ignores the controlled props.
27
85
  */
28
86
  export function ChatComposer({
29
87
  value,
@@ -34,12 +92,49 @@ export function ChatComposer({
34
92
  busy = false,
35
93
  submitLabel = 'Send',
36
94
  maxRows = 10,
95
+ attachments,
96
+ onAttachFiles,
97
+ onRemoveAttachment,
98
+ onRetryAttachment,
99
+ uploader,
100
+ documentId,
101
+ attachmentPurpose,
102
+ onAttachmentsChange,
37
103
  className,
38
104
  }: ChatComposerProps) {
39
105
  const isControlled = value !== undefined
40
106
  const [internal, setInternal] = React.useState('')
41
107
  const text = isControlled ? value! : internal
42
108
  const textareaRef = React.useRef<HTMLTextAreaElement>(null)
109
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
110
+ const [dragOver, setDragOver] = React.useState(false)
111
+
112
+ // SELF-MANAGED mode: when an `uploader` is supplied, the composer owns the
113
+ // upload state and feeds its own chips/handlers in place of the controlled
114
+ // props. The hook runs unconditionally (Rules of Hooks); a no-op uploader
115
+ // keeps it inert when in controlled mode.
116
+ const selfManaged = !!uploader
117
+ const noopUploader = React.useRef<AttachmentUploader>({
118
+ async uploadAttachment() {
119
+ return { id: '', fileUrl: null }
120
+ },
121
+ async deleteAttachment() {
122
+ /* noop */
123
+ },
124
+ })
125
+ const uploads = useComposerUploads(uploader ?? noopUploader.current, documentId, {
126
+ purpose: attachmentPurpose,
127
+ onAttachmentsChange,
128
+ })
129
+
130
+ // Resolve effective chips/handlers from whichever mode is active.
131
+ const effectiveAttachments = selfManaged ? uploads.attachments : attachments
132
+ const effectiveAttachFiles = selfManaged ? uploads.attachFiles : onAttachFiles
133
+ const effectiveRemove = selfManaged ? uploads.removeAttachment : onRemoveAttachment
134
+ const effectiveRetry = selfManaged ? uploads.retryAttachment : onRetryAttachment
135
+
136
+ const attachEnabled = !!effectiveAttachFiles && !disabled
137
+ const chips = effectiveAttachments ?? []
43
138
 
44
139
  const setText = (next: string) => {
45
140
  if (!isControlled) setInternal(next)
@@ -61,6 +156,11 @@ export function ChatComposer({
61
156
  if (!canSubmit) return
62
157
  onSubmit(text.trim())
63
158
  if (!isControlled) setInternal('')
159
+ // Self-managed chips have been (or are being) uploaded to the document; the
160
+ // consumer reads them off the server on its turn, so clear them from the
161
+ // composer once the message goes out. (Controlled mode leaves chip lifecycle
162
+ // entirely to the app.)
163
+ if (selfManaged) uploads.clear()
64
164
  }
65
165
 
66
166
  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -80,34 +180,214 @@ export function ChatComposer({
80
180
  submit()
81
181
  }
82
182
 
183
+ const emitFiles = (list: FileList | null | undefined) => {
184
+ if (!attachEnabled || !list || list.length === 0) return
185
+ effectiveAttachFiles!(Array.from(list))
186
+ }
187
+
188
+ const onPickClick = () => fileInputRef.current?.click()
189
+
190
+ const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
191
+ emitFiles(e.target.files)
192
+ // reset so picking the same file again re-fires change
193
+ e.target.value = ''
194
+ }
195
+
196
+ const onDrop = (e: React.DragEvent) => {
197
+ if (!attachEnabled) return
198
+ const files = e.dataTransfer?.files
199
+ if (files && files.length > 0) {
200
+ e.preventDefault()
201
+ setDragOver(false)
202
+ emitFiles(files)
203
+ }
204
+ }
205
+
206
+ const onDragOver = (e: React.DragEvent) => {
207
+ if (!attachEnabled) return
208
+ // Only treat as a file drag (not text selection drags).
209
+ if (Array.from(e.dataTransfer?.types ?? []).includes('Files')) {
210
+ e.preventDefault()
211
+ setDragOver(true)
212
+ }
213
+ }
214
+
215
+ const onDragLeave = () => setDragOver(false)
216
+
217
+ const onPaste = (e: React.ClipboardEvent) => {
218
+ if (!attachEnabled) return
219
+ const files = e.clipboardData?.files
220
+ if (files && files.length > 0) {
221
+ // Pasted image/file data — capture it as an attachment, not text.
222
+ e.preventDefault()
223
+ emitFiles(files)
224
+ }
225
+ }
226
+
83
227
  return (
84
- <div className={cn('flex items-end gap-2 border-t border-border bg-background p-3', className)}>
85
- <textarea
86
- ref={textareaRef}
87
- value={text}
88
- onChange={(e) => setText(e.target.value)}
89
- onKeyDown={onKeyDown}
90
- placeholder={placeholder}
91
- disabled={disabled}
92
- rows={1}
93
- className={cn(
94
- 'flex-1 resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm',
95
- 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
96
- 'disabled:cursor-not-allowed disabled:opacity-50'
228
+ <div
229
+ className={cn(
230
+ 'flex flex-col gap-2 border-t border-border bg-background p-3',
231
+ dragOver && 'ring-2 ring-inset ring-ring',
232
+ className
233
+ )}
234
+ onDrop={onDrop}
235
+ onDragOver={onDragOver}
236
+ onDragLeave={onDragLeave}
237
+ >
238
+ {chips.length > 0 && (
239
+ <ul className="flex flex-wrap gap-2" data-testid="chat-attachment-chips">
240
+ {chips.map((a) => (
241
+ <AttachmentChip
242
+ key={a.id}
243
+ attachment={a}
244
+ onRemove={effectiveRemove}
245
+ onRetry={effectiveRetry}
246
+ />
247
+ ))}
248
+ </ul>
249
+ )}
250
+ <div className="flex items-end gap-2">
251
+ {attachEnabled && (
252
+ <>
253
+ <input
254
+ ref={fileInputRef}
255
+ type="file"
256
+ multiple
257
+ className="sr-only"
258
+ data-testid="chat-attach-input"
259
+ onChange={onInputChange}
260
+ />
261
+ <button
262
+ type="button"
263
+ onClick={onPickClick}
264
+ aria-label="Attach files"
265
+ className={cn(
266
+ 'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input bg-background text-muted-foreground shadow-sm transition-colors',
267
+ 'hover:bg-accent hover:text-foreground'
268
+ )}
269
+ >
270
+ <Paperclip className="h-4 w-4" aria-hidden="true" />
271
+ </button>
272
+ </>
97
273
  )}
98
- />
99
- <button
100
- type="button"
101
- onClick={submit}
102
- disabled={!canSubmit}
103
- aria-label={submitLabel}
104
- className={cn(
105
- 'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-opacity',
106
- 'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40'
107
- )}
108
- >
109
- <SendHorizonal className="h-4 w-4" aria-hidden="true" />
110
- </button>
274
+ <textarea
275
+ ref={textareaRef}
276
+ value={text}
277
+ onChange={(e) => setText(e.target.value)}
278
+ onKeyDown={onKeyDown}
279
+ onPaste={attachEnabled ? onPaste : undefined}
280
+ placeholder={placeholder}
281
+ disabled={disabled}
282
+ rows={1}
283
+ className={cn(
284
+ 'flex-1 resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm',
285
+ 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
286
+ 'disabled:cursor-not-allowed disabled:opacity-50'
287
+ )}
288
+ />
289
+ <button
290
+ type="button"
291
+ onClick={submit}
292
+ disabled={!canSubmit}
293
+ aria-label={submitLabel}
294
+ className={cn(
295
+ 'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-opacity',
296
+ 'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40'
297
+ )}
298
+ >
299
+ <SendHorizonal className="h-4 w-4" aria-hidden="true" />
300
+ </button>
301
+ </div>
111
302
  </div>
112
303
  )
113
304
  }
305
+
306
+ function AttachmentChip({
307
+ attachment,
308
+ onRemove,
309
+ onRetry,
310
+ }: {
311
+ attachment: PendingAttachment
312
+ onRemove?: (id: string) => void
313
+ onRetry?: (id: string) => void
314
+ }) {
315
+ const { id, name, status, progress, thumbnailUrl, sizeBytes, error } = attachment
316
+ const isImage = !!thumbnailUrl && isImageName(name)
317
+ const pct = Math.round((progress ?? 0) * 100)
318
+
319
+ return (
320
+ <li
321
+ data-testid={`chat-attachment-${id}`}
322
+ data-status={status}
323
+ className={cn(
324
+ 'group relative flex items-center gap-2 rounded-md border border-border bg-muted/40 py-1 pl-1 pr-2 text-xs',
325
+ status === 'error' && 'border-red-300 bg-red-50 text-red-700'
326
+ )}
327
+ >
328
+ {isImage ? (
329
+ // eslint-disable-next-line @next/next/no-img-element
330
+ <img
331
+ src={thumbnailUrl}
332
+ alt={name}
333
+ className="h-8 w-8 shrink-0 rounded object-cover"
334
+ />
335
+ ) : (
336
+ <span
337
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground"
338
+ aria-hidden="true"
339
+ >
340
+ <Paperclip className="h-4 w-4" />
341
+ </span>
342
+ )}
343
+ <span className="flex min-w-0 flex-col">
344
+ <span className="max-w-[10rem] truncate font-medium" title={name}>
345
+ {name}
346
+ </span>
347
+ <span className="text-[10px] text-muted-foreground">
348
+ {status === 'uploading'
349
+ ? `Uploading… ${pct}%`
350
+ : status === 'error'
351
+ ? (error || 'Upload failed')
352
+ : sizeBytes != null
353
+ ? formatBytes(sizeBytes)
354
+ : null}
355
+ </span>
356
+ </span>
357
+ {status === 'error' && onRetry && (
358
+ <button
359
+ type="button"
360
+ onClick={() => onRetry(id)}
361
+ aria-label={`Retry ${name}`}
362
+ className="ml-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-red-600 hover:bg-red-100"
363
+ >
364
+ <RotateCw className="h-3 w-3" aria-hidden="true" />
365
+ </button>
366
+ )}
367
+ {status === 'uploading' && (
368
+ <span
369
+ role="progressbar"
370
+ aria-valuemin={0}
371
+ aria-valuemax={100}
372
+ aria-valuenow={pct}
373
+ className="absolute inset-x-1 bottom-0 h-1 overflow-hidden rounded-full bg-muted"
374
+ >
375
+ <span
376
+ className="block h-full bg-primary transition-[width]"
377
+ style={{ width: `${pct}%` }}
378
+ />
379
+ </span>
380
+ )}
381
+ {onRemove && (
382
+ <button
383
+ type="button"
384
+ onClick={() => onRemove(id)}
385
+ aria-label={`Remove ${name}`}
386
+ className="ml-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground"
387
+ >
388
+ <X className="h-3 w-3" aria-hidden="true" />
389
+ </button>
390
+ )}
391
+ </li>
392
+ )
393
+ }
@@ -1,9 +1,10 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import { Wrench } from 'lucide-react'
4
+ import { Paperclip, Wrench } from 'lucide-react'
5
5
  import { cn } from '../../lib/utils'
6
- import type { ChatMessageData } from './types'
6
+ import type { ChatMessageAttachment, ChatMessageData } from './types'
7
+ import { isImageName } from './attachment-utils'
7
8
 
8
9
  export interface ChatMessageProps {
9
10
  message: ChatMessageData
@@ -27,8 +28,58 @@ function StreamingDots() {
27
28
  * assistant left); `tool` / `status` kinds render as compact centered system
28
29
  * rows. Fully generic — no presentation-specific knowledge.
29
30
  */
31
+ /** Attachment chips/thumbnails rendered on a message bubble. */
32
+ function MessageAttachments({
33
+ attachments,
34
+ isUser,
35
+ }: {
36
+ attachments: ChatMessageAttachment[]
37
+ isUser: boolean
38
+ }) {
39
+ return (
40
+ <ul className="mt-2 flex flex-wrap gap-2" data-testid="chat-message-attachments">
41
+ {attachments.map((a) => {
42
+ const isImage = !!a.fileUrl && isImageName(a.contentType || a.filename)
43
+ const chip = (
44
+ <span
45
+ className={cn(
46
+ 'flex items-center gap-1.5 rounded-md border px-1.5 py-1 text-xs',
47
+ isUser
48
+ ? 'border-primary-foreground/30 bg-primary-foreground/10'
49
+ : 'border-border bg-background/60'
50
+ )}
51
+ >
52
+ <Paperclip className="h-3 w-3 shrink-0" aria-hidden="true" />
53
+ <span className="max-w-[10rem] truncate" title={a.filename}>
54
+ {a.filename}
55
+ </span>
56
+ </span>
57
+ )
58
+ return (
59
+ <li key={a.id} data-testid={`chat-message-attachment-${a.id}`}>
60
+ {isImage ? (
61
+ // eslint-disable-next-line @next/next/no-img-element
62
+ <img
63
+ src={a.fileUrl!}
64
+ alt={a.filename}
65
+ className="h-16 w-16 rounded-md border border-border object-cover"
66
+ />
67
+ ) : a.fileUrl ? (
68
+ <a href={a.fileUrl} target="_blank" rel="noreferrer" className="hover:opacity-80">
69
+ {chip}
70
+ </a>
71
+ ) : (
72
+ chip
73
+ )}
74
+ </li>
75
+ )
76
+ })}
77
+ </ul>
78
+ )
79
+ }
80
+
30
81
  export function ChatMessage({ message, renderContent, className }: ChatMessageProps) {
31
- const { role, content, status, kind = 'text', toolName } = message
82
+ const { role, content, status, kind = 'text', toolName, attachments } = message
32
83
  const isStreaming = status === 'streaming'
33
84
  const isError = status === 'error'
34
85
 
@@ -75,6 +126,9 @@ export function ChatMessage({ message, renderContent, className }: ChatMessagePr
75
126
  >
76
127
  {body}
77
128
  {isStreaming && <StreamingDots />}
129
+ {attachments && attachments.length > 0 && (
130
+ <MessageAttachments attachments={attachments} isUser={isUser} />
131
+ )}
78
132
  </div>
79
133
  </div>
80
134
  )
@@ -14,9 +14,32 @@ export interface ChatThreadProps {
14
14
  /** Auto-scroll to the newest message on change. Default true. */
15
15
  autoScroll?: boolean
16
16
  renderContent?: (content: string, message: ChatMessageData) => React.ReactNode
17
+ /** Show an animated "working" indicator at the end (the agent is processing). */
18
+ busy?: boolean
19
+ /** Label shown next to the working indicator. Default "Working…". */
20
+ busyLabel?: string
17
21
  className?: string
18
22
  }
19
23
 
24
+ /** Animated three-dot "agent is working" row, aligned like an assistant message. */
25
+ function WorkingIndicator({ label = 'Working…' }: { label?: string }) {
26
+ return (
27
+ <div
28
+ className="flex items-center gap-2 px-1 text-sm text-muted-foreground"
29
+ role="status"
30
+ aria-live="polite"
31
+ data-testid="chat-working-indicator"
32
+ >
33
+ <span className="flex gap-1" aria-hidden="true">
34
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
35
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
36
+ <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" />
37
+ </span>
38
+ {label}
39
+ </div>
40
+ )
41
+ }
42
+
20
43
  /**
21
44
  * Scrollable, auto-scrolling list of chat messages. App-agnostic.
22
45
  */
@@ -26,15 +49,18 @@ export function ChatThread({
26
49
  renderMessage,
27
50
  autoScroll = true,
28
51
  renderContent,
52
+ busy = false,
53
+ busyLabel,
29
54
  className,
30
55
  }: ChatThreadProps) {
31
56
  const anchorRef = React.useRef<HTMLDivElement>(null)
32
57
 
33
58
  React.useEffect(() => {
34
59
  if (autoScroll) anchorRef.current?.scrollIntoView({ block: 'end' })
35
- }, [messages, autoScroll])
60
+ }, [messages, busy, autoScroll])
36
61
 
37
- if (messages.length === 0 && emptyState) {
62
+ // Empty thread + not working → the caller's empty state.
63
+ if (messages.length === 0 && !busy && emptyState) {
38
64
  return (
39
65
  <div className={cn('flex h-full items-center justify-center p-6 text-center', className)}>
40
66
  {emptyState}
@@ -51,6 +77,7 @@ export function ChatThread({
51
77
  <ChatMessage key={m.id} message={m} renderContent={renderContent} />
52
78
  )
53
79
  )}
80
+ {busy && <WorkingIndicator label={busyLabel} />}
54
81
  <div ref={anchorRef} data-testid="chat-thread-anchor" />
55
82
  </div>
56
83
  )
@@ -0,0 +1,24 @@
1
+ /** Small presentation helpers shared by the chat attachment chips/thumbnails. */
2
+
3
+ /** Human-readable byte size, e.g. 2048 -> "2 KB", 1536000 -> "1.5 MB". */
4
+ export function formatBytes(bytes: number): string {
5
+ if (!Number.isFinite(bytes) || bytes < 0) return ''
6
+ if (bytes < 1024) return `${bytes} B`
7
+ const units = ['KB', 'MB', 'GB', 'TB']
8
+ let value = bytes / 1024
9
+ let i = 0
10
+ while (value >= 1024 && i < units.length - 1) {
11
+ value /= 1024
12
+ i++
13
+ }
14
+ const rounded = value >= 10 || Number.isInteger(value) ? Math.round(value) : Math.round(value * 10) / 10
15
+ return `${rounded} ${units[i]}`
16
+ }
17
+
18
+ const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|bmp|svg|heic|heif)$/i
19
+
20
+ /** Whether a filename or content-type looks like a previewable image. */
21
+ export function isImageName(nameOrType: string | undefined | null): boolean {
22
+ if (!nameOrType) return false
23
+ return nameOrType.startsWith('image/') || IMAGE_EXT.test(nameOrType)
24
+ }
@@ -0,0 +1,28 @@
1
+ import { downscaleImage } from './image-downscale'
2
+
3
+ function file(name: string, type: string, bytes = 100) {
4
+ return new File([new Uint8Array(bytes)], name, { type })
5
+ }
6
+
7
+ describe('downscaleImage (best-effort, jsdom has no canvas/createImageBitmap)', () => {
8
+ it('returns non-image files untouched', async () => {
9
+ const f = file('deck.pdf', 'application/pdf')
10
+ expect(await downscaleImage(f)).toBe(f)
11
+ })
12
+
13
+ it('returns GIFs untouched (may be animated)', async () => {
14
+ const f = file('anim.gif', 'image/gif')
15
+ expect(await downscaleImage(f)).toBe(f)
16
+ })
17
+
18
+ it('returns SVGs untouched (vector)', async () => {
19
+ const f = file('logo.svg', 'image/svg+xml')
20
+ expect(await downscaleImage(f)).toBe(f)
21
+ })
22
+
23
+ it('falls back to the original image when the environment cannot rasterize', async () => {
24
+ // In jsdom createImageBitmap is undefined → best-effort returns original.
25
+ const f = file('photo.png', 'image/png', 5_000_000)
26
+ expect(await downscaleImage(f)).toBe(f)
27
+ })
28
+ })
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Best-effort client-side image downscale for uploads.
3
+ *
4
+ * Reference images (e.g. an old deck slide a user drops in to clone the look)
5
+ * are often multi-MB originals, but the backend reference-extraction normalizes
6
+ * everything to ~1536px anyway — so uploading full-res is wasteful, slow, and
7
+ * (on a constrained proxy) can hang. We downscale in the browser first.
8
+ *
9
+ * ALWAYS best-effort: any failure (no DOM, unsupported codec, decode error)
10
+ * returns the ORIGINAL file untouched, so this can never block an upload. Only
11
+ * raster images above the threshold are touched; non-images, GIFs (possibly
12
+ * animated), SVGs, and already-small images pass through verbatim.
13
+ */
14
+
15
+ export interface DownscaleOptions {
16
+ /** Max width/height of the output (longest side). Default 1536 (model limit). */
17
+ maxDim?: number
18
+ /** JPEG quality 0..1 for the re-encoded output. Default 0.82. */
19
+ quality?: number
20
+ /** Skip downscale for files at/under this size when also within maxDim. Default 1.5MB. */
21
+ sizeThresholdBytes?: number
22
+ }
23
+
24
+ const DEFAULTS = { maxDim: 1536, quality: 0.82, sizeThresholdBytes: 1_500_000 }
25
+
26
+ /** True for raster image types we can safely re-encode (excludes gif/svg). */
27
+ function isDownscalableImage(file: File): boolean {
28
+ return (
29
+ file.type.startsWith('image/') &&
30
+ file.type !== 'image/gif' &&
31
+ file.type !== 'image/svg+xml'
32
+ )
33
+ }
34
+
35
+ export async function downscaleImage(file: File, options: DownscaleOptions = {}): Promise<File> {
36
+ const { maxDim, quality, sizeThresholdBytes } = { ...DEFAULTS, ...options }
37
+
38
+ // Bail to the original whenever we can't (or needn't) do better.
39
+ if (
40
+ typeof document === 'undefined' ||
41
+ typeof createImageBitmap === 'undefined' ||
42
+ !isDownscalableImage(file)
43
+ ) {
44
+ return file
45
+ }
46
+
47
+ try {
48
+ const bitmap = await createImageBitmap(file)
49
+ const longest = Math.max(bitmap.width, bitmap.height)
50
+ const scale = Math.min(1, maxDim / longest)
51
+
52
+ // Already within bounds and small enough — keep the original (preserves
53
+ // format/quality; e.g. a crisp PNG screenshot under the threshold).
54
+ if (scale >= 1 && file.size <= sizeThresholdBytes) {
55
+ bitmap.close?.()
56
+ return file
57
+ }
58
+
59
+ const w = Math.max(1, Math.round(bitmap.width * scale))
60
+ const h = Math.max(1, Math.round(bitmap.height * scale))
61
+ const canvas = document.createElement('canvas')
62
+ canvas.width = w
63
+ canvas.height = h
64
+ const ctx = canvas.getContext('2d')
65
+ if (!ctx) {
66
+ bitmap.close?.()
67
+ return file
68
+ }
69
+ // Flatten onto white so transparency doesn't become black in JPEG.
70
+ ctx.fillStyle = '#ffffff'
71
+ ctx.fillRect(0, 0, w, h)
72
+ ctx.drawImage(bitmap, 0, 0, w, h)
73
+ bitmap.close?.()
74
+
75
+ const blob = await new Promise<Blob | null>((resolve) =>
76
+ canvas.toBlob(resolve, 'image/jpeg', quality)
77
+ )
78
+ if (!blob || blob.size >= file.size) return file // no win → keep original
79
+
80
+ const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
81
+ return new File([blob], `${baseName}.jpg`, {
82
+ type: 'image/jpeg',
83
+ lastModified: file.lastModified,
84
+ })
85
+ } catch {
86
+ return file
87
+ }
88
+ }