@chai-ui/react 0.6.0 → 0.7.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.
Files changed (47) hide show
  1. package/DESIGN.md +19 -3
  2. package/dist/ChaiProvider.d.ts +3 -42
  3. package/dist/ChaiProvider.d.ts.map +1 -1
  4. package/dist/ChaiProvider.js +5 -43
  5. package/dist/ChaiProvider.js.map +1 -1
  6. package/dist/Composer.d.ts +4 -59
  7. package/dist/Composer.d.ts.map +1 -1
  8. package/dist/Composer.js +50 -169
  9. package/dist/Composer.js.map +1 -1
  10. package/dist/EditCard.d.ts +2 -16
  11. package/dist/EditCard.d.ts.map +1 -1
  12. package/dist/EditCard.js +16 -115
  13. package/dist/EditCard.js.map +1 -1
  14. package/dist/MediaAnalyzer.d.ts +3 -49
  15. package/dist/MediaAnalyzer.d.ts.map +1 -1
  16. package/dist/MediaAnalyzer.js +13 -141
  17. package/dist/MediaAnalyzer.js.map +1 -1
  18. package/dist/ResultCard.d.ts +13 -0
  19. package/dist/ResultCard.d.ts.map +1 -1
  20. package/dist/ResultCard.js +2 -48
  21. package/dist/ResultCard.js.map +1 -1
  22. package/dist/useComposer.d.ts +11 -86
  23. package/dist/useComposer.d.ts.map +1 -1
  24. package/dist/useComposer.js +26 -291
  25. package/dist/useComposer.js.map +1 -1
  26. package/dist/useMediaAnalyzer.d.ts +8 -28
  27. package/dist/useMediaAnalyzer.d.ts.map +1 -1
  28. package/dist/useMediaAnalyzer.js +9 -106
  29. package/dist/useMediaAnalyzer.js.map +1 -1
  30. package/package.json +3 -3
  31. package/src/ChaiProvider.tsx +12 -70
  32. package/src/Composer.tsx +73 -258
  33. package/src/EditCard.tsx +46 -139
  34. package/src/MediaAnalyzer.tsx +31 -176
  35. package/src/ResultCard.tsx +13 -38
  36. package/src/useComposer.ts +33 -411
  37. package/src/useMediaAnalyzer.ts +15 -146
  38. package/dist/media-actions.d.ts +0 -18
  39. package/dist/media-actions.d.ts.map +0 -1
  40. package/dist/media-actions.js +0 -56
  41. package/dist/media-actions.js.map +0 -1
  42. package/dist/regions.d.ts +0 -3
  43. package/dist/regions.d.ts.map +0 -1
  44. package/dist/regions.js +0 -7
  45. package/dist/regions.js.map +0 -1
  46. package/src/media-actions.ts +0 -54
  47. package/src/regions.ts +0 -7
package/src/EditCard.tsx CHANGED
@@ -1,5 +1,31 @@
1
1
  import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
2
- import { DEFAULT_MAX_REGIONS, type EditRegion, type RegionBox, type ResultStatus } from "@chai-ui/core";
2
+ import {
3
+ clampPan,
4
+ DEFAULT_MAX_REGIONS,
5
+ downloadFilename,
6
+ downloadMedia,
7
+ EDIT_ZOOM_STEPS,
8
+ editCardWidth,
9
+ fitBox,
10
+ freeRegionNumber,
11
+ newRegion,
12
+ panForKey,
13
+ panForZoom,
14
+ REGION_CORNERS,
15
+ regionBoxForKey,
16
+ regionBoxStyle,
17
+ regionPopoverPosition,
18
+ resizeBox,
19
+ shareMedia,
20
+ type EditRegion,
21
+ type EditVersion,
22
+ type Pan,
23
+ type RegionBox,
24
+ type RegionCorner,
25
+ type Size,
26
+ } from "@chai-ui/core";
27
+
28
+ export type { EditVersion } from "@chai-ui/core";
3
29
  import { Pagination } from "./primitives/Pagination.js";
