@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.
- package/DESIGN.md +19 -3
- package/dist/ChaiProvider.d.ts +3 -42
- package/dist/ChaiProvider.d.ts.map +1 -1
- package/dist/ChaiProvider.js +5 -43
- package/dist/ChaiProvider.js.map +1 -1
- package/dist/Composer.d.ts +4 -59
- package/dist/Composer.d.ts.map +1 -1
- package/dist/Composer.js +50 -169
- package/dist/Composer.js.map +1 -1
- package/dist/EditCard.d.ts +2 -16
- package/dist/EditCard.d.ts.map +1 -1
- package/dist/EditCard.js +16 -115
- package/dist/EditCard.js.map +1 -1
- package/dist/MediaAnalyzer.d.ts +3 -49
- package/dist/MediaAnalyzer.d.ts.map +1 -1
- package/dist/MediaAnalyzer.js +13 -141
- package/dist/MediaAnalyzer.js.map +1 -1
- package/dist/ResultCard.d.ts +13 -0
- package/dist/ResultCard.d.ts.map +1 -1
- package/dist/ResultCard.js +2 -48
- package/dist/ResultCard.js.map +1 -1
- package/dist/useComposer.d.ts +11 -86
- package/dist/useComposer.d.ts.map +1 -1
- package/dist/useComposer.js +26 -291
- package/dist/useComposer.js.map +1 -1
- package/dist/useMediaAnalyzer.d.ts +8 -28
- package/dist/useMediaAnalyzer.d.ts.map +1 -1
- package/dist/useMediaAnalyzer.js +9 -106
- package/dist/useMediaAnalyzer.js.map +1 -1
- package/package.json +3 -3
- package/src/ChaiProvider.tsx +12 -70
- package/src/Composer.tsx +73 -258
- package/src/EditCard.tsx +46 -139
- package/src/MediaAnalyzer.tsx +31 -176
- package/src/ResultCard.tsx +13 -38
- package/src/useComposer.ts +33 -411
- package/src/useMediaAnalyzer.ts +15 -146
- package/dist/media-actions.d.ts +0 -18
- package/dist/media-actions.d.ts.map +0 -1
- package/dist/media-actions.js +0 -56
- package/dist/media-actions.js.map +0 -1
- package/dist/regions.d.ts +0 -3
- package/dist/regions.d.ts.map +0 -1
- package/dist/regions.js +0 -7
- package/dist/regions.js.map +0 -1
- package/src/media-actions.ts +0 -54
- 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 {
|
|
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" |
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
324
|
-
|
|
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
|
-
|
|
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
|
|
347
|
-
|
|
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(
|
|
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
|
-
|
|
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 >=
|
|
494
|
-
onClick={() => zoomTo(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }) {
|
package/src/MediaAnalyzer.tsx
CHANGED
|
@@ -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 {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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">
|
package/src/ResultCard.tsx
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
-
import {
|
|
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);
|