@chai-ui/vue 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/DESIGN.md +235 -0
  2. package/LICENSE +21 -0
  3. package/README.md +122 -2
  4. package/dist/ChaiProvider.vue.d.ts +24 -0
  5. package/dist/ChaiProvider.vue.d.ts.map +1 -0
  6. package/dist/Composer.vue.d.ts +94 -0
  7. package/dist/Composer.vue.d.ts.map +1 -0
  8. package/dist/EditCard.vue.d.ts +38 -0
  9. package/dist/EditCard.vue.d.ts.map +1 -0
  10. package/dist/MediaAnalyzer.vue.d.ts +54 -0
  11. package/dist/MediaAnalyzer.vue.d.ts.map +1 -0
  12. package/dist/PageSlide.vue.d.ts +23 -0
  13. package/dist/PageSlide.vue.d.ts.map +1 -0
  14. package/dist/ResultCard.vue.d.ts +29 -0
  15. package/dist/ResultCard.vue.d.ts.map +1 -0
  16. package/dist/composer/AttachMenu.vue.d.ts +17 -0
  17. package/dist/composer/AttachMenu.vue.d.ts.map +1 -0
  18. package/dist/composer/Chip.vue.d.ts +14 -0
  19. package/dist/composer/Chip.vue.d.ts.map +1 -0
  20. package/dist/dismiss.d.ts +4 -0
  21. package/dist/dismiss.d.ts.map +1 -0
  22. package/dist/edit-card/ExpandedImage.vue.d.ts +12 -0
  23. package/dist/edit-card/ExpandedImage.vue.d.ts.map +1 -0
  24. package/dist/edit-card/RegionShape.vue.d.ts +20 -0
  25. package/dist/edit-card/RegionShape.vue.d.ts.map +1 -0
  26. package/dist/icons.d.ts +43 -0
  27. package/dist/icons.d.ts.map +1 -0
  28. package/dist/index.d.ts +16 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +2515 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/page-slide.d.ts +31 -0
  33. package/dist/page-slide.d.ts.map +1 -0
  34. package/dist/primitives/Pagination.vue.d.ts +26 -0
  35. package/dist/primitives/Pagination.vue.d.ts.map +1 -0
  36. package/dist/primitives/SearchMenu.vue.d.ts +51 -0
  37. package/dist/primitives/SearchMenu.vue.d.ts.map +1 -0
  38. package/dist/primitives/Toggle.vue.d.ts +16 -0
  39. package/dist/primitives/Toggle.vue.d.ts.map +1 -0
  40. package/dist/primitives/search-menu.d.ts +19 -0
  41. package/dist/primitives/search-menu.d.ts.map +1 -0
  42. package/dist/reasoning.d.ts +6 -0
  43. package/dist/reasoning.d.ts.map +1 -0
  44. package/dist/result-card/IconButton.vue.d.ts +21 -0
  45. package/dist/result-card/IconButton.vue.d.ts.map +1 -0
  46. package/dist/result-card/MoreMenu.vue.d.ts +13 -0
  47. package/dist/result-card/MoreMenu.vue.d.ts.map +1 -0
  48. package/dist/result-card/ResultMedia.vue.d.ts +28 -0
  49. package/dist/result-card/ResultMedia.vue.d.ts.map +1 -0
  50. package/dist/result-card/ResultText.vue.d.ts +8 -0
  51. package/dist/result-card/ResultText.vue.d.ts.map +1 -0
  52. package/dist/result-card/VideoPlayer.vue.d.ts +18 -0
  53. package/dist/result-card/VideoPlayer.vue.d.ts.map +1 -0
  54. package/dist/result-card/types.d.ts +9 -0
  55. package/dist/result-card/types.d.ts.map +1 -0
  56. package/dist/style.css +2378 -0
  57. package/dist/useComposer.d.ts +22 -0
  58. package/dist/useComposer.d.ts.map +1 -0
  59. package/dist/useMediaAnalyzer.d.ts +17 -0
  60. package/dist/useMediaAnalyzer.d.ts.map +1 -0
  61. package/package.json +79 -3
  62. package/src/ChaiProvider.vue +26 -0
  63. package/src/Composer.vue +362 -0
  64. package/src/EditCard.vue +520 -0
  65. package/src/MediaAnalyzer.vue +216 -0
  66. package/src/PageSlide.vue +36 -0
  67. package/src/ResultCard.vue +297 -0
  68. package/src/composer/AttachMenu.vue +66 -0
  69. package/src/composer/Chip.vue +26 -0
  70. package/src/dismiss.ts +22 -0
  71. package/src/edit-card/ExpandedImage.vue +39 -0
  72. package/src/edit-card/RegionShape.vue +39 -0
  73. package/src/icons.ts +55 -0
  74. package/src/index.ts +77 -0
  75. package/src/page-slide.ts +32 -0
  76. package/src/primitives/Pagination.vue +76 -0
  77. package/src/primitives/SearchMenu.vue +167 -0
  78. package/src/primitives/Toggle.vue +23 -0
  79. package/src/primitives/search-menu.ts +20 -0
  80. package/src/reasoning.ts +10 -0
  81. package/src/result-card/IconButton.vue +18 -0
  82. package/src/result-card/MoreMenu.vue +35 -0
  83. package/src/result-card/ResultMedia.vue +77 -0
  84. package/src/result-card/ResultText.vue +37 -0
  85. package/src/result-card/VideoPlayer.vue +90 -0
  86. package/src/result-card/types.ts +10 -0
  87. package/src/useComposer.ts +38 -0
  88. package/src/useMediaAnalyzer.ts +33 -0