4
30
  import {
5
31
  AddIcon,
@@ -14,25 +40,7 @@ import {
14
40
  ZoomInIcon,
15
41
  ZoomOutIcon,
16
42
  } from "./icons.js";
17
- import { downloadFilename, downloadMedia, shareMedia } from "./media-actions.js";
18
43
  import { PageSlide, usePageSlide } from "./page-slide.js";
19
- import { regionColor } from "./regions.js";
20
-
21
- /**
22
- * One version of the image being edited: the original, or the result of an
23
- * edit. `useComposer`'s `edit.versions` builds these from its runs.
24
- */
25
- export interface EditVersion {
26
- id: string;
27
- /** The image, once there is one. */
28
- src?: string;
29
- /** The image this version was edited from, shown under the progress spinner until `src` arrives. */
30
- from?: string;
31
- status: ResultStatus;
32
- /** The regions this version was made with (none for the original). Shown faintly, for reference. */
33
- regions: EditRegion[];
34
- error?: string;
35
- }
36
44
 
37
45
  export interface EditCardProps {
38
46
  /** The original image first, then each edit. Paged with the dots in the card's bottom row. */
@@ -65,60 +73,12 @@ export interface EditCardProps {
65
73
  disabled?: boolean;
66
74
  }
67
75
 
68
- const ZOOM_STEPS = [1, 1.25, 1.5, 2, 3, 4];
69
- const MIN_SIZE = 0.03;
70
- const KEY_STEP = 0.01;
71
- const KEY_STEP_LARGE = 0.05;
72
-
73
- type Size = { width: number; height: number };
74
- type Pan = { x: number; y: number };
75
76
  type Drag =
76
- | { kind: "move" | "nw" | "ne" | "sw" | "se"; id: string; x: number; y: number; box: RegionBox; moved: boolean }
77
+ | { kind: "move" | RegionCorner; id: string; x: number; y: number; box: RegionBox; moved: boolean }
77
78
  | { kind: "pan"; x: number; y: number; pan: Pan; moved: boolean };
78
79
 
79
80
  const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
80
81
 
81
- /** Keeps a box inside the image and at least the minimum size. */
82
- function fitBox(box: RegionBox): RegionBox {
83
- const width = clamp(box.width, MIN_SIZE, 1);
84
- const height = clamp(box.height, MIN_SIZE, 1);
85
- return { x: clamp(box.x, 0, 1 - width), y: clamp(box.y, 0, 1 - height), width, height };
86
- }
87
-
88
- /** A box resized by dragging one corner; the opposite corner stays put. */
89
- function resizeBox(box: RegionBox, corner: "nw" | "ne" | "sw" | "se", dx: number, dy: number): RegionBox {
90
- let left = box.x;
91
- let top = box.y;
92
- let right = box.x + box.width;
93
- let bottom = box.y + box.height;
94
- if (corner === "nw" || corner === "sw") left = clamp(left + dx, 0, right - MIN_SIZE);
95
- else right = clamp(right + dx, left + MIN_SIZE, 1);
96
- if (corner === "nw" || corner === "ne") top = clamp(top + dy, 0, bottom - MIN_SIZE);
97
- else bottom = clamp(bottom + dy, top + MIN_SIZE, 1);
98
- return { x: left, y: top, width: right - left, height: bottom - top };
99
- }
100
-
101
- /** Pan limits at a zoom level: the image always covers the viewport. */
102
- function clampPan(pan: Pan, zoom: number, size: Size): Pan {
103
- return {
104
- x: clamp(pan.x, size.width - size.width * zoom, 0),
105
- y: clamp(pan.y, size.height - size.height * zoom, 0),
106
- };
107
- }
108
-
109
- function nextRegionId(): string {
110
- return typeof crypto !== "undefined" && "randomUUID" in crypto
111
- ? crypto.randomUUID()
112
- : `region-${Date.now()}-${Math.random().toString(36).slice(2)}`;
113
- }
114
-
115
- /** The lowest number no current region uses, so freed numbers (and colors) come back first. */
116
- function freeNumber(regions: EditRegion[]): number {
117
- let n = 1;
118
- while (regions.some((r) => r.number === n)) n += 1;
119
- return n;
120
- }
121
-
122
82
  /**
123
83
  * The image editing card: the person marks regions on the image, gives each
124
84
  * one an instruction, and checks it into the Composer. It shares
@@ -198,7 +158,7 @@ export function EditCard({
198
158
  // A region being drawn takes a free number, even if the regions changed
199
159
  // since it was started (so checking it in can't duplicate a number).
200
160
  const shownDraft =
201
- draft && regions.some((r) => r.number === draft.number) ? { ...draft, number: freeNumber(regions) } : draft;
161
+ draft && regions.some((r) => r.number === draft.number) ? { ...draft, number: freeRegionNumber(regions) } : draft;
202
162
  const all = shownDraft ? [...regions, shownDraft] : regions;
203
163
  const selected = all.find((r) => r.id === selectedId) ?? null;
204
164
  const isDraft = selected != null && selected.id === draft?.id;
@@ -230,13 +190,7 @@ export function EditCard({
230
190
  }
231
191
 
232
192
  function addRegion() {
233
- // Centered in the part of the image in view, so it's visible when zoomed in.
234
- const viewW = 1 / zoom;
235
- const viewH = 1 / zoom;
236
- const centerX = size.width ? (-pan.x / (size.width * zoom)) + viewW / 2 : 0.5;
237
- const centerY = size.height ? (-pan.y / (size.height * zoom)) + viewH / 2 : 0.5;
238
- const box = fitBox({ x: centerX - (0.3 * viewW) / 2, y: centerY - (0.2 * viewH) / 2, width: 0.3 * viewW, height: 0.2 * viewH });
239
- const region: EditRegion = { id: nextRegionId(), number: freeNumber(regions), box, prompt: "" };
193
+ const region = newRegion(regions, zoom, pan, size);
240
194
  setDraft(region);
241
195
  setHidden(false);
242
196
  select(region);
@@ -268,14 +222,10 @@ export function EditCard({
268
222
  }
269
223
 
270
224
  function zoomTo(next: number) {
271
- // Zooms around the middle of the view, so what's centered stays centered.
272
- const cx = size.width / 2;
273
- const cy = size.height / 2;
274
- const ratio = next / zoom;
275
- setPan(clampPan({ x: cx - (cx - pan.x) * ratio, y: cy - (cy - pan.y) * ratio }, next, size));
225
+ setPan(panForZoom(pan, zoom, next, size));
276
226
  setZoom(next);
277
227
  }
278
- const zoomIndex = ZOOM_STEPS.indexOf(zoom);
228
+ const zoomIndex = EDIT_ZOOM_STEPS.indexOf(zoom);
279
229
 
280
230
  // --- Pointer: move and resize regions, pan the zoomed image ---
281
231
  // Moves and releases bubble to the viewport, even while a region or
@@ -320,19 +270,11 @@ export function EditCard({
320
270
  // --- Keyboard ---
321
271
 
322
272
  function handleRegionKey(e: KeyboardEvent, region: EditRegion) {
323
- const step = e.altKey ? KEY_STEP_LARGE : KEY_STEP;
324
- const arrows: Record<string, [number, number]> = {
325
- ArrowLeft: [-step, 0],
326
- ArrowRight: [step, 0],
327
- ArrowUp: [0, -step],
328
- ArrowDown: [0, step],
329
- };
330
- const delta = arrows[e.key];
331
- if (delta) {
273
+ const box = regionBoxForKey(region.box, e.key, { shift: e.shiftKey, alt: e.altKey });
274
+ if (box) {
332
275
  e.preventDefault();
333
276
  e.stopPropagation();
334
- const [dx, dy] = delta;
335
- setBox(region.id, e.shiftKey ? resizeBox(region.box, "se", dx, dy) : fitBox({ ...region.box, x: region.box.x + dx, y: region.box.y + dy }));
277
+ setBox(region.id, box);
336
278
  } else if (e.key === "Delete" || e.key === "Backspace") {
337
279
  e.preventDefault();
338
280
  if (region.id === draft?.id) setDraft(null);
@@ -343,12 +285,10 @@ export function EditCard({
343
285
 
344
286
  function handleViewportKey(e: KeyboardEvent<HTMLDivElement>) {
345
287
  if (e.target !== e.currentTarget || zoom <= 1) return;
346
- const step = 40;
347
- const arrows: Record<string, [number, number]> = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };
348
- const delta = arrows[e.key];
349
- if (!delta) return;
288
+ const next = panForKey(pan, e.key, zoom, size);
289
+ if (!next) return;
350
290
  e.preventDefault();
351
- setPan(clampPan({ x: pan.x + delta[0], y: pan.y + delta[1] }, zoom, size));
291
+ setPan(next);
352
292
  }
353
293
 
354
294
  function handleFieldKey(e: KeyboardEvent<HTMLTextAreaElement>) {
@@ -383,31 +323,11 @@ export function EditCard({
383
323
  // --- Layout ---
384
324
 
385
325
  // The field sits above its region, or below it when there isn't room above.
386
- const popover = selected && !hidden && size.width
387
- ? (() => {
388
- const left = selected.box.x * size.width * zoom + pan.x;
389
- const top = selected.box.y * size.height * zoom + pan.y;
390
- const bottom = (selected.box.y + selected.box.height) * size.height * zoom + pan.y;
391
- // Below only when it clears the controls along the image's bottom edge.
392
- const reserved = versions.length > 1 ? 100 : 56;
393
- const fitsBelow = bottom + 8 + POPOVER_HEIGHT <= size.height - reserved;
394
- const above = top > POPOVER_HEIGHT + 16 || !fitsBelow;
395
- return {
396
- left: clamp(left, 8, Math.max(8, size.width - POPOVER_WIDTH - 8)),
397
- top: above ? Math.max(top - 8, POPOVER_HEIGHT + 8) : bottom + 8,
398
- above,
399
- };
400
- })()
401
- : null;
326
+ const popover = selected && !hidden && size.width ? regionPopoverPosition(selected.box, zoom, pan, size, versions.length > 1) : null;
402
327
 
403
328
  if (!version) return null;
404
329
 
405
- // The starting width (from `scale`, else `width`), then fitted inside
406
- // maxWidth × maxHeight with the image's shape kept.
407
- // With neither, the card fills the box.
408
- const baseWidth = scale != null && natural ? natural.width * scale : (width ?? maxWidth);
409
- const heightCap = natural && natural.height > 0 ? (maxHeight * natural.width) / natural.height : Infinity;
410
- const cardWidth = Math.round(Math.min(baseWidth, maxWidth, heightCap));
330
+ const cardWidth = editCardWidth(natural, { width, scale, maxWidth, maxHeight });
411
331
 
412
332
  return (
413
333
  <>
@@ -490,8 +410,8 @@ export function EditCard({
490
410
  className="chai-card-top-btn"
491
411
  aria-label="Zoom in"
492
412
  title="Zoom in"
493
- disabled={!ready || zoomIndex >= ZOOM_STEPS.length - 1}
494
- onClick={() => zoomTo(ZOOM_STEPS[zoomIndex + 1]!)}
413
+ disabled={!ready || zoomIndex >= EDIT_ZOOM_STEPS.length - 1}
414
+ onClick={() => zoomTo(EDIT_ZOOM_STEPS[zoomIndex + 1]!)}
495
415
  >
496
416
  <ZoomInIcon />
497
417
  </button>
@@ -504,7 +424,7 @@ export function EditCard({
504
424
  aria-label="Zoom out"
505
425
  title="Zoom out"
506
426
  disabled={!ready || zoomIndex <= 0}
507
- onClick={() => zoomTo(ZOOM_STEPS[zoomIndex - 1]!)}
427
+ onClick={() => zoomTo(EDIT_ZOOM_STEPS[zoomIndex - 1]!)}
508
428
  >
509
429
  <ZoomOutIcon />
510
430
  </button>
@@ -629,12 +549,7 @@ export function EditCard({
629
549
  );
630
550
  }
631
551
 
632
- const POPOVER_WIDTH = 220;
633
- /** A typical height for the instruction panel (title row and a two-line field), for placing it. */
634
- const POPOVER_HEIGHT = 96;
635
-
636
- type DragKind = "move" | "nw" | "ne" | "sw" | "se";
637
- const CORNERS = ["nw", "ne", "sw", "se"] as const;
552
+ type DragKind = "move" | RegionCorner;
638
553
 
639
554
  /** One region on the image: a button to select, move or delete it, its number badge, and corner handles while selected. */
640
555
  function RegionShape({
@@ -674,7 +589,7 @@ function RegionShape({
674
589
  {region.number}
675
590
  </span>
676
591
  {selected &&
677
- CORNERS.map((corner) => (
592
+ REGION_CORNERS.map((corner) => (
678
593
  <span
679
594
  key={corner}
680
595
  className={`chai-edit-card__handle chai-edit-card__handle--${corner}`}
@@ -686,15 +601,7 @@ function RegionShape({
686
601
  );
687
602
  }
688
603
 
689
- function boxStyle(r: EditRegion): CSSProperties {
690
- return {
691
- left: `${r.box.x * 100}%`,
692
- top: `${r.box.y * 100}%`,
693
- width: `${r.box.width * 100}%`,
694
- height: `${r.box.height * 100}%`,
695
- "--chai-region-color": regionColor(r.number),
696
- } as CSSProperties;
697
- }
604
+ const boxStyle = (r: EditRegion) => regionBoxStyle(r) as CSSProperties;
698
605
 
699
606
  /** The full-size image in a modal dialog: focus moves in, Escape closes, and focus returns to Expand. */
700
607
  function ExpandedImage({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
@@ -2,11 +2,28 @@ import { createComponent } from "@lit/react";
2
2
  import * as React from "react";
3
3
  import { useEffect, useRef, useState, type ChangeEvent, type DragEvent } from "react";
4
4
  import { MdFab } from "@material/web/fab/fab.js";
5
- import { SUGGESTED_ANALYSIS_MODELS } from "@chai-ui/core";
6
- import type { DroppedMedia, ModelOption, MediaKind } from "@chai-ui/core";
7
- import type { MediaAnalysisPromptLength } from "@chai-ui/core";
8
- import { SearchMenu, type SearchMenuSection } from "./primitives/SearchMenu.js";
5
+ import {
6
+ DEFAULT_MAX_ATTACHMENTS_BY_KIND,
7
+ isMediaAnalyzerAtCap,
8
+ mediaAnalyzerModelSections,
9
+ nextMediaAnalyzerAttachments,
10
+ PROMPT_LENGTH_HINT,
11
+ readMediaFiles,
12
+ replacedNote,
13
+ SUGGESTED_ANALYSIS_MODELS,
14
+ } from "@chai-ui/core";
15
+ import type { ModelOption, MediaKind, MediaAnalysisPromptLength } from "@chai-ui/core";
16
+ import { SearchMenu } from "./primitives/SearchMenu.js";
9
17
  import { StopIcon } from "./icons.js";
18
+ import type { MediaAnalyzerAttachment, MediaAnalyzerSubmitPayload } from "@chai-ui/core";
19
+
20
+ export {
21
+ DEFAULT_MAX_ATTACHMENTS_BY_KIND,
22
+ isMediaAnalyzerAtCap,
23
+ nextMediaAnalyzerAttachments,
24
+ type MediaAnalyzerAttachment,
25
+ type MediaAnalyzerSubmitPayload,
26
+ } from "@chai-ui/core";
10
27
 
11
28
  /** See primitives/Toggle.tsx for why `createComponent` is used instead of raw JSX on custom-element tags. */
12
29
  const MdFabElement = createComponent({
@@ -16,100 +33,6 @@ const MdFabElement = createComponent({
16
33
  events: { onClick: "click" },
17
34
  });
18
35
 
19
- const ACCEPTED_KINDS: MediaKind[] = ["image", "video", "audio"];
20
-
21
- function detectKind(file: File): MediaKind | undefined {
22
- const prefix = file.type.split("/")[0];
23
- return ACCEPTED_KINDS.find((k) => k === prefix);
24
- }
25
-
26
- function nextAttachmentId(): string {
27
- return typeof crypto !== "undefined" && "randomUUID" in crypto
28
- ? crypto.randomUUID()
29
- : `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`;
30
- }
31
-
32
- export interface MediaAnalyzerAttachment extends DroppedMedia {
33
- id: string;
34
- }
35
-
36
- /**
37
- * How many attachments of each kind an analysis can hold at once. Image
38
- * defaults to 5 (a sequence/series reads as one analysis); video and audio
39
- * default to 1 — there's no equivalent "a few short clips" case for either,
40
- * and multiple would each need their own kind-specific model/template
41
- * anyway. Only one *kind* is ever active per analysis (see
42
- * `nextMediaAnalyzerAttachments`'s own comment) — these caps only bound how
43
- * many of that one kind can pile up before the drop zone stops accepting
44
- * more.
45
- */
46
- export const DEFAULT_MAX_ATTACHMENTS_BY_KIND: Record<MediaKind, number> = {
47
- image: 5,
48
- video: 1,
49
- audio: 1,
50
- // `MediaKind`'s fourth member — text is output, never what a drop zone
51
- // itself detects (`detectKind` only ever
52
- // returns image/video/audio) — unreachable here, present only so this
53
- // stays a total map over the real type instead of a partial one.
54
- text: 0,
55
- };
56
-
57
- /**
58
- * Applies one newly-read file to the current attachment list (§3 principle
59
- * 3, "derive intent, don't demand it" — extended here to multiple files of
60
- * one kind). Only one media *kind* exists per analysis: dropping a kind
61
- * that doesn't match what's already attached replaces the whole list
62
- * (reference: the design spec — "there can only be one media
63
- * type per analysis... the video thumbnail will replace the image"), never
64
- * merges the two. Within one kind, video/audio are single-slot (a second
65
- * drop replaces the first — there's nothing to "add" to), while image fills
66
- * up to `max` and then silently stops accepting more of the same kind (the
67
- * drop zone's own disabled-looking state is what explains why, not a
68
- * rejected-file dialog for something this low-stakes).
69
- */
70
- export function nextMediaAnalyzerAttachments(
71
- current: MediaAnalyzerAttachment[],
72
- incoming: DroppedMedia,
73
- maxByKind: Record<MediaKind, number> = DEFAULT_MAX_ATTACHMENTS_BY_KIND
74
- ): MediaAnalyzerAttachment[] {
75
- const existingKind = current[0]?.kind;
76
- const withId: MediaAnalyzerAttachment = { ...incoming, id: nextAttachmentId() };
77
- if (!existingKind || existingKind !== incoming.kind) return [withId];
78
- if (incoming.kind === "image") {
79
- if (current.length >= (maxByKind.image ?? DEFAULT_MAX_ATTACHMENTS_BY_KIND.image)) return current;
80
- return [...current, withId];
81
- }
82
- // video/audio: single slot, a same-kind drop always replaces it.
83
- return [withId];
84
- }
85
-
86
- /**
87
- * True once a same-kind drop would actually be rejected outright, i.e. the
88
- * drop zone should show its disabled "maximum added" look. Only ever true
89
- * for image: video/audio are single-slot, so a same-kind drop *always*
90
- * succeeds by replacing the one already there (see
91
- * `nextMediaAnalyzerAttachments`'s own comment) — there's no "blocked"
92
- * state to show for either, just the normal "ready to replace" one.
93
- */
94
- export function isMediaAnalyzerAtCap(
95
- current: MediaAnalyzerAttachment[],
96
- maxByKind: Record<MediaKind, number> = DEFAULT_MAX_ATTACHMENTS_BY_KIND
97
- ): boolean {
98
- if (current[0]?.kind !== "image") return false;
99
- return current.length >= (maxByKind.image ?? DEFAULT_MAX_ATTACHMENTS_BY_KIND.image);
100
- }
101
-
102
- export interface MediaAnalyzerSubmitPayload {
103
- attachments: MediaAnalyzerAttachment[];
104
- modelId: string | null;
105
- /** When true, `useMediaAnalyzer` routes among `models` at submit instead of using `modelId`. */
106
- autoSelectModel: boolean;
107
- /** The models offered for the attached media's kind. */
108
- models: ModelOption[];
109
- /** `null` until a person actually picks one — see the `promptLength` prop's own doc comment for why this doesn't default to "concise". */
110
- promptLength: MediaAnalysisPromptLength | null;
111
- }
112
-
113
36
  export interface MediaAnalyzerProps {
114
37
  attachments: MediaAnalyzerAttachment[];
115
38
  onAttachmentsChange: (next: MediaAnalyzerAttachment[]) => void;
@@ -165,20 +88,6 @@ export interface MediaAnalyzerProps {
165
88
  onAbort?: () => void;
166
89
  }
167
90
 
168
- const KIND_LABEL: Record<MediaKind, string> = {
169
- image: "Image",
170
- video: "Video",
171
- audio: "Audio",
172
- text: "Text", // unreachable — see DEFAULT_MAX_ATTACHMENTS_BY_KIND's own comment
173
- };
174
-
175
- /** Clarifies what "Terse"/"Concise"/"Detailed" actually mean in the "Prompt length" menu's own option rows — design spec, not core prompt data (it's UI copy, not sent to any model), so it lives here rather than in media-analysis-to-prompt.prompts.ts. */
176
- const PROMPT_LENGTH_HINT: Record<MediaAnalysisPromptLength, string> = {
177
- terse: "short",
178
- concise: "moderate",
179
- detailed: "long",
180
- };
181
-
182
91
  export function MediaAnalyzer({
183
92
  attachments,
184
93
  onAttachmentsChange,
@@ -214,69 +123,23 @@ export function MediaAnalyzer({
214
123
  const [replaced, setReplaced] = useState<{ note: string; for: MediaAnalyzerAttachment[] } | null>(null);
215
124
  const isDragActive = dragDepth > 0;
216
125
 
217
- /**
218
- * Reads every file first, then folds each resolved one through
219
- * `nextMediaAnalyzerAttachments` in drop order and commits a single final
220
- * result — not one `onAttachmentsChange` call per file as each
221
- * `FileReader` happens to finish. Multiple files read concurrently
222
- * resolve in whatever order the browser gets to them; a call per file
223
- * would have each one apply itself on top of the *pre-drop* `attachments`
224
- * closure instead of the previous file's own result, so dropping 3 images
225
- * at once onto an empty zone could easily end up with just 1 attached
226
- * instead of 3 (last write wins) — same failure shape as Composer.tsx's
227
- * own `handleFilesSelected`, which this mirrors for exactly that reason.
228
- */
229
- function handleFiles(fileList: FileList | null) {
126
+ /** Reads every file first, then applies them in drop order and commits once. */
127
+ async function handleFiles(fileList: FileList | null) {
230
128
  if (!fileList || fileList.length === 0) return;
231
- const files = Array.from(fileList);
232
- let pending = files.length;
233
- const resolved: DroppedMedia[] = [];
234
- const settle = () => {
235
- pending -= 1;
236
- if (pending === 0 && resolved.length > 0) {
237
- const next = resolved.reduce(
238
- (acc, media) => nextMediaAnalyzerAttachments(acc, media, maxAttachmentsByKind),
239
- attachments
240
- );
241
- const removed = attachments.filter((a) => !next.some((n) => n.id === a.id));
242
- const added = next.filter((n) => !attachments.some((a) => a.id === n.id));
243
- setReplaced(removed.length > 0 && added.length > 0 ? { note: replacedNote(removed, added), for: next } : null);
244
- onAttachmentsChange(next);
245
- }
246
- };
247
- files.forEach((file) => {
248
- const fileKind = detectKind(file);
249
- if (!fileKind) {
250
- onUnsupportedFile?.(file.name);
251
- settle();
252
- return;
253
- }
254
- const reader = new FileReader();
255
- reader.onload = () => {
256
- resolved.push({ src: String(reader.result), kind: fileKind, name: file.name, size: file.size });
257
- settle();
258
- };
259
- reader.onerror = () => {
260
- onUnsupportedFile?.(file.name);
261
- settle();
262
- };
263
- reader.readAsDataURL(file);
264
- });
129
+ const { media, unsupported } = await readMediaFiles(fileList);
130
+ unsupported.forEach((name) => onUnsupportedFile?.(name));
131
+ if (media.length === 0) return;
132
+ const next = media.reduce((acc, m) => nextMediaAnalyzerAttachments(acc, m, maxAttachmentsByKind), attachments);
133
+ const note = replacedNote(attachments, next);
134
+ setReplaced(note ? { note, for: next } : null);
135
+ onAttachmentsChange(next);
265
136
  }
266
137
 
267
138
  function handleRemove(id: string) {
268
139
  onAttachmentsChange(attachments.filter((a) => a.id !== id));
269
140
  }
270
141
 
271
- const modelOptions: { id: string; label: string }[] = kind
272
- ? (modelsByKind[kind] ?? models).map((m) => ({ id: m.id, label: m.label }))
273
- : [];
274
- const sections: SearchMenuSection<{ id: string; label: string }>[] = kind
275
- ? [{ label: KIND_LABEL[kind], options: modelOptions }]
276
- : (["image", "video", "audio"] as const).map((k) => ({
277
- label: KIND_LABEL[k],
278
- options: (modelsByKind[k] ?? []).map((m) => ({ id: m.id, label: m.label })),
279
- }));
142
+ const sections = mediaAnalyzerModelSections(kind, modelsByKind, models);
280
143
  // Looked up across every section (not just `modelOptions`, which is empty
281
144
  // until a kind is known) — a model picked while the menu still shows all
282
145
  // three kinds' sections (no attachment yet) must still resolve to a real
@@ -472,14 +335,6 @@ function MediaAnalyzerThumbnail({
472
335
  // Same "trace and inline as a currentColor component" convention Composer.tsx
473
336
  // documents — icons aren't shared across files yet in this codebase.
474
337
 
475
- /** "Replaced 2 images with clip.wav." */
476
- function replacedNote(removed: MediaAnalyzerAttachment[], added: MediaAnalyzerAttachment[]): string {
477
- const name = (a: MediaAnalyzerAttachment) => a.name ?? KIND_LABEL[a.kind].toLowerCase();
478
- const describe = (list: MediaAnalyzerAttachment[]) =>
479
- list.length === 1 ? name(list[0]!) : `${list.length} ${KIND_LABEL[list[0]!.kind].toLowerCase()}s`;
480
- return `Replaced ${describe(removed)} with ${describe(added)}.`;
481
- }
482
-
483
338
  function ArrowForwardIcon() {
484
339
  return (
485
340
  <svg viewBox="0 -960 960 960" aria-hidden="true">
@@ -1,8 +1,18 @@
1
1
  import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2
- import { ROUTING_MODEL_ID, type ModelOption, type Result } from "@chai-ui/core";
2
+ import {
3
+ downloadFilename,
4
+ downloadMedia,
5
+ formatAspectRatio,
6
+ formatCost,
7
+ formatDuration,
8
+ formatTokens,
9
+ resultModelLabel,
10
+ shareMedia,
11
+ type ModelOption,
12
+ type Result,
13
+ } from "@chai-ui/core";
3
14
  import { Pagination } from "./primitives/Pagination.js";
4
15
  import { CheckSymbolIcon, DownloadIcon, ExpandIcon, ShareIcon } from "./icons.js";
5
- import { downloadFilename, downloadMedia, shareMedia } from "./media-actions.js";
6
16
  import { PageSlide, usePageSlide } from "./page-slide.js";
7
17
 
8
18
  /**
@@ -19,38 +29,6 @@ import { PageSlide, usePageSlide } from "./page-slide.js";
19
29
  * never a grid of separate prompts/history.
20
30
  */
21
31
 
22
- // --- Formatting ------------------------------------------------------------
23
-
24
- function formatDuration(ms: number | undefined): string | undefined {
25
- if (ms == null) return undefined;
26
- return `${(ms / 1000).toFixed(2)}s`;
27
- }
28
-
29
- function formatCost(usd: number | undefined): string | undefined {
30
- if (usd == null) return undefined;
31
- return `$${usd.toFixed(2)}`;
32
- }
33
-
34
- function formatTokens(total: number | undefined): string | undefined {
35
- if (total == null) return undefined;
36
- return `${total.toLocaleString()} tokens`;
37
- }
38
-
39
- function gcd(a: number, b: number): number {
40
- return b === 0 ? a : gcd(b, a % b);
41
- }
42
-
43
- /** A real, exactly-reduced ratio (e.g. 1024×768 → "4:3", 1920×1080 → "16:9") from the media's own measured dimensions — not a guess at the nearest "named" ratio, so an unusual crop just shows its own honest (if less familiar) reduced fraction instead of a misleading rounded one. */
44
- function formatAspectRatio(width: number, height: number): string | undefined {
45
- if (!width || !height) return undefined;
46
- const w = Math.round(width);
47
- const h = Math.round(height);
48
- const divisor = gcd(w, h);
49
- if (!divisor) return undefined;
50
- return `${w / divisor}:${h / divisor}`;
51
- }
52
-
53
-
54
32
  // --- Public types ------------------------------------------------------------
55
33
 
56
34
  /** One row in the "more" (⋯) menu, for app-specific actions (e.g. "Flag content"). ResultCard can't implement any of them — every one of these just fires `onAction` with this `id`. */
@@ -207,10 +185,7 @@ export function ResultCard({
207
185
  if (!result) return null;
208
186
 
209
187
  const vote = getVote?.(result) ?? null;
210
- const modelLabel =
211
- result.modelId === ROUTING_MODEL_ID
212
- ? "Choosing model…"
213
- : (models?.find((m) => m.id === result.modelId)?.label ?? result.modelId);
188
+ const modelLabel = resultModelLabel(result, models);
214
189
  const duration = formatDuration(result.durationMs);
215
190
  const cost = formatCost(result.usage?.costUsd);
216
191
  const tokens = formatTokens(result.usage?.totalTokens);