@chai-ui/react 0.1.0 → 0.2.1

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 (55) hide show
  1. package/DESIGN.md +6 -1
  2. package/README.md +69 -6
  3. package/dist/Composer.d.ts +19 -2
  4. package/dist/Composer.d.ts.map +1 -1
  5. package/dist/Composer.js +36 -10
  6. package/dist/Composer.js.map +1 -1
  7. package/dist/EditCard.d.ts +52 -0
  8. package/dist/EditCard.d.ts.map +1 -0
  9. package/dist/EditCard.js +360 -0
  10. package/dist/EditCard.js.map +1 -0
  11. package/dist/ResultCard.d.ts +2 -1
  12. package/dist/ResultCard.d.ts.map +1 -1
  13. package/dist/ResultCard.js +19 -63
  14. package/dist/ResultCard.js.map +1 -1
  15. package/dist/icons.d.ts +27 -0
  16. package/dist/icons.d.ts.map +1 -0
  17. package/dist/icons.js +50 -0
  18. package/dist/icons.js.map +1 -0
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/media-actions.d.ts +15 -0
  24. package/dist/media-actions.d.ts.map +1 -0
  25. package/dist/media-actions.js +47 -0
  26. package/dist/media-actions.js.map +1 -0
  27. package/dist/page-slide.d.ts +32 -0
  28. package/dist/page-slide.d.ts.map +1 -0
  29. package/dist/page-slide.js +43 -0
  30. package/dist/page-slide.js.map +1 -0
  31. package/dist/primitives/Pagination.d.ts +3 -1
  32. package/dist/primitives/Pagination.d.ts.map +1 -1
  33. package/dist/primitives/Pagination.js +2 -2
  34. package/dist/primitives/Pagination.js.map +1 -1
  35. package/dist/regions.d.ts +3 -0
  36. package/dist/regions.d.ts.map +1 -0
  37. package/dist/regions.js +7 -0
  38. package/dist/regions.js.map +1 -0
  39. package/dist/style.css +508 -82
  40. package/dist/useComposer.d.ts +22 -1
  41. package/dist/useComposer.d.ts.map +1 -1
  42. package/dist/useComposer.js +126 -24
  43. package/dist/useComposer.js.map +1 -1
  44. package/package.json +3 -3
  45. package/src/Composer.tsx +101 -13
  46. package/src/EditCard.tsx +688 -0
  47. package/src/ResultCard.tsx +60 -125
  48. package/src/icons.tsx +115 -0
  49. package/src/index.ts +8 -0
  50. package/src/media-actions.ts +45 -0
  51. package/src/page-slide.tsx +82 -0
  52. package/src/primitives/Pagination.tsx +4 -2
  53. package/src/regions.ts +7 -0
  54. package/src/style.css +508 -82
  55. package/src/useComposer.ts +147 -18
