@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/DESIGN.md +19 -3
- package/dist/Composer.d.ts +2 -2
- package/dist/Composer.d.ts.map +1 -1
- package/dist/Composer.js +49 -165
- package/dist/Composer.js.map +1 -1
- 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 +2 -37
- 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/package.json +3 -3
- package/src/Composer.tsx +64 -196
- package/src/EditCard.tsx +44 -123
- package/src/MediaAnalyzer.tsx +29 -162
- package/src/ResultCard.tsx +13 -38
- 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,29 @@
|
|
|
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";
|
|
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" |
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
310
|
-
|
|
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
|
-
|
|
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
|
|
333
|
-
|
|
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(
|
|
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
|
-
|
|
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 >=
|
|
480
|
-
onClick={() => zoomTo(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }) {
|
package/src/MediaAnalyzer.tsx
CHANGED
|
@@ -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 {
|
|
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";
|
|
10
18
|
import type { MediaAnalyzerAttachment, MediaAnalyzerSubmitPayload } from "@chai-ui/core";
|
|
11
19
|
|
|
12
|
-
export
|
|
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
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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">
|
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);
|
package/dist/media-actions.d.ts
DELETED
|
@@ -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"}
|
package/dist/media-actions.js
DELETED
|
@@ -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
package/dist/regions.d.ts.map
DELETED
|
@@ -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"}
|