@@ -0,0 +1,520 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The image editing card: the person marks regions on the image, gives each
4
+ * one an instruction, and checks it into the Composer. It shares
5
+ * ResultCard's frame, with region actions where the votes would be.
6
+ *
7
+ * Drop it in with `useComposer`'s `edit`: `v-model:active-version` and
8
+ * `v-model:regions`, sharing the regions with the Composer's. Each edit's
9
+ * result comes back as a new version. Same props as React's `EditCard`.
10
+ *
11
+ * - "New region" adds a box with a prompt field. Check adds the region to
12
+ * the Composer; trash removes it. Clicking a region reopens its field.
13
+ * - Drag a region to move it, or a corner to resize it. By keyboard:
14
+ * arrows move the selected region, Shift + arrows resize it.
15
+ * - Zoom keeps the image inside the card; drag (or arrow keys) to pan.
16
+ */
17
+ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
18
+ import {
19
+ clampPan,
20
+ DEFAULT_MAX_REGIONS,
21
+ downloadFilename,
22
+ downloadMedia,
23
+ EDIT_ZOOM_STEPS,
24
+ editCardWidth,
25
+ fitBox,
26
+ freeRegionNumber,
27
+ newRegion,
28
+ panForKey,
29
+ panForZoom,
30
+ regionBoxForKey,
31
+ regionBoxStyle,
32
+ regionPopoverPosition,
33
+ resizeBox,
34
+ shareMedia,
35
+ type EditRegion,
36
+ type EditVersion,
37
+ type Pan,
38
+ type RegionBox,
39
+ type RegionCorner,
40
+ type Size,
41
+ } from "@chai-ui/core";
42
+ import ExpandedImage from "./edit-card/ExpandedImage.vue";
43
+ import RegionShape from "./edit-card/RegionShape.vue";
44
+ import {
45
+ AddIcon,
46
+ CheckSymbolIcon,
47
+ CloseSymbolIcon,
48
+ DeleteIcon,
49
+ DownloadIcon,
50
+ ExpandIcon,
51
+ ShareIcon,
52
+ VisibilityIcon,
53
+ VisibilityOffIcon,
54
+ ZoomInIcon,
55
+ ZoomOutIcon,
56
+ } from "./icons.js";
57
+ import PageSlide from "./PageSlide.vue";
58
+ import { usePageSlide } from "./page-slide.js";
59
+ import Pagination from "./primitives/Pagination.vue";
60
+
61
+ const props = withDefaults(
62
+ defineProps<{
63
+ /** The original image first, then each edit. Paged with the dots in the card's bottom row. */
64
+ versions: EditVersion[];
65
+ activeVersion: number;
66
+ /** The new regions on the image, each committed with its instruction. Share them with the Composer's `regions`. */
67
+ regions: EditRegion[];
68
+ /** The most regions the chosen model takes (`ModelOption.maxRegions`). "New region" is disabled at this many. */
69
+ maxRegions?: number;
70
+ /** A fixed starting width in px, fitted within `maxWidth` and `maxHeight`. */
71
+ width?: number;
72
+ /** Sizes the card from the image's own pixel width: `0.3` makes it 30% as wide as the image. */
73
+ scale?: number;
74
+ /** The widest the card gets, in px. With no `width` or `scale`, the card fills `maxWidth` × `maxHeight`, keeping the image's shape. */
75
+ maxWidth?: number;
76
+ /** The tallest the card gets, in px. */
77
+ maxHeight?: number;
78
+ /** Alt text for the image. */
79
+ alt?: string;
80
+ disabled?: boolean;
81
+ }>(),
82
+ { maxRegions: DEFAULT_MAX_REGIONS, maxWidth: 630, maxHeight: 630, alt: "Image being edited" }
83
+ );
84
+ const emit = defineEmits<{
85
+ "update:activeVersion": [index: number];
86
+ "update:regions": [next: EditRegion[]];
87
+ /** After the card downloads or shares the version showing. */
88
+ action: [action: "download" | "share", version: EditVersion];
89
+ }>();
90
+
91
+ type Drag =
92
+ | { kind: "move" | RegionCorner; id: string; x: number; y: number; box: RegionBox; moved: boolean }
93
+ | { kind: "pan"; x: number; y: number; pan: Pan; moved: boolean };
94
+
95
+ const index = computed(() => Math.min(Math.max(props.activeVersion, 0), Math.max(0, props.versions.length - 1)));
96
+ const version = computed(() => props.versions[index.value]);
97
+ const ready = computed(() => version.value?.status === "done" && Boolean(version.value.src));
98
+ const paginated = computed(() => props.versions.length > 1);
99
+ const hasImage = computed(() => Boolean(version.value?.src || version.value?.from));
100
+
101
+ // A region being drawn, not yet checked into the Composer.
102
+ const draft = ref<EditRegion | null>(null);
103
+ const selectedId = ref<string | null>(null);
104
+ const text = ref("");
105
+ const hidden = ref(false);
106
+ const zoom = ref(1);
107
+ const pan = ref<Pan>({ x: 0, y: 0 });
108
+ const size = ref<Size>({ width: 0, height: 0 });
109
+ // The shown image's own pixel size, kept until the next version's image loads, so the card doesn't jump between.
110
+ const natural = ref<Size | null>(null);
111
+ const expanded = ref(false);
112
+ const linkCopied = ref(false);
113
+ let drag: Drag | null = null;
114
+ const viewport = ref<HTMLElement>();
115
+ const field = ref<HTMLTextAreaElement>();
116
+
117
+ // Paging slides between versions, and a different version starts clean: no half-drawn region, no zoom.
118
+ const { slide, endSlide } = usePageSlide(() => ({
119
+ id: version.value?.id,
120
+ index: index.value,
121
+ image: version.value?.src ?? version.value?.from,
122
+ }));
123
+ watch(
124
+ () => version.value?.id,
125
+ () => {
126
+ draft.value = null;
127
+ selectedId.value = null;
128
+ zoom.value = 1;
129
+ pan.value = { x: 0, y: 0 };
130
+ }
131
+ );
132
+
133
+ let observer: ResizeObserver | undefined;
134
+ onMounted(() => {
135
+ observer = new ResizeObserver(([entry]) => {
136
+ if (entry) size.value = { width: entry.contentRect.width, height: entry.contentRect.height };
137
+ });
138
+ if (viewport.value) observer.observe(viewport.value);
139
+ });
140
+ onUnmounted(() => observer?.disconnect());
141
+
142
+ // A region being drawn takes a free number, even if the regions changed
143
+ // since it was started (so checking it in can't duplicate a number).
144
+ const shownDraft = computed(() => {
145
+ const d = draft.value;
146
+ return d && props.regions.some((r) => r.number === d.number) ? { ...d, number: freeRegionNumber(props.regions) } : d;
147
+ });
148
+ const all = computed(() => (shownDraft.value ? [...props.regions, shownDraft.value] : props.regions));
149
+ const selected = computed(() => all.value.find((r) => r.id === selectedId.value) ?? null);
150
+ const isDraft = computed(() => selected.value != null && selected.value.id === draft.value?.id);
151
+ const canCheck = computed(
152
+ () => selected.value != null && text.value.trim().length > 0 && (isDraft.value || text.value.trim() !== selected.value.prompt)
153
+ );
154
+ const atCap = computed(() => all.value.length >= props.maxRegions);
155
+ const anyRegions = computed(() => all.value.length > 0 || (version.value?.regions.length ?? 0) > 0);
156
+ const zoomIndex = computed(() => EDIT_ZOOM_STEPS.indexOf(zoom.value));
157
+ // The field sits above its region, or below it when there isn't room above.
158
+ const popover = computed(() =>
159
+ selected.value && !hidden.value && size.value.width
160
+ ? regionPopoverPosition(selected.value.box, zoom.value, pan.value, size.value, paginated.value)
161
+ : null
162
+ );
163
+ const cardWidth = computed(() =>
164
+ editCardWidth(natural.value, { width: props.width, scale: props.scale, maxWidth: props.maxWidth, maxHeight: props.maxHeight })
165
+ );
166
+
167
+ // The field opens focused, so a new region can be described straight away.
168
+ watch(selectedId, (id) => id && field.value?.focus(), { flush: "post" });
169
+ // The field grows with its text instead of scrolling it out of view.
170
+ watch(
171
+ [text, selectedId],
172
+ () => {
173
+ const el = field.value;
174
+ if (!el) return;
175
+ el.style.height = "auto";
176
+ el.style.height = `${el.scrollHeight}px`;
177
+ },
178
+ { flush: "post" }
179
+ );
180
+
181
+ function select(region: EditRegion | null) {
182
+ selectedId.value = region?.id ?? null;
183
+ text.value = region?.prompt ?? "";
184
+ }
185
+
186
+ function setBox(id: string, box: RegionBox) {
187
+ if (draft.value?.id === id) draft.value = { ...draft.value, box };
188
+ else emit("update:regions", props.regions.map((r) => (r.id === id ? { ...r, box } : r)));
189
+ }
190
+
191
+ function removeRegion(region: EditRegion) {
192
+ if (region.id === draft.value?.id) draft.value = null;
193
+ else emit("update:regions", props.regions.filter((r) => r.id !== region.id));
194
+ select(null);
195
+ }
196
+
197
+ function addRegion() {
198
+ const region = newRegion(props.regions, zoom.value, pan.value, size.value);
199
+ draft.value = region;
200
+ hidden.value = false;
201
+ select(region);
202
+ }
203
+
204
+ function check() {
205
+ const region = selected.value;
206
+ if (!region || !canCheck.value) return;
207
+ const prompt = text.value.trim();
208
+ if (isDraft.value) {
209
+ emit("update:regions", [...props.regions, { ...region, prompt }]);
210
+ draft.value = null;
211
+ } else {
212
+ emit("update:regions", props.regions.map((r) => (r.id === region.id ? { ...r, prompt } : r)));
213
+ }
214
+ select(null);
215
+ }
216
+
217
+ function clearAll() {
218
+ draft.value = null;
219
+ select(null);
220
+ emit("update:regions", []);
221
+ }
222
+
223
+ function toggleHidden() {
224
+ hidden.value = !hidden.value;
225
+ select(null);
226
+ }
227
+
228
+ function zoomTo(next: number) {
229
+ pan.value = panForZoom(pan.value, zoom.value, next, size.value);
230
+ zoom.value = next;
231
+ }
232
+
233
+ // --- Pointer: move and resize regions, pan the zoomed image. Moves and
234
+ // releases bubble to the viewport, even while a region or handle holds the
235
+ // pointer capture, so the viewport handles them all.
236
+
237
+ function startRegionDrag(e: PointerEvent, region: EditRegion, kind: "move" | RegionCorner) {
238
+ if (props.disabled || e.button !== 0) return;
239
+ e.stopPropagation();
240
+ (e.currentTarget as Element).setPointerCapture(e.pointerId);
241
+ drag = { kind, id: region.id, x: e.clientX, y: e.clientY, box: region.box, moved: false };
242
+ if (region.id !== selectedId.value) select(region);
243
+ }
244
+
245
+ function startPan(e: PointerEvent) {
246
+ if (e.button !== 0) return;
247
+ if (zoom.value > 1) (e.currentTarget as Element).setPointerCapture(e.pointerId);
248
+ drag = { kind: "pan", x: e.clientX, y: e.clientY, pan: pan.value, moved: false };
249
+ }
250
+
251
+ function onPointerMove(e: PointerEvent) {
252
+ const d = drag;
253
+ const { width, height } = size.value;
254
+ if (!d || !width) return;
255
+ const dxPx = e.clientX - d.x;
256
+ const dyPx = e.clientY - d.y;
257
+ if (Math.abs(dxPx) + Math.abs(dyPx) > 2) d.moved = true;
258
+ if (d.kind === "pan") {
259
+ if (zoom.value > 1) pan.value = clampPan({ x: d.pan.x + dxPx, y: d.pan.y + dyPx }, zoom.value, size.value);
260
+ return;
261
+ }
262
+ const dx = dxPx / (width * zoom.value);
263
+ const dy = dyPx / (height * zoom.value);
264
+ setBox(d.id, d.kind === "move" ? fitBox({ ...d.box, x: d.box.x + dx, y: d.box.y + dy }) : resizeBox(d.box, d.kind, dx, dy));
265
+ }
266
+
267
+ function onPointerUp() {
268
+ const d = drag;
269
+ drag = null;
270
+ // A click on the image itself (not a drag) closes the open field.
271
+ if (d?.kind === "pan" && !d.moved) select(null);
272
+ }
273
+
274
+ // --- Keyboard
275
+
276
+ function onRegionKey(e: KeyboardEvent, region: EditRegion) {
277
+ const box = regionBoxForKey(region.box, e.key, { shift: e.shiftKey, alt: e.altKey });
278
+ if (box) {
279
+ e.preventDefault();
280
+ e.stopPropagation();
281
+ setBox(region.id, box);
282
+ } else if (e.key === "Delete" || e.key === "Backspace") {
283
+ e.preventDefault();
284
+ removeRegion(region);
285
+ }
286
+ }
287
+
288
+ function onViewportKey(e: KeyboardEvent) {
289
+ if (e.target !== e.currentTarget || zoom.value <= 1) return;
290
+ const next = panForKey(pan.value, e.key, zoom.value, size.value);
291
+ if (!next) return;
292
+ e.preventDefault();
293
+ pan.value = next;
294
+ }
295
+
296
+ function onFieldKey(e: KeyboardEvent) {
297
+ if (e.key === "Enter" && !e.shiftKey) {
298
+ e.preventDefault();
299
+ check();
300
+ } else if (e.key === "Escape") {
301
+ e.preventDefault();
302
+ if (isDraft.value) draft.value = null;
303
+ select(null);
304
+ }
305
+ }
306
+
307
+ // --- Media actions
308
+
309
+ async function download() {
310
+ const v = version.value;
311
+ if (!v?.src) return;
312
+ await downloadMedia(v.src, downloadFilename(v.id, "image", v.src));
313
+ emit("action", "download", v);
314
+ }
315
+
316
+ async function share() {
317
+ const v = version.value;
318
+ if (!v?.src) return;
319
+ // With no share sheet the link is copied; say so, or the click looks like nothing happened.
320
+ if ((await shareMedia(v.src)) === "copied") {
321
+ linkCopied.value = true;
322
+ setTimeout(() => (linkCopied.value = false), 1500);
323
+ }
324
+ emit("action", "share", v);
325
+ }
326
+
327
+ function onImageLoad(e: Event) {
328
+ const img = e.currentTarget as HTMLImageElement;
329
+ natural.value = { width: img.naturalWidth, height: img.naturalHeight };
330
+ }
331
+ </script>
332
+
333
+ <template>
334
+ <template v-if="version">
335
+ <div class="chai-edit-card" :style="{ '--chai-edit-card-width': `${cardWidth}px` }">
336
+ <div
337
+ ref="viewport"
338
+ :class="['chai-edit-card__viewport', { 'chai-edit-card__viewport--zoomed': zoom > 1 }]"
339
+ role="group"
340
+ :aria-label="zoom > 1 ? `Image, zoomed to ${Math.round(zoom * 100)}%. Arrow keys move around.` : 'Image'"
341
+ :tabindex="zoom > 1 ? 0 : undefined"
342
+ @pointerdown="startPan"
343
+ @pointermove="onPointerMove"
344
+ @pointerup="onPointerUp"
345
+ @pointercancel="onPointerUp"
346
+ @keydown="onViewportKey"
347
+ >
348
+ <PageSlide :slide="slide" @end="endSlide">
349
+ <div class="chai-edit-card__layer" :style="{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, '--chai-edit-zoom': zoom }">
350
+ <img
351
+ v-if="version.src || version.from"
352
+ class="chai-edit-card__image"
353
+ :src="version.src ?? version.from"
354
+ :alt="alt"
355
+ draggable="false"
356
+ @load="onImageLoad"
357
+ />
358
+ <div v-else class="chai-edit-card__placeholder" />
359
+
360
+ <template v-if="!hidden">
361
+ <span
362
+ v-for="r in version.regions"
363
+ :key="`applied-${r.id}`"
364
+ class="chai-edit-card__region chai-edit-card__region--applied"
365
+ :style="regionBoxStyle(r)"
366
+ aria-hidden="true"
367
+ />
368
+ <template v-if="ready">
369
+ <RegionShape
370
+ v-for="r in all"
371
+ :key="r.id"
372
+ :region="r"
373
+ :selected="r.id === selectedId"
374
+ :draft="r.id === draft?.id"
375
+ :disabled="disabled"
376
+ @drag-start="(e, kind) => startRegionDrag(e, r, kind)"
377
+ @select="select(r)"
378
+ @keydown="onRegionKey($event, r)"
379
+ />
380
+ </template>
381
+ </template>
382
+ </div>
383
+ </PageSlide>
384
+
385
+ <div v-if="version.status !== 'done'" class="chai-edit-card__status" role="status">
386
+ <span v-if="version.status === 'queued' || version.status === 'running'" class="chai-edit-card__spinner" role="img" aria-label="Editing" />
387
+ <p v-else class="chai-edit-card__status-text">{{ version.status === "cancelled" ? "Stopped" : (version.error ?? "Something went wrong.") }}</p>
388
+ </div>
389
+
390
+ <!-- Translucent backings keep these readable on any image. -->
391
+ <div class="chai-card-top chai-card-top--start" @pointerdown.stop>
392
+ <button
393
+ type="button"
394
+ class="chai-card-top-btn"
395
+ aria-label="Zoom in"
396
+ title="Zoom in"
397
+ :disabled="!ready || zoomIndex >= EDIT_ZOOM_STEPS.length - 1"
398
+ @click="zoomTo(EDIT_ZOOM_STEPS[zoomIndex + 1]!)"
399
+ >
400
+ <ZoomInIcon />
401
+ </button>
402
+ <span class="chai-edit-card__zoom-level" aria-live="polite">{{ Math.round(zoom * 100) }}%</span>
403
+ <button
404
+ type="button"
405
+ class="chai-card-top-btn"
406
+ aria-label="Zoom out"
407
+ title="Zoom out"
408
+ :disabled="!ready || zoomIndex <= 0"
409
+ @click="zoomTo(EDIT_ZOOM_STEPS[zoomIndex - 1]!)"
410
+ >
411
+ <ZoomOutIcon />
412
+ </button>
413
+ </div>
414
+
415
+ <div class="chai-card-top chai-card-top--end" @pointerdown.stop>
416
+ <button type="button" class="chai-card-top-btn" aria-label="Download" title="Download" :disabled="disabled || !ready" @click="download">
417
+ <DownloadIcon />
418
+ </button>
419
+ <button
420
+ type="button"
421
+ class="chai-card-top-btn"
422
+ :aria-label="linkCopied ? 'Link copied' : 'Share'"
423
+ :title="linkCopied ? 'Link copied' : 'Share'"
424
+ :disabled="disabled || !ready"
425
+ @click="share"
426
+ >
427
+ <CheckSymbolIcon v-if="linkCopied" /><ShareIcon v-else />
428
+ </button>
429
+ <button type="button" class="chai-card-top-btn" aria-label="Expand" title="Expand" :disabled="!ready" @click="expanded = true">
430
+ <ExpandIcon />
431
+ </button>
432
+ </div>
433
+
434
+ <div v-if="paginated && hasImage" class="chai-card-fade" />
435
+
436
+ <!-- Over the bottom of the image (it fills the card): the region actions. -->
437
+ <div class="chai-card-bottom" @pointerdown.stop>
438
+ <div class="chai-card-actions">
439
+ <div class="chai-card-chips">
440
+ <button
441
+ type="button"
442
+ class="chai-card-chip"
443
+ :disabled="disabled || !ready || atCap"
444
+ :title="atCap ? `This model takes up to ${maxRegions} regions` : undefined"
445
+ @click="addRegion"
446
+ >
447
+ <AddIcon />New region
448
+ </button>
449
+ <button type="button" class="chai-card-chip" :disabled="disabled || all.length === 0" @click="clearAll">
450
+ <CloseSymbolIcon />Clear regions
451
+ </button>
452
+ </div>
453
+ <!-- Named, and given a tooltip, by what a press will do. -->
454
+ <button
455
+ type="button"
456
+ class="chai-card-round"
457
+ :aria-label="hidden ? 'Show regions' : 'Hide regions'"
458
+ :title="hidden ? 'Show regions' : 'Hide regions'"
459
+ :disabled="!anyRegions"
460
+ @click="toggleHidden"
461
+ >
462
+ <VisibilityOffIcon v-if="hidden" /><VisibilityIcon v-else />
463
+ </button>
464
+ </div>
465
+ </div>
466
+
467
+ <div
468
+ v-if="popover && selected"
469
+ :class="['chai-edit-card__popover', { 'chai-edit-card__popover--above': popover.above }]"
470
+ :style="{ left: `${popover.left}px`, top: `${popover.top}px` }"
471
+ role="dialog"
472
+ :aria-label="`Region ${selected.number}`"
473
+ @pointerdown.stop
474
+ >
475
+ <div class="chai-edit-card__popover-header">
476
+ <span class="chai-edit-card__popover-title">Region {{ selected.number }}</span>
477
+ <button
478
+ type="button"
479
+ class="chai-card-top-btn"
480
+ aria-label="Add region to prompt"
481
+ title="Add region to prompt"
482
+ :disabled="disabled || !canCheck"
483
+ @click="check"
484
+ >
485
+ <CheckSymbolIcon />
486
+ </button>
487
+ <button type="button" class="chai-card-top-btn" aria-label="Delete region" title="Delete region" :disabled="disabled" @click="removeRegion(selected)">
488
+ <DeleteIcon />
489
+ </button>
490
+ </div>
491
+ <textarea
492
+ ref="field"
493
+ v-model="text"
494
+ class="chai-edit-card__field"
495
+ rows="1"
496
+ placeholder="What should change here?"
497
+ :aria-label="`Instruction for region ${selected.number}`"
498
+ :disabled="disabled"
499
+ @keydown="onFieldKey"
500
+ />
501
+ </div>
502
+ </div>
503
+
504
+ <!-- The version dots are the card's last row, under the image. -->
505
+ <div v-if="paginated" :class="['chai-card-pager-bar', { 'chai-card-pager-bar--over-media': hasImage }]">
506
+ <Pagination
507
+ :count="versions.length"
508
+ :index="index"
509
+ label="Versions"
510
+ item-label="Version"
511
+ :disabled="disabled"
512
+ @update:index="emit('update:activeVersion', $event)"
513
+ />
514
+ </div>
515
+ </div>
516
+
517
+ <!-- The expanded view reuses the result card's dialog, outside the card for the same reason. -->
518
+ <ExpandedImage v-if="expanded && version.src" :src="version.src" :alt="alt" @close="expanded = false" />
519
+ </template>
520
+ </template>