@@ -0,0 +1,688 @@
1
+ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
2
+ import { DEFAULT_MAX_REGIONS, type EditRegion, type RegionBox, type ResultStatus } from "@chai-ui/core";
3
+ import { Pagination } from "./primitives/Pagination.js";
4
+ import {
5
+ AddIcon,
6
+ CheckSymbolIcon,
7
+ CloseSymbolIcon,
8
+ DeleteIcon,
9
+ DownloadIcon,
10
+ ExpandIcon,
11
+ ShareIcon,
12
+ VisibilityIcon,
13
+ VisibilityOffIcon,
14
+ ZoomInIcon,
15
+ ZoomOutIcon,
16
+ } from "./icons.js";
17
+ import { downloadFilename, downloadMedia, shareMedia } from "./media-actions.js";
18
+ 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
+
37
+ export interface EditCardProps {
38
+ /** The original image first, then each edit. Paged with the dots under the card. */
39
+ versions: EditVersion[];
40
+ activeVersion: number;
41
+ onActiveVersionChange: (index: number) => void;
42
+ /** The new regions on the image, each committed with its instruction. Controlled: share them with `Composer`'s `regions`. */
43
+ regions: EditRegion[];
44
+ onRegionsChange: (next: EditRegion[]) => void;
45
+ /** The most regions the chosen model takes (`ModelOption.maxRegions`). "New region" is disabled at this many. Defaults to 6. */
46
+ maxRegions?: number;
47
+ /** Width in px. Defaults to 421. The height follows the image. */
48
+ width?: number;
49
+ /** Alt text for the image. Defaults to "Image being edited". */
50
+ alt?: string;
51
+ /** Fires after the card downloads or shares the version showing. */
52
+ onAction?: (action: "download" | "share", version: EditVersion) => void;
53
+ disabled?: boolean;
54
+ }
55
+
56
+ const ZOOM_STEPS = [1, 1.25, 1.5, 2, 3, 4];
57
+ const MIN_SIZE = 0.03;
58
+ const KEY_STEP = 0.01;
59
+ const KEY_STEP_LARGE = 0.05;
60
+
61
+ type Size = { width: number; height: number };
62
+ type Pan = { x: number; y: number };
63
+ type Drag =
64
+ | { kind: "move" | "nw" | "ne" | "sw" | "se"; id: string; x: number; y: number; box: RegionBox; moved: boolean }
65
+ | { kind: "pan"; x: number; y: number; pan: Pan; moved: boolean };
66
+
67
+ const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
68
+
69
+ /** Keeps a box inside the image and at least the minimum size. */
70
+ function fitBox(box: RegionBox): RegionBox {
71
+ const width = clamp(box.width, MIN_SIZE, 1);
72
+ const height = clamp(box.height, MIN_SIZE, 1);
73
+ return { x: clamp(box.x, 0, 1 - width), y: clamp(box.y, 0, 1 - height), width, height };
74
+ }
75
+
76
+ /** A box resized by dragging one corner; the opposite corner stays put. */
77
+ function resizeBox(box: RegionBox, corner: "nw" | "ne" | "sw" | "se", dx: number, dy: number): RegionBox {
78
+ let left = box.x;
79
+ let top = box.y;
80
+ let right = box.x + box.width;
81
+ let bottom = box.y + box.height;
82
+ if (corner === "nw" || corner === "sw") left = clamp(left + dx, 0, right - MIN_SIZE);
83
+ else right = clamp(right + dx, left + MIN_SIZE, 1);
84
+ if (corner === "nw" || corner === "ne") top = clamp(top + dy, 0, bottom - MIN_SIZE);
85
+ else bottom = clamp(bottom + dy, top + MIN_SIZE, 1);
86
+ return { x: left, y: top, width: right - left, height: bottom - top };
87
+ }
88
+
89
+ /** Pan limits at a zoom level: the image always covers the viewport. */
90
+ function clampPan(pan: Pan, zoom: number, size: Size): Pan {
91
+ return {
92
+ x: clamp(pan.x, size.width - size.width * zoom, 0),
93
+ y: clamp(pan.y, size.height - size.height * zoom, 0),
94
+ };
95
+ }
96
+
97
+ function nextRegionId(): string {
98
+ return typeof crypto !== "undefined" && "randomUUID" in crypto
99
+ ? crypto.randomUUID()
100
+ : `region-${Date.now()}-${Math.random().toString(36).slice(2)}`;
101
+ }
102
+
103
+ /** The lowest number no current region uses, so freed numbers (and colors) come back first. */
104
+ function freeNumber(regions: EditRegion[]): number {
105
+ let n = 1;
106
+ while (regions.some((r) => r.number === n)) n += 1;
107
+ return n;
108
+ }
109
+
110
+ /**
111
+ * The image editing card: the person marks regions on the image, gives each
112
+ * one an instruction, and checks it into the Composer. It shares
113
+ * `ResultCard`'s frame, with region actions where the votes would be.
114
+ *
115
+ * Drop it in with `useComposer`'s `edit`: `{edit && <EditCard {...edit} />}`.
116
+ * Its regions are shared with `Composer` (pass `edit.regions` and
117
+ * `edit.onRegionsChange` there too), and each edit's result comes back here
118
+ * as a new version.
119
+ *
120
+ * - "New region" adds a box with a prompt field. Check adds the region to
121
+ * the Composer; trash removes it. Clicking a region reopens its field.
122
+ * - Drag a region to move it, or a corner to resize it. By keyboard:
123
+ * arrows move the selected region, Shift + arrows resize it.
124
+ * - Zoom keeps the image inside the card; drag (or arrow keys) to pan.
125
+ */
126
+ export function EditCard({
127
+ versions,
128
+ activeVersion,
129
+ onActiveVersionChange,
130
+ regions,
131
+ onRegionsChange,
132
+ maxRegions = DEFAULT_MAX_REGIONS,
133
+ width,
134
+ alt = "Image being edited",
135
+ onAction,
136
+ disabled = false,
137
+ }: EditCardProps) {
138
+ const index = clamp(activeVersion, 0, Math.max(0, versions.length - 1));
139
+ const version = versions[index];
140
+ const ready = version?.status === "done" && Boolean(version.src);
141
+
142
+ // A region being drawn, not yet checked into the Composer.
143
+ const [draft, setDraft] = useState<EditRegion | null>(null);
144
+ const [selectedId, setSelectedId] = useState<string | null>(null);
145
+ const [text, setText] = useState("");
146
+ const [hidden, setHidden] = useState(false);
147
+ const [zoom, setZoom] = useState(1);
148
+ const [pan, setPan] = useState<Pan>({ x: 0, y: 0 });
149
+ const [size, setSize] = useState<Size>({ width: 0, height: 0 });
150
+ const [expanded, setExpanded] = useState(false);
151
+ const drag = useRef<Drag | null>(null);
152
+ const viewportRef = useRef<HTMLDivElement>(null);
153
+ const fieldRef = useRef<HTMLTextAreaElement>(null);
154
+
155
+ // Paging slides between versions, and a different version starts clean:
156
+ // no half-drawn region, no zoom.
157
+ const { slide, endSlide } = usePageSlide(version?.id, index, version?.src ?? version?.from);
158
+ const [cleanFor, setCleanFor] = useState(version?.id);
159
+ if (version?.id !== cleanFor) {
160
+ setCleanFor(version?.id);
161
+ setDraft(null);
162
+ setSelectedId(null);
163
+ setZoom(1);
164
+ setPan({ x: 0, y: 0 });
165
+ }
166
+
167
+ useEffect(() => {
168
+ const el = viewportRef.current;
169
+ if (!el) return;
170
+ const observer = new ResizeObserver(([entry]) => {
171
+ if (entry) setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
172
+ });
173
+ observer.observe(el);
174
+ return () => observer.disconnect();
175
+ }, []);
176
+
177
+ // A region being drawn takes a free number, even if the regions changed
178
+ // since it was started (so checking it in can't duplicate a number).
179
+ const shownDraft =
180
+ draft && regions.some((r) => r.number === draft.number) ? { ...draft, number: freeNumber(regions) } : draft;
181
+ const all = shownDraft ? [...regions, shownDraft] : regions;
182
+ const selected = all.find((r) => r.id === selectedId) ?? null;
183
+ const isDraft = selected != null && selected.id === draft?.id;
184
+ const canCheck = selected != null && text.trim().length > 0 && (isDraft || text.trim() !== selected.prompt);
185
+ const atCap = all.length >= maxRegions;
186
+ const anyRegions = all.length > 0 || (version?.regions.length ?? 0) > 0;
187
+
188
+ // The field opens focused, so a new region can be described straight away.
189
+ useEffect(() => {
190
+ if (selectedId) fieldRef.current?.focus();
191
+ }, [selectedId]);
192
+
193
+ // The field grows with its text instead of scrolling it out of view.
194
+ useEffect(() => {
195
+ const el = fieldRef.current;
196
+ if (!el) return;
197
+ el.style.height = "auto";
198
+ el.style.height = `${el.scrollHeight}px`;
199
+ }, [text, selectedId]);
200
+
201
+ function select(region: EditRegion | null) {
202
+ setSelectedId(region?.id ?? null);
203
+ setText(region?.prompt ?? "");
204
+ }
205
+
206
+ function setBox(id: string, box: RegionBox) {
207
+ if (draft?.id === id) setDraft({ ...draft, box });
208
+ else onRegionsChange(regions.map((r) => (r.id === id ? { ...r, box } : r)));
209
+ }
210
+
211
+ function addRegion() {
212
+ // Centered in the part of the image in view, so it's visible when zoomed in.
213
+ const viewW = 1 / zoom;
214
+ const viewH = 1 / zoom;
215
+ const centerX = size.width ? (-pan.x / (size.width * zoom)) + viewW / 2 : 0.5;
216
+ const centerY = size.height ? (-pan.y / (size.height * zoom)) + viewH / 2 : 0.5;
217
+ const box = fitBox({ x: centerX - (0.3 * viewW) / 2, y: centerY - (0.2 * viewH) / 2, width: 0.3 * viewW, height: 0.2 * viewH });
218
+ const region: EditRegion = { id: nextRegionId(), number: freeNumber(regions), box, prompt: "" };
219
+ setDraft(region);
220
+ setHidden(false);
221
+ select(region);
222
+ }
223
+
224
+ function check() {
225
+ if (!selected || !canCheck) return;
226
+ const prompt = text.trim();
227
+ if (isDraft) {
228
+ onRegionsChange([...regions, { ...selected, prompt }]);
229
+ setDraft(null);
230
+ } else {
231
+ onRegionsChange(regions.map((r) => (r.id === selected.id ? { ...r, prompt } : r)));
232
+ }
233
+ select(null);
234
+ }
235
+
236
+ function remove() {
237
+ if (!selected) return;
238
+ if (isDraft) setDraft(null);
239
+ else onRegionsChange(regions.filter((r) => r.id !== selected.id));
240
+ select(null);
241
+ }
242
+
243
+ function clearAll() {
244
+ setDraft(null);
245
+ select(null);
246
+ onRegionsChange([]);
247
+ }
248
+
249
+ function zoomTo(next: number) {
250
+ // Zooms around the middle of the view, so what's centered stays centered.
251
+ const cx = size.width / 2;
252
+ const cy = size.height / 2;
253
+ const ratio = next / zoom;
254
+ setPan(clampPan({ x: cx - (cx - pan.x) * ratio, y: cy - (cy - pan.y) * ratio }, next, size));
255
+ setZoom(next);
256
+ }
257
+ const zoomIndex = ZOOM_STEPS.indexOf(zoom);
258
+
259
+ // --- Pointer: move and resize regions, pan the zoomed image ---
260
+ // Moves and releases bubble to the viewport, even while a region or
261
+ // handle holds the pointer capture, so the viewport handles them all.
262
+
263
+ function startRegionDrag(e: PointerEvent, region: EditRegion, kind: DragKind) {
264
+ if (disabled || e.button !== 0) return;
265
+ e.stopPropagation();
266
+ e.currentTarget.setPointerCapture(e.pointerId);
267
+ drag.current = { kind, id: region.id, x: e.clientX, y: e.clientY, box: region.box, moved: false };
268
+ if (region.id !== selectedId) select(region);
269
+ }
270
+
271
+ function startPan(e: PointerEvent) {
272
+ if (e.button !== 0) return;
273
+ if (zoom > 1) e.currentTarget.setPointerCapture(e.pointerId);
274
+ drag.current = { kind: "pan", x: e.clientX, y: e.clientY, pan, moved: false };
275
+ }
276
+
277
+ function handlePointerMove(e: PointerEvent) {
278
+ const d = drag.current;
279
+ if (!d || !size.width) return;
280
+ const dxPx = e.clientX - d.x;
281
+ const dyPx = e.clientY - d.y;
282
+ if (Math.abs(dxPx) + Math.abs(dyPx) > 2) d.moved = true;
283
+ if (d.kind === "pan") {
284
+ if (zoom > 1) setPan(clampPan({ x: d.pan.x + dxPx, y: d.pan.y + dyPx }, zoom, size));
285
+ return;
286
+ }
287
+ const dx = dxPx / (size.width * zoom);
288
+ const dy = dyPx / (size.height * zoom);
289
+ 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));
290
+ }
291
+
292
+ function handlePointerUp() {
293
+ const d = drag.current;
294
+ drag.current = null;
295
+ // A click on the image itself (not a drag) closes the open field.
296
+ if (d?.kind === "pan" && !d.moved) select(null);
297
+ }
298
+
299
+ // --- Keyboard ---
300
+
301
+ function handleRegionKey(e: KeyboardEvent, region: EditRegion) {
302
+ const step = e.altKey ? KEY_STEP_LARGE : KEY_STEP;
303
+ const arrows: Record<string, [number, number]> = {
304
+ ArrowLeft: [-step, 0],
305
+ ArrowRight: [step, 0],
306
+ ArrowUp: [0, -step],
307
+ ArrowDown: [0, step],
308
+ };
309
+ const delta = arrows[e.key];
310
+ if (delta) {
311
+ e.preventDefault();
312
+ e.stopPropagation();
313
+ const [dx, dy] = delta;
314
+ setBox(region.id, e.shiftKey ? resizeBox(region.box, "se", dx, dy) : fitBox({ ...region.box, x: region.box.x + dx, y: region.box.y + dy }));
315
+ } else if (e.key === "Delete" || e.key === "Backspace") {
316
+ e.preventDefault();
317
+ if (region.id === draft?.id) setDraft(null);
318
+ else onRegionsChange(regions.filter((r) => r.id !== region.id));
319
+ select(null);
320
+ }
321
+ }
322
+
323
+ function handleViewportKey(e: KeyboardEvent<HTMLDivElement>) {
324
+ if (e.target !== e.currentTarget || zoom <= 1) return;
325
+ const step = 40;
326
+ const arrows: Record<string, [number, number]> = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };
327
+ const delta = arrows[e.key];
328
+ if (!delta) return;
329
+ e.preventDefault();
330
+ setPan(clampPan({ x: pan.x + delta[0], y: pan.y + delta[1] }, zoom, size));
331
+ }
332
+
333
+ function handleFieldKey(e: KeyboardEvent<HTMLTextAreaElement>) {
334
+ if (e.key === "Enter" && !e.shiftKey) {
335
+ e.preventDefault();
336
+ check();
337
+ } else if (e.key === "Escape") {
338
+ e.preventDefault();
339
+ if (isDraft) setDraft(null);
340
+ select(null);
341
+ }
342
+ }
343
+
344
+ // --- Media actions ---
345
+
346
+ const handleDownload = async () => {
347
+ if (!version?.src) return;
348
+ await downloadMedia(version.src, downloadFilename(version.id, "image", version.src));
349
+ onAction?.("download", version);
350
+ };
351
+
352
+ const handleShare = async () => {
353
+ if (!version?.src) return;
354
+ await shareMedia(version.src);
355
+ onAction?.("share", version);
356
+ };
357
+
358
+ // --- Layout ---
359
+
360
+ // The field sits above its region, or below it when there isn't room above.
361
+ const popover = selected && !hidden && size.width
362
+ ? (() => {
363
+ const left = selected.box.x * size.width * zoom + pan.x;
364
+ const top = selected.box.y * size.height * zoom + pan.y;
365
+ const bottom = (selected.box.y + selected.box.height) * size.height * zoom + pan.y;
366
+ // Below only when it clears the controls along the image's bottom edge.
367
+ const reserved = versions.length > 1 ? 100 : 56;
368
+ const fitsBelow = bottom + 8 + POPOVER_HEIGHT <= size.height - reserved;
369
+ const above = top > POPOVER_HEIGHT + 16 || !fitsBelow;
370
+ return {
371
+ left: clamp(left, 8, Math.max(8, size.width - POPOVER_WIDTH - 8)),
372
+ top: above ? Math.max(top - 8, POPOVER_HEIGHT + 8) : bottom + 8,
373
+ above,
374
+ };
375
+ })()
376
+ : null;
377
+
378
+ if (!version) return null;
379
+
380
+ return (
381
+ <>
382
+ <div
383
+ className="chai-edit-card"
384
+ style={width != null ? ({ "--chai-edit-card-width": `${width}px` } as CSSProperties) : undefined}
385
+ >
386
+ <div
387
+ ref={viewportRef}
388
+ className={`chai-edit-card__viewport${zoom > 1 ? " chai-edit-card__viewport--zoomed" : ""}`}
389
+ role="group"
390
+ aria-label={zoom > 1 ? `Image, zoomed to ${Math.round(zoom * 100)}%. Arrow keys move around.` : "Image"}
391
+ tabIndex={zoom > 1 ? 0 : undefined}
392
+ onPointerDown={startPan}
393
+ onPointerMove={handlePointerMove}
394
+ onPointerUp={handlePointerUp}
395
+ onPointerCancel={handlePointerUp}
396
+ onKeyDown={handleViewportKey}
397
+ >
398
+ <PageSlide slide={slide} onEnd={endSlide}>
399
+ <div
400
+ className="chai-edit-card__layer"
401
+ style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, "--chai-edit-zoom": zoom } as CSSProperties}
402
+ >
403
+ {version.src || version.from ? (
404
+ <img className="chai-edit-card__image" src={version.src ?? version.from} alt={alt} draggable={false} />
405
+ ) : (
406
+ <div className="chai-edit-card__placeholder" />
407
+ )}
408
+
409
+ {!hidden &&
410
+ version.regions.map((r) => (
411
+ <span
412
+ key={`applied-${r.id}`}
413
+ className="chai-edit-card__region chai-edit-card__region--applied"
414
+ style={boxStyle(r)}
415
+ aria-hidden="true"
416
+ />
417
+ ))}
418
+
419
+ {!hidden &&
420
+ ready &&
421
+ all.map((r) => (
422
+ <RegionShape
423
+ key={r.id}
424
+ region={r}
425
+ selected={r.id === selectedId}
426
+ draft={r.id === draft?.id}
427
+ disabled={disabled}
428
+ onDragStart={startRegionDrag}
429
+ onSelect={select}
430
+ onKeyDown={handleRegionKey}
431
+ />
432
+ ))}
433
+ </div>
434
+ </PageSlide>
435
+
436
+ {version.status !== "done" && (
437
+ <div className="chai-edit-card__status" role="status">
438
+ {version.status === "queued" || version.status === "running" ? (
439
+ <span className="chai-edit-card__spinner" role="img" aria-label="Editing" />
440
+ ) : (
441
+ <p className="chai-edit-card__status-text">
442
+ {version.status === "cancelled" ? "Stopped" : (version.error ?? "Something went wrong.")}
443
+ </p>
444
+ )}
445
+ </div>
446
+ )}
447
+
448
+ {/* Translucent backings keep these readable on any image. */}
449
+ <div className="chai-card-top chai-card-top--start" onPointerDown={(e) => e.stopPropagation()}>
450
+ <button
451
+ type="button"
452
+ className="chai-card-top-btn"
453
+ aria-label="Zoom in"
454
+ title="Zoom in"
455
+ disabled={!ready || zoomIndex >= ZOOM_STEPS.length - 1}
456
+ onClick={() => zoomTo(ZOOM_STEPS[zoomIndex + 1]!)}
457
+ >
458
+ <ZoomInIcon />
459
+ </button>
460
+ <span className="chai-edit-card__zoom-level" aria-live="polite">
461
+ {Math.round(zoom * 100)}%
462
+ </span>
463
+ <button
464
+ type="button"
465
+ className="chai-card-top-btn"
466
+ aria-label="Zoom out"
467
+ title="Zoom out"
468
+ disabled={!ready || zoomIndex <= 0}
469
+ onClick={() => zoomTo(ZOOM_STEPS[zoomIndex - 1]!)}
470
+ >
471
+ <ZoomOutIcon />
472
+ </button>
473
+ </div>
474
+
475
+ <div className="chai-card-top chai-card-top--end" onPointerDown={(e) => e.stopPropagation()}>
476
+ <button type="button" className="chai-card-top-btn" aria-label="Download" title="Download" disabled={disabled || !ready} onClick={handleDownload}>
477
+ <DownloadIcon />
478
+ </button>
479
+ <button type="button" className="chai-card-top-btn" aria-label="Share" title="Share" disabled={disabled || !ready} onClick={handleShare}>
480
+ <ShareIcon />
481
+ </button>
482
+ <button type="button" className="chai-card-top-btn" aria-label="Expand" title="Expand" disabled={!ready} onClick={() => setExpanded(true)}>
483
+ <ExpandIcon />
484
+ </button>
485
+ </div>
486
+
487
+ {/* Over the bottom of the image (it fills the card): the version dots
488
+ once there's an edit, and under them the region actions. Stacked
489
+ in one column so wrapped chips push the dots up, never under. */}
490
+ <div className="chai-card-bottom" onPointerDown={(e) => e.stopPropagation()}>
491
+ {versions.length > 1 && (
492
+ <div className="chai-card-pager">
493
+ <Pagination count={versions.length} index={index} onChange={onActiveVersionChange} label="Versions" itemLabel="Version" disabled={disabled} />
494
+ </div>
495
+ )}
496
+ <div className="chai-card-actions">
497
+ <div className="chai-card-chips">
498
+ <button
499
+ type="button"
500
+ className="chai-card-chip"
501
+ disabled={disabled || !ready || atCap}
502
+ title={atCap ? `This model takes up to ${maxRegions} regions` : undefined}
503
+ onClick={addRegion}
504
+ >
505
+ <AddIcon />
506
+ New region
507
+ </button>
508
+ <button type="button" className="chai-card-chip" disabled={disabled || all.length === 0} onClick={clearAll}>
509
+ <CloseSymbolIcon />
510
+ Clear regions
511
+ </button>
512
+ </div>
513
+ {/* Named, and given a tooltip, by what a press will do. */}
514
+ <button
515
+ type="button"
516
+ className="chai-card-round"
517
+ aria-label={hidden ? "Show regions" : "Hide regions"}
518
+ title={hidden ? "Show regions" : "Hide regions"}
519
+ disabled={!anyRegions}
520
+ onClick={() => {
521
+ setHidden(!hidden);
522
+ select(null);
523
+ }}
524
+ >
525
+ {hidden ? <VisibilityOffIcon /> : <VisibilityIcon />}
526
+ </button>
527
+ </div>
528
+ </div>
529
+
530
+ {popover && selected && (
531
+ <div
532
+ className={`chai-edit-card__popover${popover.above ? " chai-edit-card__popover--above" : ""}`}
533
+ style={{ left: popover.left, top: popover.top }}
534
+ role="dialog"
535
+ aria-label={`Region ${selected.number}`}
536
+ onPointerDown={(e) => e.stopPropagation()}
537
+ >
538
+ <div className="chai-edit-card__popover-header">
539
+ <span className="chai-edit-card__popover-title">Region {selected.number}</span>
540
+ <button
541
+ type="button"
542
+ className="chai-card-top-btn"
543
+ aria-label="Add region to prompt"
544
+ title="Add region to prompt"
545
+ disabled={disabled || !canCheck}
546
+ onClick={check}
547
+ >
548
+ <CheckSymbolIcon />
549
+ </button>
550
+ <button
551
+ type="button"
552
+ className="chai-card-top-btn"
553
+ aria-label="Delete region"
554
+ title="Delete region"
555
+ disabled={disabled}
556
+ onClick={remove}
557
+ >
558
+ <DeleteIcon />
559
+ </button>
560
+ </div>
561
+ <textarea
562
+ ref={fieldRef}
563
+ className="chai-edit-card__field"
564
+ value={text}
565
+ rows={1}
566
+ placeholder="What should change here?"
567
+ aria-label={`Instruction for region ${selected.number}`}
568
+ disabled={disabled}
569
+ onChange={(e) => setText(e.target.value)}
570
+ onKeyDown={handleFieldKey}
571
+ />
572
+ </div>
573
+ )}
574
+ </div>
575
+
576
+ </div>
577
+
578
+ {/* The expanded view reuses the result card's dialog, outside the card for the same reason. */}
579
+ {expanded && version.src && (
580
+ <ExpandedImage src={version.src} alt={alt} onClose={() => setExpanded(false)} />
581
+ )}
582
+ </>
583
+ );
584
+ }
585
+
586
+ const POPOVER_WIDTH = 220;
587
+ /** A typical height for the instruction panel (title row and a two-line field), for placing it. */
588
+ const POPOVER_HEIGHT = 96;
589
+
590
+ type DragKind = "move" | "nw" | "ne" | "sw" | "se";
591
+ const CORNERS = ["nw", "ne", "sw", "se"] as const;
592
+
593
+ /** One region on the image: a button to select, move or delete it, its number badge, and corner handles while selected. */
594
+ function RegionShape({
595
+ region,
596
+ selected,
597
+ draft,
598
+ disabled,
599
+ onDragStart,
600
+ onSelect,
601
+ onKeyDown,
602
+ }: {
603
+ region: EditRegion;
604
+ selected: boolean;
605
+ draft: boolean;
606
+ disabled: boolean;
607
+ onDragStart: (e: PointerEvent, region: EditRegion, kind: DragKind) => void;
608
+ onSelect: (region: EditRegion) => void;
609
+ onKeyDown: (e: KeyboardEvent, region: EditRegion) => void;
610
+ }) {
611
+ const name = `Region ${region.number}`;
612
+ return (
613
+ <div
614
+ className={`chai-edit-card__region${selected ? " chai-edit-card__region--selected" : ""}${draft ? " chai-edit-card__region--draft" : ""}`}
615
+ style={boxStyle(region)}
616
+ >
617
+ <button
618
+ type="button"
619
+ className="chai-edit-card__region-body"
620
+ aria-label={`${name}${region.prompt ? `: ${region.prompt}` : ", no instruction yet"}. Arrow keys move it, Shift and arrow keys resize it.`}
621
+ aria-pressed={selected}
622
+ disabled={disabled}
623
+ onPointerDown={(e) => onDragStart(e, region, "move")}
624
+ onClick={() => onSelect(region)}
625
+ onKeyDown={(e) => onKeyDown(e, region)}
626
+ />
627
+ <span className="chai-edit-card__badge" aria-hidden="true">
628
+ {region.number}
629
+ </span>
630
+ {selected &&
631
+ CORNERS.map((corner) => (
632
+ <span
633
+ key={corner}
634
+ className={`chai-edit-card__handle chai-edit-card__handle--${corner}`}
635
+ aria-hidden="true"
636
+ onPointerDown={(e) => onDragStart(e, region, corner)}
637
+ />
638
+ ))}
639
+ </div>
640
+ );
641
+ }
642
+
643
+ function boxStyle(r: EditRegion): CSSProperties {
644
+ return {
645
+ left: `${r.box.x * 100}%`,
646
+ top: `${r.box.y * 100}%`,
647
+ width: `${r.box.width * 100}%`,
648
+ height: `${r.box.height * 100}%`,
649
+ "--chai-region-color": regionColor(r.number),
650
+ } as CSSProperties;
651
+ }
652
+
653
+ /** The full-size image in a modal dialog: focus moves in, Escape closes, and focus returns to Expand. */
654
+ function ExpandedImage({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
655
+ const closeRef = useRef<HTMLButtonElement>(null);
656
+ useEffect(() => {
657
+ const opener = document.activeElement as HTMLElement | null;
658
+ closeRef.current?.focus();
659
+ return () => opener?.focus();
660
+ }, []);
661
+
662
+ return (
663
+ <div
664
+ className="chai-result-card__expand-overlay"
665
+ role="dialog"
666
+ aria-modal="true"
667
+ aria-label="Expanded image"
668
+ onKeyDown={(e) => {
669
+ if (e.key === "Escape") {
670
+ e.stopPropagation();
671
+ onClose();
672
+ } else if (e.key === "Tab") {
673
+ // One control inside: keep focus on it.
674
+ e.preventDefault();
675
+ closeRef.current?.focus();
676
+ }
677
+ }}
678
+ >
679
+ <div className="chai-result-card__expand-scrim" role="presentation" onClick={onClose} />
680
+ <div className="chai-result-card__expand-frame">
681
+ <img className="chai-result-card__image" src={src} alt={alt} />
682
+ <button ref={closeRef} type="button" className="chai-result-card__collapse" aria-label="Collapse" onClick={onClose}>
683
+ <CloseSymbolIcon />
684
+ </button>
685
+ </div>
686
+ </div>
687
+ );
688
+ }