@chai-ui/react 0.6.1 → 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.
package/src/EditCard.tsx CHANGED
@@ -1,5 +1,29 @@
1
1
  import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
2
- import { DEFAULT_MAX_REGIONS, type EditRegion, type EditVersion, type RegionBox } 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";
3
27
 
4
28
  export type { EditVersion } from "@chai-ui/core";
5
29
  import { Pagination } from "./primitives/Pagination.js";
@@ -16,9 +40,7 @@ import {
16
40
  ZoomInIcon,
17
41
  ZoomOutIcon,
18
42
  } from "./icons.js";
19
- import { downloadFilename, downloadMedia, shareMedia } from "./media-actions.js";
20
43
  import { PageSlide, usePageSlide } from "./page-slide.js";
21
- import { regionColor } from "./regions.js";
22
44
 
23
45
  export interface EditCardProps {
24
46
  /** The original image first, then each edit. Paged with the dots in the card's bottom row. */
@@ -51,60 +73,12 @@ export interface EditCardProps {
51
73
  disabled?: boolean;
52
74
  }
53
75
 
54
- const ZOOM_STEPS = [1, 1.25, 1.5, 2, 3, 4];
55
- const MIN_SIZE = 0.03;
56
- const KEY_STEP = 0.01;
57
- const KEY_STEP_LARGE = 0.05;
58
-
59
- type Size = { width: number; height: number };
60
- type Pan = { x: number; y: number };
61
76
  type Drag =
62
- | { 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 }
63
78
  | { kind: "pan"; x: number; y: number; pan: Pan; moved: boolean };
64
79
 
65
80
  const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
66
81
 
67
- /** Keeps a box inside the image and at least the minimum size. */
68
- function fitBox(box: RegionBox): RegionBox {
69
- const width = clamp(box.width, MIN_SIZE, 1);
70
- const height = clamp(box.height, MIN_SIZE, 1);
71
- return { x: clamp(box.x, 0, 1 - width), y: clamp(box.y, 0, 1 - height), width, height };
72
- }
73
-
74
- /** A box resized by dragging one corner; the opposite corner stays put. */
75
- function resizeBox(box: RegionBox, corner: "nw" | "ne" | "sw" | "se", dx: number, dy: number): RegionBox {
76
- let left = box.x;
77
- let top = box.y;
78
- let right = box.x + box.width;
79
- let bottom = box.y + box.height;
80
- if (corner === "nw" || corner === "sw") left = clamp(left + dx, 0, right - MIN_SIZE);
81
- else right = clamp(right + dx, left + MIN_SIZE, 1);
82
- if (corner === "nw" || corner === "ne") top = clamp(top + dy, 0, bottom - MIN_SIZE);
83
- else bottom = clamp(bottom + dy, top + MIN_SIZE, 1);
84
- return { x: left, y: top, width: right - left, height: bottom - top };
85
- }
86
-
87
- /** Pan limits at a zoom level: the image always covers the viewport. */
88
- function clampPan(pan: Pan, zoom: number, size: Size): Pan {
89
- return {
90
- x: clamp(pan.x, size.width - size.width * zoom, 0),
91
- y: clamp(pan.y, size.height - size.height * zoom, 0),
92
- };
93
- }
94
-
95
- function nextRegionId(): string {
96
- return typeof crypto !== "undefined" && "randomUUID" in crypto
97
- ? crypto.randomUUID()
98
- : `region-${Date.now()}-${Math.random().toString(36).slice(2)}`;
99
- }
100
-
101
- /** The lowest number no current region uses, so freed numbers (and colors) come back first. */
102
- function freeNumber(regions: EditRegion[]): number {
103
- let n = 1;
104
- while (regions.some((r) => r.number === n)) n += 1;
105
- return n;
106
- }
107
-
108
82
  /**
109
83
  * The image editing card: the person marks regions on the image, gives each
110
84
  * one an instruction, and checks it into the Composer. It shares
@@ -184,7 +158,7 @@ export function EditCard({
184
158
  // A region being drawn takes a free number, even if the regions changed
185
159
  // since it was started (so checking it in can't duplicate a number).
186
160
  const shownDraft =
187
- 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;
188
162
  const all = shownDraft ? [...regions, shownDraft] : regions;
189
163
  const selected = all.find((r) => r.id === selectedId) ?? null;
190
164
  const isDraft = selected != null && selected.id === draft?.id;
@@ -216,13 +190,7 @@ export function EditCard({
216
190
  }
217
191
 
218
192
  function addRegion() {
219
- // Centered in the part of the image in view, so it's visible when zoomed in.
220
- const viewW = 1 / zoom;
221
- const viewH = 1 / zoom;
222
- const centerX = size.width ? (-pan.x / (size.width * zoom)) + viewW / 2 : 0.5;
223
- const centerY = size.height ? (-pan.y / (size.height * zoom)) + viewH / 2 : 0.5;
224
- const box = fitBox({ x: centerX - (0.3 * viewW) / 2, y: centerY - (0.2 * viewH) / 2, width: 0.3 * viewW, height: 0.2 * viewH });
225
- const region: EditRegion = { id: nextRegionId(), number: freeNumber(regions), box, prompt: "" };
193
+ const region = newRegion(regions, zoom, pan, size);
226
194
  setDraft(region);
227
195
  setHidden(false);
228
196
  select(region);
@@ -254,14 +222,10 @@ export function EditCard({
254
222
  }
255
223
 
256
224
  function zoomTo(next: number) {
257
- // Zooms around the middle of the view, so what's centered stays centered.
258
- const cx = size.width / 2;
259
- const cy = size.height / 2;
260
- const ratio = next / zoom;
261
- setPan(clampPan({ x: cx - (cx - pan.x) * ratio, y: cy - (cy - pan.y) * ratio }, next, size));
225
+ setPan(panForZoom(pan, zoom, next, size));
262
226
  setZoom(next);
263
227
  }
264
- const zoomIndex = ZOOM_STEPS.indexOf(zoom);
228
+ const zoomIndex = EDIT_ZOOM_STEPS.indexOf(zoom);
265
229
 
266
230
  // --- Pointer: move and resize regions, pan the zoomed image ---
267
231
  // Moves and releases bubble to the viewport, even while a region or
@@ -306,19 +270,11 @@ export function EditCard({
306
270
  // --- Keyboard ---
307
271
 
308
272
  function handleRegionKey(e: KeyboardEvent, region: EditRegion) {
309
- const step = e.altKey ? KEY_STEP_LARGE : KEY_STEP;
310
- const arrows: Record<string, [number, number]> = {
311
- ArrowLeft: [-step, 0],
312
- ArrowRight: [step, 0],
313
- ArrowUp: [0, -step],
314
- ArrowDown: [0, step],
315
- };
316
- const delta = arrows[e.key];
317
- if (delta) {
273
+ const box = regionBoxForKey(region.box, e.key, { shift: e.shiftKey, alt: e.altKey });
274
+ if (box) {
318
275
  e.preventDefault();
319
276
  e.stopPropagation();
320
- const [dx, dy] = delta;
321
- 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);
322
278
  } else if (e.key === "Delete" || e.key === "Backspace") {
323
279
  e.preventDefault();
324
280
  if (region.id === draft?.id) setDraft(null);
@@ -329,12 +285,10 @@ export function EditCard({
329
285
 
330
286
  function handleViewportKey(e: KeyboardEvent<HTMLDivElement>) {
331
287
  if (e.target !== e.currentTarget || zoom <= 1) return;
332
- const step = 40;
333
- const arrows: Record<string, [number, number]> = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };
334
- const delta = arrows[e.key];
335
- if (!delta) return;
288
+ const next = panForKey(pan, e.key, zoom, size);
289
+ if (!next) return;
336
290
  e.preventDefault();
337
- setPan(clampPan({ x: pan.x + delta[0], y: pan.y + delta[1] }, zoom, size));
291
+ setPan(next);
338
292
  }
339
293
 
340
294
  function handleFieldKey(e: KeyboardEvent<HTMLTextAreaElement>) {
@@ -369,31 +323,11 @@ export function EditCard({
369
323
  // --- Layout ---
370
324
 
371
325
  // The field sits above its region, or below it when there isn't room above.
372
- const popover = selected && !hidden && size.width
373
- ? (() => {
374
- const left = selected.box.x * size.width * zoom + pan.x;
375
- const top = selected.box.y * size.height * zoom + pan.y;
376
- const bottom = (selected.box.y + selected.box.height) * size.height * zoom + pan.y;
377
- // Below only when it clears the controls along the image's bottom edge.
378
- const reserved = versions.length > 1 ? 100 : 56;
379
- const fitsBelow = bottom + 8 + POPOVER_HEIGHT <= size.height - reserved;
380
- const above = top > POPOVER_HEIGHT + 16 || !fitsBelow;
381
- return {
382
- left: clamp(left, 8, Math.max(8, size.width - POPOVER_WIDTH - 8)),
383
- top: above ? Math.max(top - 8, POPOVER_HEIGHT + 8) : bottom + 8,
384
- above,
385
- };
386
- })()
387
- : null;
326
+ const popover = selected && !hidden && size.width ? regionPopoverPosition(selected.box, zoom, pan, size, versions.length > 1) : null;
388
327
 
389
328
  if (!version) return null;
390
329
 
391
- // The starting width (from `scale`, else `width`), then fitted inside
392
- // maxWidth × maxHeight with the image's shape kept.
393
- // With neither, the card fills the box.
394
- const baseWidth = scale != null && natural ? natural.width * scale : (width ?? maxWidth);
395
- const heightCap = natural && natural.height > 0 ? (maxHeight * natural.width) / natural.height : Infinity;
396
- const cardWidth = Math.round(Math.min(baseWidth, maxWidth, heightCap));
330
+ const cardWidth = editCardWidth(natural, { width, scale, maxWidth, maxHeight });
397
331
 
398
332
  return (
399
333
  <>
@@ -476,8 +410,8 @@ export function EditCard({
476
410
  className="chai-card-top-btn"
477
411
  aria-label="Zoom in"
478
412
  title="Zoom in"
479
- disabled={!ready || zoomIndex >= ZOOM_STEPS.length - 1}
480
- onClick={() => zoomTo(ZOOM_STEPS[zoomIndex + 1]!)}
413
+ disabled={!ready || zoomIndex >= EDIT_ZOOM_STEPS.length - 1}
414
+ onClick={() => zoomTo(EDIT_ZOOM_STEPS[zoomIndex + 1]!)}
481
415
  >
482
416
  <ZoomInIcon />
483
417
  </button>
@@ -490,7 +424,7 @@ export function EditCard({
490
424
  aria-label="Zoom out"
491
425
  title="Zoom out"
492
426
  disabled={!ready || zoomIndex <= 0}
493
- onClick={() => zoomTo(ZOOM_STEPS[zoomIndex - 1]!)}
427
+ onClick={() => zoomTo(EDIT_ZOOM_STEPS[zoomIndex - 1]!)}
494
428
  >
495
429
  <ZoomOutIcon />
496
430
  </button>
@@ -615,12 +549,7 @@ export function EditCard({
615
549
  );
616
550
  }
617
551
 
618
- const POPOVER_WIDTH = 220;
619
- /** A typical height for the instruction panel (title row and a two-line field), for placing it. */
620
- const POPOVER_HEIGHT = 96;
621
-
622
- type DragKind = "move" | "nw" | "ne" | "sw" | "se";
623
- const CORNERS = ["nw", "ne", "sw", "se"] as const;
552
+ type DragKind = "move" | RegionCorner;
624
553
 
625
554
  /** One region on the image: a button to select, move or delete it, its number badge, and corner handles while selected. */
626
555
  function RegionShape({
@@ -660,7 +589,7 @@ function RegionShape({
660
589
  {region.number}
661
590
  </span>
662
591
  {selected &&
663
- CORNERS.map((corner) => (
592
+ REGION_CORNERS.map((corner) => (
664
593
  <span
665
594
  key={corner}
666
595
  className={`chai-edit-card__handle chai-edit-card__handle--${corner}`}
@@ -672,15 +601,7 @@ function RegionShape({
672
601
  );
673
602
  }
674
603
 
675
- function boxStyle(r: EditRegion): CSSProperties {
676
- return {
677
- left: `${r.box.x * 100}%`,
678
- top: `${r.box.y * 100}%`,
679
- width: `${r.box.width * 100}%`,
680
- height: `${r.box.height * 100}%`,
681
- "--chai-region-color": regionColor(r.number),
682
- } as CSSProperties;
683
- }
604
+ const boxStyle = (r: EditRegion) => regionBoxStyle(r) as CSSProperties;
684
605
 
685
606
  /** The full-size image in a modal dialog: focus moves in, Escape closes, and focus returns to Expand. */
686
607
  function ExpandedImage({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
@@ -2,14 +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";
10
18
  import type { MediaAnalyzerAttachment, MediaAnalyzerSubmitPayload } from "@chai-ui/core";
11
19
 
12
- export type { MediaAnalyzerAttachment, MediaAnalyzerSubmitPayload } from "@chai-ui/core";
20
+ export {
21
+ DEFAULT_MAX_ATTACHMENTS_BY_KIND,
22
+ isMediaAnalyzerAtCap,
23
+ nextMediaAnalyzerAttachments,
24
+ type MediaAnalyzerAttachment,
25
+ type MediaAnalyzerSubmitPayload,
26
+ } from "@chai-ui/core";
13
27
 
14
28
  /** See primitives/Toggle.tsx for why `createComponent` is used instead of raw JSX on custom-element tags. */
15
29
  const MdFabElement = createComponent({
@@ -19,85 +33,6 @@ const MdFabElement = createComponent({
19
33
  events: { onClick: "click" },
20
34
  });
21
35
 
22
- const ACCEPTED_KINDS: MediaKind[] = ["image", "video", "audio"];
23
-
24
- function detectKind(file: File): MediaKind | undefined {
25
- const prefix = file.type.split("/")[0];
26
- return ACCEPTED_KINDS.find((k) => k === prefix);
27
- }
28
-
29
- function nextAttachmentId(): string {
30
- return typeof crypto !== "undefined" && "randomUUID" in crypto
31
- ? crypto.randomUUID()
32
- : `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`;
33
- }
34
-
35
- /**
36
- * How many attachments of each kind an analysis can hold at once. Image
37
- * defaults to 5 (a sequence/series reads as one analysis); video and audio
38
- * default to 1 — there's no equivalent "a few short clips" case for either,
39
- * and multiple would each need their own kind-specific model/template
40
- * anyway. Only one *kind* is ever active per analysis (see
41
- * `nextMediaAnalyzerAttachments`'s own comment) — these caps only bound how
42
- * many of that one kind can pile up before the drop zone stops accepting
43
- * more.
44
- */
45
- export const DEFAULT_MAX_ATTACHMENTS_BY_KIND: Record<MediaKind, number> = {
46
- image: 5,
47
- video: 1,
48
- audio: 1,
49
- // `MediaKind`'s fourth member — text is output, never what a drop zone
50
- // itself detects (`detectKind` only ever
51
- // returns image/video/audio) — unreachable here, present only so this
52
- // stays a total map over the real type instead of a partial one.
53
- text: 0,
54
- };
55
-
56
- /**
57
- * Applies one newly-read file to the current attachment list (§3 principle
58
- * 3, "derive intent, don't demand it" — extended here to multiple files of
59
- * one kind). Only one media *kind* exists per analysis: dropping a kind
60
- * that doesn't match what's already attached replaces the whole list
61
- * (reference: the design spec — "there can only be one media
62
- * type per analysis... the video thumbnail will replace the image"), never
63
- * merges the two. Within one kind, video/audio are single-slot (a second
64
- * drop replaces the first — there's nothing to "add" to), while image fills
65
- * up to `max` and then silently stops accepting more of the same kind (the
66
- * drop zone's own disabled-looking state is what explains why, not a
67
- * rejected-file dialog for something this low-stakes).
68
- */
69
- export function nextMediaAnalyzerAttachments(
70
- current: MediaAnalyzerAttachment[],
71
- incoming: DroppedMedia,
72
- maxByKind: Record<MediaKind, number> = DEFAULT_MAX_ATTACHMENTS_BY_KIND
73
- ): MediaAnalyzerAttachment[] {
74
- const existingKind = current[0]?.kind;
75
- const withId: MediaAnalyzerAttachment = { ...incoming, id: nextAttachmentId() };
76
- if (!existingKind || existingKind !== incoming.kind) return [withId];
77
- if (incoming.kind === "image") {
78
- if (current.length >= (maxByKind.image ?? DEFAULT_MAX_ATTACHMENTS_BY_KIND.image)) return current;
79
- return [...current, withId];
80
- }
81
- // video/audio: single slot, a same-kind drop always replaces it.
82
- return [withId];
83
- }
84
-
85
- /**
86
- * True once a same-kind drop would actually be rejected outright, i.e. the
87
- * drop zone should show its disabled "maximum added" look. Only ever true
88
- * for image: video/audio are single-slot, so a same-kind drop *always*
89
- * succeeds by replacing the one already there (see
90
- * `nextMediaAnalyzerAttachments`'s own comment) — there's no "blocked"
91
- * state to show for either, just the normal "ready to replace" one.
92
- */
93
- export function isMediaAnalyzerAtCap(
94
- current: MediaAnalyzerAttachment[],
95
- maxByKind: Record<MediaKind, number> = DEFAULT_MAX_ATTACHMENTS_BY_KIND
96
- ): boolean {
97
- if (current[0]?.kind !== "image") return false;
98
- return current.length >= (maxByKind.image ?? DEFAULT_MAX_ATTACHMENTS_BY_KIND.image);
99
- }
100
-
101
36
  export interface MediaAnalyzerProps {
102
37
  attachments: MediaAnalyzerAttachment[];
103
38
  onAttachmentsChange: (next: MediaAnalyzerAttachment[]) => void;
@@ -153,20 +88,6 @@ export interface MediaAnalyzerProps {
153
88
  onAbort?: () => void;
154
89
  }
155
90
 
156
- const KIND_LABEL: Record<MediaKind, string> = {
157
- image: "Image",
158
- video: "Video",
159
- audio: "Audio",
160
- text: "Text", // unreachable — see DEFAULT_MAX_ATTACHMENTS_BY_KIND's own comment
161
- };
162
-
163
- /** 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. */
164
- const PROMPT_LENGTH_HINT: Record<MediaAnalysisPromptLength, string> = {
165
- terse: "short",
166
- concise: "moderate",
167
- detailed: "long",
168
- };
169
-
170
91
  export function MediaAnalyzer({
171
92
  attachments,
172
93
  onAttachmentsChange,
@@ -202,69 +123,23 @@ export function MediaAnalyzer({
202
123
  const [replaced, setReplaced] = useState<{ note: string; for: MediaAnalyzerAttachment[] } | null>(null);
203
124
  const isDragActive = dragDepth > 0;
204
125
 
205
- /**
206
- * Reads every file first, then folds each resolved one through
207
- * `nextMediaAnalyzerAttachments` in drop order and commits a single final
208
- * result — not one `onAttachmentsChange` call per file as each
209
- * `FileReader` happens to finish. Multiple files read concurrently
210
- * resolve in whatever order the browser gets to them; a call per file
211
- * would have each one apply itself on top of the *pre-drop* `attachments`
212
- * closure instead of the previous file's own result, so dropping 3 images
213
- * at once onto an empty zone could easily end up with just 1 attached
214
- * instead of 3 (last write wins) — same failure shape as Composer.tsx's
215
- * own `handleFilesSelected`, which this mirrors for exactly that reason.
216
- */
217
- 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) {
218
128
  if (!fileList || fileList.length === 0) return;
219
- const files = Array.from(fileList);
220
- let pending = files.length;
221
- const resolved: DroppedMedia[] = [];
222
- const settle = () => {
223
- pending -= 1;
224
- if (pending === 0 && resolved.length > 0) {
225
- const next = resolved.reduce(
226
- (acc, media) => nextMediaAnalyzerAttachments(acc, media, maxAttachmentsByKind),
227
- attachments
228
- );
229
- const removed = attachments.filter((a) => !next.some((n) => n.id === a.id));
230
- const added = next.filter((n) => !attachments.some((a) => a.id === n.id));
231
- setReplaced(removed.length > 0 && added.length > 0 ? { note: replacedNote(removed, added), for: next } : null);
232
- onAttachmentsChange(next);
233
- }
234
- };
235
- files.forEach((file) => {
236
- const fileKind = detectKind(file);
237
- if (!fileKind) {
238
- onUnsupportedFile?.(file.name);
239
- settle();
240
- return;
241
- }
242
- const reader = new FileReader();
243
- reader.onload = () => {
244
- resolved.push({ src: String(reader.result), kind: fileKind, name: file.name, size: file.size });
245
- settle();
246
- };
247
- reader.onerror = () => {
248
- onUnsupportedFile?.(file.name);
249
- settle();
250
- };
251
- reader.readAsDataURL(file);
252
- });
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);
253
136
  }
254
137
 
255
138
  function handleRemove(id: string) {
256
139
  onAttachmentsChange(attachments.filter((a) => a.id !== id));
257
140
  }
258
141
 
259
- const modelOptions: { id: string; label: string }[] = kind
260
- ? (modelsByKind[kind] ?? models).map((m) => ({ id: m.id, label: m.label }))
261
- : [];
262
- const sections: SearchMenuSection<{ id: string; label: string }>[] = kind
263
- ? [{ label: KIND_LABEL[kind], options: modelOptions }]
264
- : (["image", "video", "audio"] as const).map((k) => ({
265
- label: KIND_LABEL[k],
266
- options: (modelsByKind[k] ?? []).map((m) => ({ id: m.id, label: m.label })),
267
- }));
142
+ const sections = mediaAnalyzerModelSections(kind, modelsByKind, models);
268
143
  // Looked up across every section (not just `modelOptions`, which is empty
269
144
  // until a kind is known) — a model picked while the menu still shows all
270
145
  // three kinds' sections (no attachment yet) must still resolve to a real
@@ -460,14 +335,6 @@ function MediaAnalyzerThumbnail({
460
335
  // Same "trace and inline as a currentColor component" convention Composer.tsx
461
336
  // documents — icons aren't shared across files yet in this codebase.
462
337
 
463
- /** "Replaced 2 images with clip.wav." */
464
- function replacedNote(removed: MediaAnalyzerAttachment[], added: MediaAnalyzerAttachment[]): string {
465
- const name = (a: MediaAnalyzerAttachment) => a.name ?? KIND_LABEL[a.kind].toLowerCase();
466
- const describe = (list: MediaAnalyzerAttachment[]) =>
467
- list.length === 1 ? name(list[0]!) : `${list.length} ${KIND_LABEL[list[0]!.kind].toLowerCase()}s`;
468
- return `Replaced ${describe(removed)} with ${describe(added)}.`;
469
- }
470
-
471
338
  function ArrowForwardIcon() {
472
339
  return (
473
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);
@@ -1,18 +0,0 @@
1
- /**
2
- * Download and share for a piece of generated media, shared by the cards.
3
- *
4
- * A plain `<a download href={remoteUrl}>` only forces a download for a
5
- * same-origin or `blob:`/`data:` URL; for a cross-origin CDN URL (what a
6
- * generated result usually is) browsers ignore `download` and open it
7
- * instead. Fetching it into a blob first works wherever the source allows
8
- * cross-origin fetches. When it doesn't, opening the media is the fallback.
9
- */
10
- export declare function downloadMedia(src: string, filename: string): Promise<void>;
11
- /**
12
- * The native share sheet where there is one, otherwise the media's URL
13
- * copied to the clipboard. Says which, so the button can confirm a copy.
14
- */
15
- export declare function shareMedia(src: string): Promise<"shared" | "copied" | "none">;
16
- /** A file name for a download: the URL's own extension when it has one, otherwise a guess by kind. */
17
- export declare function downloadFilename(id: string, kind: string, src: string): string;
18
- //# sourceMappingURL=media-actions.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"media-actions.d.ts","sourceRoot":"","sources":["../src/media-actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAehF;AAED;;;GAGG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAC,CAgBnF;AAED,sGAAsG;AACtG,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAI9E"}
@@ -1,56 +0,0 @@
1
- /**
2
- * Download and share for a piece of generated media, shared by the cards.
3
- *
4
- * A plain `<a download href={remoteUrl}>` only forces a download for a
5
- * same-origin or `blob:`/`data:` URL; for a cross-origin CDN URL (what a
6
- * generated result usually is) browsers ignore `download` and open it
7
- * instead. Fetching it into a blob first works wherever the source allows
8
- * cross-origin fetches. When it doesn't, opening the media is the fallback.
9
- */
10
- export async function downloadMedia(src, filename) {
11
- try {
12
- const response = await fetch(src);
13
- const blob = await response.blob();
14
- const blobUrl = URL.createObjectURL(blob);
15
- const a = document.createElement("a");
16
- a.href = blobUrl;
17
- a.download = filename;
18
- document.body.appendChild(a);
19
- a.click();
20
- a.remove();
21
- URL.revokeObjectURL(blobUrl);
22
- }
23
- catch {
24
- window.open(src, "_blank", "noopener");
25
- }
26
- }
27
- /**
28
- * The native share sheet where there is one, otherwise the media's URL
29
- * copied to the clipboard. Says which, so the button can confirm a copy.
30
- */
31
- export async function shareMedia(src) {
32
- if (navigator.share) {
33
- try {
34
- await navigator.share({ url: src });
35
- return "shared";
36
- }
37
- catch {
38
- // Dismissed, or not shareable: same as closing a native share sheet.
39
- return "none";
40
- }
41
- }
42
- try {
43
- await navigator.clipboard.writeText(src);
44
- return "copied";
45
- }
46
- catch {
47
- return "none";
48
- }
49
- }
50
- /** A file name for a download: the URL's own extension when it has one, otherwise a guess by kind. */
51
- export function downloadFilename(id, kind, src) {
52
- const match = /\.([a-zA-Z0-9]{2,5})(?:[?#]|$)/.exec(src);
53
- const ext = match?.[1] ?? (kind === "video" ? "mp4" : kind === "audio" ? "mp3" : kind === "text" ? "txt" : "png");
54
- return `${kind}-${id}.${ext}`;
55
- }
56
- //# sourceMappingURL=media-actions.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"media-actions.js","sourceRoot":"","sources":["../src/media-actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,GAAW,EAAE,QAAgB;IAC/D,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,CAAC,CAAC;QAClC,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;QACnC,MAAM,OAAO,GAAG,GAAG,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;QAC1C,MAAM,CAAC,GAAG,QAAQ,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;QACtC,CAAC,CAAC,IAAI,GAAG,OAAO,CAAC;QACjB,CAAC,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACtB,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;QAC7B,CAAC,CAAC,KAAK,EAAE,CAAC;QACV,CAAC,CAAC,MAAM,EAAE,CAAC;QACX,GAAG,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,UAAU,CAAC,CAAC;IACzC,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,GAAW;IAC1C,IAAI,SAAS,CAAC,KAAK,EAAE,CAAC;QACpB,IAAI,CAAC;YACH,MAAM,SAAS,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC;YACpC,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,MAAM,CAAC;YACP,qEAAqE;YACrE,OAAO,MAAM,CAAC;QAChB,CAAC;IACH,CAAC;IACD,IAAI,CAAC;QACH,MAAM,SAAS,CAAC,SAAS,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QACzC,OAAO,QAAQ,CAAC;IAClB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,MAAM,CAAC;IAChB,CAAC;AACH,CAAC;AAED,sGAAsG;AACtG,MAAM,UAAU,gBAAgB,CAAC,EAAU,EAAE,IAAY,EAAE,GAAW;IACpE,MAAM,KAAK,GAAG,gCAAgC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACzD,MAAM,GAAG,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAClH,OAAO,GAAG,IAAI,IAAI,EAAE,IAAI,GAAG,EAAE,CAAC;AAChC,CAAC"}
package/dist/regions.d.ts DELETED
@@ -1,3 +0,0 @@
1
- /** The color token for a region number. Numbers past the last color start over at the first. */
2
- export declare function regionColor(number: number): string;
3
- //# sourceMappingURL=regions.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"regions.d.ts","sourceRoot":"","sources":["../src/regions.ts"],"names":[],"mappings":"AAGA,gGAAgG;AAChG,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAElD"}