@chai-ui/react 0.5.0 → 0.6.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 +6 -1
- package/README.md +79 -17
- package/dist/Composer.d.ts +7 -2
- package/dist/Composer.d.ts.map +1 -1
- package/dist/Composer.js +25 -20
- package/dist/Composer.js.map +1 -1
- package/dist/EditCard.d.ts.map +1 -1
- package/dist/EditCard.js +7 -2
- package/dist/EditCard.js.map +1 -1
- package/dist/MediaAnalyzer.d.ts +12 -5
- package/dist/MediaAnalyzer.d.ts.map +1 -1
- package/dist/MediaAnalyzer.js +26 -8
- package/dist/MediaAnalyzer.js.map +1 -1
- package/dist/ResultCard.d.ts.map +1 -1
- package/dist/ResultCard.js +10 -6
- package/dist/ResultCard.js.map +1 -1
- package/dist/icons.d.ts +1 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +9 -0
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/media-actions.d.ts +5 -2
- package/dist/media-actions.d.ts.map +1 -1
- package/dist/media-actions.js +12 -3
- package/dist/media-actions.js.map +1 -1
- package/dist/style.css +15 -0
- package/package.json +3 -3
- package/src/Composer.tsx +32 -27
- package/src/EditCard.tsx +15 -3
- package/src/MediaAnalyzer.tsx +48 -15
- package/src/ResultCard.tsx +12 -4
- package/src/icons.tsx +14 -0
- package/src/index.ts +10 -0
- package/src/media-actions.ts +13 -4
- package/src/style.css +15 -0
package/src/Composer.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { StopIcon } from "./icons.js";
|
|
1
2
|
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
|
2
3
|
import { createComponent } from "@lit/react";
|
|
3
4
|
import * as React from "react";
|
|
4
5
|
import { MdFab } from "@material/web/fab/fab.js";
|
|
6
|
+
import { deriveGenerationUseCase, suggestedModels } from "@chai-ui/core";
|
|
5
7
|
import type { DroppedMedia, EditRegion, ModelOption, ParameterOption, MediaKind } from "@chai-ui/core";
|
|
6
8
|
import { SearchMenu } from "./primitives/SearchMenu.js";
|
|
7
9
|
import { regionColor } from "./regions.js";
|
|
@@ -134,7 +136,7 @@ export interface ComposerProps {
|
|
|
134
136
|
multiSelectUseCases?: boolean;
|
|
135
137
|
useCases?: ComposerUseCase[];
|
|
136
138
|
onUseCasesChange?: (next: ComposerUseCase[]) => void;
|
|
137
|
-
/** Models per use-case kind, for `multiSelectUseCases`. */
|
|
139
|
+
/** Models per use-case kind, for `multiSelectUseCases`. A kind left out gets Chai's suggested models (see `models`). */
|
|
138
140
|
modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
|
|
139
141
|
/**
|
|
140
142
|
* Builder opt-in, off by default: the end user can pick several models,
|
|
@@ -145,7 +147,12 @@ export interface ComposerProps {
|
|
|
145
147
|
multiSelectModels?: boolean;
|
|
146
148
|
modelIds?: string[];
|
|
147
149
|
onModelIdsChange?: (ids: string[]) => void;
|
|
148
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Models for the current use case. Leave it out and Composer offers Chai's
|
|
152
|
+
* suggested Fal models for the use case (`suggestedModels`), with the
|
|
153
|
+
* first one picked until the person picks another. Pass a list to use
|
|
154
|
+
* your own; pass `[]` for no Model menu.
|
|
155
|
+
*/
|
|
149
156
|
models?: ModelOption[];
|
|
150
157
|
modelId?: string | null;
|
|
151
158
|
/** `null` clears the pick: Composer does this when auto-select turns on, until routing picks a model. */
|
|
@@ -311,19 +318,31 @@ export function Composer({
|
|
|
311
318
|
const showStop = submitting && Boolean(onAbort);
|
|
312
319
|
const multiModel = multiSelectModels || multiSelectUseCases;
|
|
313
320
|
const rawPickedIds = multiModel ? modelIds : modelId ? [modelId] : [];
|
|
321
|
+
// The app's list for a use case, or Chai's suggested models when it gave none.
|
|
322
|
+
const rosterFor = (u: ComposerUseCase): { options: ModelOption[]; suggested: boolean } => {
|
|
323
|
+
const given = multiSelectUseCases ? modelsByKind?.[u.kind] : models;
|
|
324
|
+
if (given) return { options: given, suggested: false };
|
|
325
|
+
return { options: suggestedModels(u.edit ? "image-edit" : deriveGenerationUseCase(u, attachments)), suggested: true };
|
|
326
|
+
};
|
|
314
327
|
// One menu section per use case with multi-use-case select; otherwise one flat roster.
|
|
315
|
-
const modelSections = multiSelectUseCases
|
|
316
|
-
?
|
|
317
|
-
|
|
318
|
-
.filter((s) => s.options.length > 0)
|
|
319
|
-
: [{ label: "", kind: activeUseCases[0]!.kind, options: models ?? [] }].filter((s) => s.options.length > 0);
|
|
328
|
+
const modelSections = (multiSelectUseCases ? activeUseCases : activeUseCases.slice(0, 1))
|
|
329
|
+
.map((u) => ({ label: multiSelectUseCases ? u.label : "", kind: u.kind, ...rosterFor(u) }))
|
|
330
|
+
.filter((s) => s.options.length > 0);
|
|
320
331
|
const allModels = modelSections.flatMap((s) => s.options);
|
|
321
332
|
// A pick that isn't in the current list (e.g. the edit model after leaving
|
|
322
333
|
// edit mode) is shown as unpicked and never submitted. With no list at
|
|
323
|
-
// all, the app's pick is passed through as given.
|
|
324
|
-
|
|
334
|
+
// all, the app's pick is passed through as given. With nothing picked
|
|
335
|
+
// from a suggested list (and auto-select off), its first model is the pick.
|
|
336
|
+
const validPicks =
|
|
325
337
|
allModels.length > 0 ? rawPickedIds.filter((id) => allModels.some((m) => m.id === id)) : rawPickedIds;
|
|
338
|
+
const pickedModelIds =
|
|
339
|
+
validPicks.length > 0 || autoSelectModel
|
|
340
|
+
? validPicks
|
|
341
|
+
: modelSections.filter((s) => s.suggested).map((s) => s.options[0]!.id);
|
|
326
342
|
const pickedModels = allModels.filter((m) => pickedModelIds.includes(m.id));
|
|
343
|
+
// No menu that can't change anything: without its handler, the Model menu
|
|
344
|
+
// hides (the default pick still runs).
|
|
345
|
+
const canPickModel = multiModel ? Boolean(onModelIdsChange) : Boolean(onModelChange);
|
|
327
346
|
const modelTriggerLabel =
|
|
328
347
|
pickedModels.length > 1
|
|
329
348
|
? `${pickedModels.length} models`
|
|
@@ -354,9 +373,9 @@ export function Composer({
|
|
|
354
373
|
function buildSelections(): ComposerSelection[] {
|
|
355
374
|
return activeUseCases.map((u) => ({
|
|
356
375
|
useCase: u,
|
|
357
|
-
models:
|
|
376
|
+
models: rosterFor(u).options,
|
|
358
377
|
modelIds: multiSelectUseCases
|
|
359
|
-
? pickedModelIds.filter((id) =>
|
|
378
|
+
? pickedModelIds.filter((id) => rosterFor(u).options.some((m) => m.id === id))
|
|
360
379
|
: pickedModelIds,
|
|
361
380
|
}));
|
|
362
381
|
}
|
|
@@ -610,7 +629,7 @@ export function Composer({
|
|
|
610
629
|
trigger stays enabled whenever the switch is shown — it's the
|
|
611
630
|
only way back to the switch. Rows are disabled by `SearchMenu`
|
|
612
631
|
itself while auto-select is on. */}
|
|
613
|
-
{modelSections.length > 0 && (
|
|
632
|
+
{modelSections.length > 0 && canPickModel && (
|
|
614
633
|
<SearchMenu
|
|
615
634
|
{...(multiSelectUseCases
|
|
616
635
|
? { sections: modelSections.map((s) => ({ label: s.label, options: s.options })) }
|
|
@@ -636,7 +655,7 @@ export function Composer({
|
|
|
636
655
|
/>
|
|
637
656
|
)}
|
|
638
657
|
|
|
639
|
-
{showAspectRatio && aspectOptions.length > 0 && (
|
|
658
|
+
{showAspectRatio && aspectOptions.length > 0 && onAspectRatioChange && (
|
|
640
659
|
<SearchMenu
|
|
641
660
|
options={aspectOptions.map((o) => ({ id: o.value, label: o.label }))}
|
|
642
661
|
value={offeredAspect}
|
|
@@ -1046,20 +1065,6 @@ function UndoIcon() {
|
|
|
1046
1065
|
);
|
|
1047
1066
|
}
|
|
1048
1067
|
|
|
1049
|
-
// Real Material Symbols "stop" glyph, the filled variant specifically
|
|
1050
|
-
// (`stop-fill`, not the base `stop` — that one traces a hollow square-
|
|
1051
|
-
// outline shape even filled with `currentColor`, which read as a thin
|
|
1052
|
-
// frame rather than a real stop icon at 24px; design review caught
|
|
1053
|
-
// this live). Viewbox `0 -960 960 960` — the submit FAB's in-flight state,
|
|
1054
|
-
// its own doc comment above where it's rendered.
|
|
1055
|
-
function StopIcon() {
|
|
1056
|
-
return (
|
|
1057
|
-
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
1058
|
-
<path d="M240-240v-480h480v480H240Z" fill="currentColor" />
|
|
1059
|
-
</svg>
|
|
1060
|
-
);
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
1068
|
function AttachFileIcon() {
|
|
1064
1069
|
return (
|
|
1065
1070
|
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
package/src/EditCard.tsx
CHANGED
|
@@ -168,6 +168,7 @@ export function EditCard({
|
|
|
168
168
|
// until the next version's image loads, so the card doesn't jump between.
|
|
169
169
|
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
|
|
170
170
|
const [expanded, setExpanded] = useState(false);
|
|
171
|
+
const [linkCopied, setLinkCopied] = useState(false);
|
|
171
172
|
const drag = useRef<Drag | null>(null);
|
|
172
173
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
173
174
|
const fieldRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -371,7 +372,11 @@ export function EditCard({
|
|
|
371
372
|
|
|
372
373
|
const handleShare = async () => {
|
|
373
374
|
if (!version?.src) return;
|
|
374
|
-
|
|
375
|
+
// With no share sheet the link is copied; say so, or the click looks like nothing happened.
|
|
376
|
+
if ((await shareMedia(version.src)) === "copied") {
|
|
377
|
+
setLinkCopied(true);
|
|
378
|
+
setTimeout(() => setLinkCopied(false), 1500);
|
|
379
|
+
}
|
|
375
380
|
onAction?.("share", version);
|
|
376
381
|
};
|
|
377
382
|
|
|
@@ -509,8 +514,15 @@ export function EditCard({
|
|
|
509
514
|
<button type="button" className="chai-card-top-btn" aria-label="Download" title="Download" disabled={disabled || !ready} onClick={handleDownload}>
|
|
510
515
|
<DownloadIcon />
|
|
511
516
|
</button>
|
|
512
|
-
<button
|
|
513
|
-
|
|
517
|
+
<button
|
|
518
|
+
type="button"
|
|
519
|
+
className="chai-card-top-btn"
|
|
520
|
+
aria-label={linkCopied ? "Link copied" : "Share"}
|
|
521
|
+
title={linkCopied ? "Link copied" : "Share"}
|
|
522
|
+
disabled={disabled || !ready}
|
|
523
|
+
onClick={handleShare}
|
|
524
|
+
>
|
|
525
|
+
{linkCopied ? <CheckSymbolIcon /> : <ShareIcon />}
|
|
514
526
|
</button>
|
|
515
527
|
<button type="button" className="chai-card-top-btn" aria-label="Expand" title="Expand" disabled={!ready} onClick={() => setExpanded(true)}>
|
|
516
528
|
<ExpandIcon />
|
package/src/MediaAnalyzer.tsx
CHANGED
|
@@ -2,9 +2,11 @@ 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";
|
|
5
6
|
import type { DroppedMedia, ModelOption, MediaKind } from "@chai-ui/core";
|
|
6
7
|
import type { MediaAnalysisPromptLength } from "@chai-ui/core";
|
|
7
8
|
import { SearchMenu, type SearchMenuSection } from "./primitives/SearchMenu.js";
|
|
9
|
+
import { StopIcon } from "./icons.js";
|
|
8
10
|
|
|
9
11
|
/** See primitives/Toggle.tsx for why `createComponent` is used instead of raw JSX on custom-element tags. */
|
|
10
12
|
const MdFabElement = createComponent({
|
|
@@ -113,10 +115,15 @@ export interface MediaAnalyzerProps {
|
|
|
113
115
|
onAttachmentsChange: (next: MediaAnalyzerAttachment[]) => void;
|
|
114
116
|
onUnsupportedFile?: (fileName: string) => void;
|
|
115
117
|
maxAttachmentsByKind?: Record<MediaKind, number>;
|
|
116
|
-
/**
|
|
117
|
-
models
|
|
118
|
-
/**
|
|
119
|
-
|
|
118
|
+
/** Fallback roster for a kind `modelsByKind` doesn't list. Optional. */
|
|
119
|
+
models?: ModelOption[];
|
|
120
|
+
/**
|
|
121
|
+
* Per-kind rosters — once a kind is known, only that kind's models show.
|
|
122
|
+
* Before that, all three show as separate sections (reference: Figma "06
|
|
123
|
+
* Composer from Media Analysis"). Defaults to Chai's suggested OpenRouter
|
|
124
|
+
* models (`SUGGESTED_ANALYSIS_MODELS`); pass your own to replace them.
|
|
125
|
+
*/
|
|
126
|
+
modelsByKind?: Partial<Record<MediaKind, ModelOption[]>>;
|
|
120
127
|
/**
|
|
121
128
|
* The picked model. While `autoSelectModel` is on it's `null` until a
|
|
122
129
|
* submit routes, then the routed model, shown by name on the trigger.
|
|
@@ -154,6 +161,8 @@ export interface MediaAnalyzerProps {
|
|
|
154
161
|
submitting?: boolean;
|
|
155
162
|
submitError?: string | null;
|
|
156
163
|
onSubmit: (payload: MediaAnalyzerSubmitPayload) => void;
|
|
164
|
+
/** Fires when the stop button is pressed while `submitting`. With it, submit turns into stop during an analysis (wire it to `useMediaAnalyzer`'s `cancel`); without it, submit is just disabled. */
|
|
165
|
+
onAbort?: () => void;
|
|
157
166
|
}
|
|
158
167
|
|
|
159
168
|
const KIND_LABEL: Record<MediaKind, string> = {
|
|
@@ -175,8 +184,8 @@ export function MediaAnalyzer({
|
|
|
175
184
|
onAttachmentsChange,
|
|
176
185
|
onUnsupportedFile,
|
|
177
186
|
maxAttachmentsByKind = DEFAULT_MAX_ATTACHMENTS_BY_KIND,
|
|
178
|
-
models,
|
|
179
|
-
modelsByKind,
|
|
187
|
+
models = [],
|
|
188
|
+
modelsByKind = SUGGESTED_ANALYSIS_MODELS,
|
|
180
189
|
modelId,
|
|
181
190
|
onModelChange,
|
|
182
191
|
autoSelectModel,
|
|
@@ -188,6 +197,7 @@ export function MediaAnalyzer({
|
|
|
188
197
|
submitting,
|
|
189
198
|
submitError,
|
|
190
199
|
onSubmit,
|
|
200
|
+
onAbort,
|
|
191
201
|
}: MediaAnalyzerProps) {
|
|
192
202
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
193
203
|
const kind = attachments[0]?.kind;
|
|
@@ -199,6 +209,9 @@ export function MediaAnalyzer({
|
|
|
199
209
|
// children. Active for as long as this is > 0; both drag handlers below
|
|
200
210
|
// clamp at 0 so a stray extra `dragleave` can't go negative.
|
|
201
211
|
const [dragDepth, setDragDepth] = useState(0);
|
|
212
|
+
// What the last attach replaced (one kind per analysis), shown until the
|
|
213
|
+
// attachments change some other way.
|
|
214
|
+
const [replaced, setReplaced] = useState<{ note: string; for: MediaAnalyzerAttachment[] } | null>(null);
|
|
202
215
|
const isDragActive = dragDepth > 0;
|
|
203
216
|
|
|
204
217
|
/**
|
|
@@ -221,9 +234,14 @@ export function MediaAnalyzer({
|
|
|
221
234
|
const settle = () => {
|
|
222
235
|
pending -= 1;
|
|
223
236
|
if (pending === 0 && resolved.length > 0) {
|
|
224
|
-
|
|
225
|
-
|
|
237
|
+
const next = resolved.reduce(
|
|
238
|
+
(acc, media) => nextMediaAnalyzerAttachments(acc, media, maxAttachmentsByKind),
|
|
239
|
+
attachments
|
|
226
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);
|
|
227
245
|
}
|
|
228
246
|
};
|
|
229
247
|
files.forEach((file) => {
|
|
@@ -278,6 +296,7 @@ export function MediaAnalyzer({
|
|
|
278
296
|
const selectedLength = promptLengthOptions.find((o) => o.value === promptLength);
|
|
279
297
|
const hasContent = attachments.length > 0;
|
|
280
298
|
const isSubmitDisabled = disabled || !hasContent;
|
|
299
|
+
const showStop = Boolean(submitting && onAbort);
|
|
281
300
|
|
|
282
301
|
return (
|
|
283
302
|
<div className="chai-media-analyzer">
|
|
@@ -338,6 +357,12 @@ export function MediaAnalyzer({
|
|
|
338
357
|
</div>
|
|
339
358
|
)}
|
|
340
359
|
|
|
360
|
+
{replaced && replaced.for === attachments && (
|
|
361
|
+
<p className="chai-media-analyzer__note" role="status">
|
|
362
|
+
{replaced.note}
|
|
363
|
+
</p>
|
|
364
|
+
)}
|
|
365
|
+
|
|
341
366
|
{submitError && (
|
|
342
367
|
<p className="chai-media-analyzer__error" role="alert">
|
|
343
368
|
{submitError}
|
|
@@ -390,14 +415,16 @@ export function MediaAnalyzer({
|
|
|
390
415
|
size="small"
|
|
391
416
|
variant="secondary"
|
|
392
417
|
label=""
|
|
393
|
-
data-ready={!isSubmitDisabled || undefined}
|
|
418
|
+
data-ready={showStop || !isSubmitDisabled || undefined}
|
|
394
419
|
data-error={Boolean(submitError) || undefined}
|
|
420
|
+
data-stop={showStop || undefined}
|
|
395
421
|
{...({
|
|
396
|
-
ariaLabel: submitError ? "Retry" : "Analyze media",
|
|
397
|
-
ariaDisabled: submitting ? "true" : isSubmitDisabled ? "true" : "false",
|
|
422
|
+
ariaLabel: showStop ? "Stop" : submitError ? "Retry" : "Analyze media",
|
|
423
|
+
ariaDisabled: showStop ? "false" : submitting ? "true" : isSubmitDisabled ? "true" : "false",
|
|
398
424
|
} as Record<string, unknown>)}
|
|
399
425
|
onClick={() => {
|
|
400
|
-
if (
|
|
426
|
+
if (showStop) onAbort?.();
|
|
427
|
+
else if (!isSubmitDisabled && !submitting) onSubmit({
|
|
401
428
|
attachments,
|
|
402
429
|
modelId,
|
|
403
430
|
autoSelectModel,
|
|
@@ -406,9 +433,7 @@ export function MediaAnalyzer({
|
|
|
406
433
|
});
|
|
407
434
|
}}
|
|
408
435
|
>
|
|
409
|
-
<span slot="icon">
|
|
410
|
-
<ArrowForwardIcon />
|
|
411
|
-
</span>
|
|
436
|
+
<span slot="icon">{showStop ? <StopIcon /> : <ArrowForwardIcon />}</span>
|
|
412
437
|
</MdFabElement>
|
|
413
438
|
</div>
|
|
414
439
|
</div>
|
|
@@ -447,6 +472,14 @@ function MediaAnalyzerThumbnail({
|
|
|
447
472
|
// Same "trace and inline as a currentColor component" convention Composer.tsx
|
|
448
473
|
// documents — icons aren't shared across files yet in this codebase.
|
|
449
474
|
|
|
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
|
+
|
|
450
483
|
function ArrowForwardIcon() {
|
|
451
484
|
return (
|
|
452
485
|
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
package/src/ResultCard.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
2
|
import { ROUTING_MODEL_ID, type ModelOption, type Result } from "@chai-ui/core";
|
|
3
3
|
import { Pagination } from "./primitives/Pagination.js";
|
|
4
|
-
import { DownloadIcon, ExpandIcon, ShareIcon } from "./icons.js";
|
|
4
|
+
import { CheckSymbolIcon, DownloadIcon, ExpandIcon, ShareIcon } from "./icons.js";
|
|
5
5
|
import { downloadFilename, downloadMedia, shareMedia } from "./media-actions.js";
|
|
6
6
|
import { PageSlide, usePageSlide } from "./page-slide.js";
|
|
7
7
|
|
|
@@ -125,6 +125,7 @@ export function ResultCard({
|
|
|
125
125
|
const [flipped, setFlipped] = useState(false);
|
|
126
126
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
127
127
|
const [copied, setCopied] = useState(false);
|
|
128
|
+
const [linkCopied, setLinkCopied] = useState(false);
|
|
128
129
|
const [mediaExpanded, setMediaExpanded] = useState(false);
|
|
129
130
|
// Not part of `Result` (no engine reports generated-media dimensions
|
|
130
131
|
// today) — measured client-side off the loaded `<img>`/`<video>` itself
|
|
@@ -238,7 +239,11 @@ export function ResultCard({
|
|
|
238
239
|
};
|
|
239
240
|
|
|
240
241
|
const handleShare = async () => {
|
|
241
|
-
|
|
242
|
+
// With no share sheet the link is copied; say so, or the click looks like nothing happened.
|
|
243
|
+
if (result.output && (await shareMedia(result.output.src)) === "copied") {
|
|
244
|
+
setLinkCopied(true);
|
|
245
|
+
setTimeout(() => setLinkCopied(false), 1500);
|
|
246
|
+
}
|
|
242
247
|
onAction("share", result);
|
|
243
248
|
};
|
|
244
249
|
|
|
@@ -274,6 +279,7 @@ export function ResultCard({
|
|
|
274
279
|
mediaExpanded={mediaExpanded}
|
|
275
280
|
onDownload={handleDownload}
|
|
276
281
|
onShare={handleShare}
|
|
282
|
+
linkCopied={linkCopied}
|
|
277
283
|
onExpand={() => setMediaExpanded(true)}
|
|
278
284
|
onDimensions={(w, h) => setMeasured({ id: result.id, width: w, height: h })}
|
|
279
285
|
videoTimeRef={videoTimeRef}
|
|
@@ -458,6 +464,7 @@ function ResultMedia({
|
|
|
458
464
|
mediaExpanded,
|
|
459
465
|
onDownload,
|
|
460
466
|
onShare,
|
|
467
|
+
linkCopied,
|
|
461
468
|
onExpand,
|
|
462
469
|
onDimensions,
|
|
463
470
|
videoTimeRef,
|
|
@@ -470,6 +477,7 @@ function ResultMedia({
|
|
|
470
477
|
mediaExpanded: boolean;
|
|
471
478
|
onDownload: () => void;
|
|
472
479
|
onShare: () => void;
|
|
480
|
+
linkCopied: boolean;
|
|
473
481
|
onExpand: () => void;
|
|
474
482
|
onDimensions: (width: number, height: number) => void;
|
|
475
483
|
videoTimeRef: React.MutableRefObject<number>;
|
|
@@ -555,8 +563,8 @@ function ResultMedia({
|
|
|
555
563
|
<ResultIconButton variant="top" label="Download" disabled={disabled} onClick={onDownload}>
|
|
556
564
|
<DownloadIcon />
|
|
557
565
|
</ResultIconButton>
|
|
558
|
-
<ResultIconButton variant="top" label="Share" disabled={disabled} onClick={onShare}>
|
|
559
|
-
<ShareIcon />
|
|
566
|
+
<ResultIconButton variant="top" label={linkCopied ? "Link copied" : "Share"} disabled={disabled} onClick={onShare}>
|
|
567
|
+
{linkCopied ? <CheckSymbolIcon /> : <ShareIcon />}
|
|
560
568
|
</ResultIconButton>
|
|
561
569
|
{(isVideo || isImage) && (
|
|
562
570
|
<ResultIconButton variant="top" label="Expand" disabled={disabled} onClick={onExpand}>
|
package/src/icons.tsx
CHANGED
|
@@ -113,3 +113,17 @@ export function CloseSymbolIcon() {
|
|
|
113
113
|
</svg>
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
// Real Material Symbols "stop" glyph, the filled variant specifically
|
|
118
|
+
// (`stop-fill`, not the base `stop` — that one traces a hollow square-
|
|
119
|
+
// outline shape even filled with `currentColor`, which read as a thin
|
|
120
|
+
// frame rather than a real stop icon at 24px; design review caught
|
|
121
|
+
// this live). Viewbox `0 -960 960 960` — the submit FAB's in-flight state,
|
|
122
|
+
// its own doc comment above where it's rendered.
|
|
123
|
+
export function StopIcon() {
|
|
124
|
+
return (
|
|
125
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
126
|
+
<path d="M240-240v-480h480v480H240Z" fill="currentColor" />
|
|
127
|
+
</svg>
|
|
128
|
+
);
|
|
129
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,13 @@ export {
|
|
|
22
22
|
DEFAULT_MEDIA_ANALYSIS_PROMPT_LENGTH,
|
|
23
23
|
MEDIA_ANALYSIS_PROMPTS,
|
|
24
24
|
MEDIA_ANALYSIS_PROMPT_LENGTHS,
|
|
25
|
+
SUGGESTED_IMAGE_MODELS,
|
|
26
|
+
SUGGESTED_TEXT_TO_VIDEO_MODELS,
|
|
27
|
+
SUGGESTED_IMAGE_TO_VIDEO_MODELS,
|
|
28
|
+
SUGGESTED_EDIT_MODELS,
|
|
29
|
+
SUGGESTED_ANALYSIS_MODELS,
|
|
30
|
+
suggestedModels,
|
|
31
|
+
checkSuggestedModels,
|
|
25
32
|
} from "@chai-ui/core";
|
|
26
33
|
export type {
|
|
27
34
|
DroppedMedia,
|
|
@@ -40,5 +47,8 @@ export type {
|
|
|
40
47
|
Result,
|
|
41
48
|
ResultStatus,
|
|
42
49
|
Run,
|
|
50
|
+
SuggestedModelProblem,
|
|
51
|
+
SuggestedModelsCheck,
|
|
52
|
+
FalInputFormat,
|
|
43
53
|
Usage,
|
|
44
54
|
} from "@chai-ui/core";
|
package/src/media-actions.ts
CHANGED
|
@@ -24,16 +24,25 @@ export async function downloadMedia(src: string, filename: string): Promise<void
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
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: string): Promise<"shared" | "copied" | "none"> {
|
|
29
32
|
if (navigator.share) {
|
|
30
33
|
try {
|
|
31
34
|
await navigator.share({ url: src });
|
|
35
|
+
return "shared";
|
|
32
36
|
} catch {
|
|
33
37
|
// Dismissed, or not shareable: same as closing a native share sheet.
|
|
38
|
+
return "none";
|
|
34
39
|
}
|
|
35
|
-
}
|
|
36
|
-
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
await navigator.clipboard.writeText(src);
|
|
43
|
+
return "copied";
|
|
44
|
+
} catch {
|
|
45
|
+
return "none";
|
|
37
46
|
}
|
|
38
47
|
}
|
|
39
48
|
|
package/src/style.css
CHANGED
|
@@ -1178,11 +1178,26 @@
|
|
|
1178
1178
|
--md-fab-secondary-icon-color: var(--chai-media-analyzer-text);
|
|
1179
1179
|
}
|
|
1180
1180
|
|
|
1181
|
+
/* While analyzing, with `onAbort`: submit becomes stop, same as the Composer's.
|
|
1182
|
+
Before `[data-error]`, so an error still wins. */
|
|
1183
|
+
.chai-media-analyzer__submit[data-stop] {
|
|
1184
|
+
--md-fab-secondary-container-color: var(--chai-color-semantic-warning-subtle);
|
|
1185
|
+
--md-fab-secondary-icon-color: var(--chai-color-semantic-warning);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1181
1188
|
.chai-media-analyzer__submit[data-error] {
|
|
1182
1189
|
--md-fab-secondary-container-color: var(--chai-color-semantic-danger-subtle);
|
|
1183
1190
|
--md-fab-secondary-icon-color: var(--chai-color-semantic-danger);
|
|
1184
1191
|
}
|
|
1185
1192
|
|
|
1193
|
+
/* What the last attach replaced (one kind per analysis). */
|
|
1194
|
+
.chai-media-analyzer__note {
|
|
1195
|
+
margin: 0;
|
|
1196
|
+
padding: 2px 2px 0;
|
|
1197
|
+
color: var(--chai-media-analyzer-text);
|
|
1198
|
+
font-size: var(--chai-type-size-label-large);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1186
1201
|
.chai-media-analyzer__error {
|
|
1187
1202
|
margin: 0;
|
|
1188
1203
|
padding: 2px 2px 0;
|