@schlessera/brain-ui-react 0.12.0 → 0.13.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 (101) hide show
  1. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  2. package/dist/components/chat/brain-markdown.js +12 -5
  3. package/dist/components/chat/brain-markdown.js.map +1 -1
  4. package/dist/components/chat/chat-page.d.ts.map +1 -1
  5. package/dist/components/chat/chat-page.js +2 -1
  6. package/dist/components/chat/chat-page.js.map +1 -1
  7. package/dist/components/chat/mermaid-viewer.d.ts +11 -1
  8. package/dist/components/chat/mermaid-viewer.d.ts.map +1 -1
  9. package/dist/components/chat/mermaid-viewer.js +8 -250
  10. package/dist/components/chat/mermaid-viewer.js.map +1 -1
  11. package/dist/components/chat/message-bubble.d.ts.map +1 -1
  12. package/dist/components/chat/message-bubble.js +2 -1
  13. package/dist/components/chat/message-bubble.js.map +1 -1
  14. package/dist/components/chat/session-drawer.d.ts.map +1 -1
  15. package/dist/components/chat/session-drawer.js +11 -3
  16. package/dist/components/chat/session-drawer.js.map +1 -1
  17. package/dist/components/chat/share-card.d.ts +9 -0
  18. package/dist/components/chat/share-card.d.ts.map +1 -0
  19. package/dist/components/chat/share-card.js +58 -0
  20. package/dist/components/chat/share-card.js.map +1 -0
  21. package/dist/components/dev/share-harness.d.ts +4 -0
  22. package/dist/components/dev/share-harness.d.ts.map +1 -0
  23. package/dist/components/dev/share-harness.js +104 -0
  24. package/dist/components/dev/share-harness.js.map +1 -0
  25. package/dist/components/files/file-viewer-binary.d.ts.map +1 -1
  26. package/dist/components/files/file-viewer-binary.js +2 -1
  27. package/dist/components/files/file-viewer-binary.js.map +1 -1
  28. package/dist/components/files/file-viewer.d.ts.map +1 -1
  29. package/dist/components/files/file-viewer.js +11 -2
  30. package/dist/components/files/file-viewer.js.map +1 -1
  31. package/dist/components/images/image-share.d.ts +18 -0
  32. package/dist/components/images/image-share.d.ts.map +1 -0
  33. package/dist/components/images/image-share.js +47 -0
  34. package/dist/components/images/image-share.js.map +1 -0
  35. package/dist/components/images/image-viewer.d.ts +17 -0
  36. package/dist/components/images/image-viewer.d.ts.map +1 -0
  37. package/dist/components/images/image-viewer.js +20 -0
  38. package/dist/components/images/image-viewer.js.map +1 -0
  39. package/dist/components/images/zoomable-image.d.ts +35 -0
  40. package/dist/components/images/zoomable-image.d.ts.map +1 -0
  41. package/dist/components/images/zoomable-image.js +40 -0
  42. package/dist/components/images/zoomable-image.js.map +1 -0
  43. package/dist/components/viewer/zoom-viewer.d.ts +26 -0
  44. package/dist/components/viewer/zoom-viewer.d.ts.map +1 -0
  45. package/dist/components/viewer/zoom-viewer.js +254 -0
  46. package/dist/components/viewer/zoom-viewer.js.map +1 -0
  47. package/dist/hooks/use-share-intake.d.ts +6 -0
  48. package/dist/hooks/use-share-intake.d.ts.map +1 -0
  49. package/dist/hooks/use-share-intake.js +153 -0
  50. package/dist/hooks/use-share-intake.js.map +1 -0
  51. package/dist/hooks/use-websocket.d.ts +13 -0
  52. package/dist/hooks/use-websocket.d.ts.map +1 -1
  53. package/dist/hooks/use-websocket.js +24 -1
  54. package/dist/hooks/use-websocket.js.map +1 -1
  55. package/dist/index.d.ts +4 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +10 -1
  58. package/dist/index.js.map +1 -1
  59. package/dist/lib/image-optimize.d.ts +62 -0
  60. package/dist/lib/image-optimize.d.ts.map +1 -0
  61. package/dist/lib/image-optimize.js +152 -0
  62. package/dist/lib/image-optimize.js.map +1 -0
  63. package/dist/lib/image-source.d.ts +13 -0
  64. package/dist/lib/image-source.d.ts.map +1 -0
  65. package/dist/lib/image-source.js +57 -0
  66. package/dist/lib/image-source.js.map +1 -0
  67. package/dist/lib/share-intake.d.ts +49 -0
  68. package/dist/lib/share-intake.d.ts.map +1 -0
  69. package/dist/lib/share-intake.js +203 -0
  70. package/dist/lib/share-intake.js.map +1 -0
  71. package/dist/stores/chat-store.d.ts +7 -1
  72. package/dist/stores/chat-store.d.ts.map +1 -1
  73. package/dist/stores/chat-store.js +13 -3
  74. package/dist/stores/chat-store.js.map +1 -1
  75. package/dist/stores/share-store.d.ts +29 -0
  76. package/dist/stores/share-store.d.ts.map +1 -0
  77. package/dist/stores/share-store.js +19 -0
  78. package/dist/stores/share-store.js.map +1 -0
  79. package/dist/styles.css +1 -1
  80. package/package.json +2 -2
  81. package/src/components/chat/brain-markdown.tsx +15 -9
  82. package/src/components/chat/chat-page.tsx +6 -0
  83. package/src/components/chat/mermaid-viewer.tsx +15 -310
  84. package/src/components/chat/message-bubble.tsx +6 -2
  85. package/src/components/chat/session-drawer.tsx +19 -3
  86. package/src/components/chat/share-card.tsx +185 -0
  87. package/src/components/dev/share-harness.tsx +243 -0
  88. package/src/components/files/file-viewer-binary.tsx +10 -1
  89. package/src/components/files/file-viewer.tsx +11 -2
  90. package/src/components/images/image-share.ts +57 -0
  91. package/src/components/images/image-viewer.tsx +50 -0
  92. package/src/components/images/zoomable-image.tsx +93 -0
  93. package/src/components/viewer/zoom-viewer.tsx +354 -0
  94. package/src/hooks/use-share-intake.ts +190 -0
  95. package/src/hooks/use-websocket.ts +24 -1
  96. package/src/index.ts +21 -1
  97. package/src/lib/image-optimize.ts +195 -0
  98. package/src/lib/image-source.ts +53 -0
  99. package/src/lib/share-intake.ts +231 -0
  100. package/src/stores/chat-store.ts +25 -4
  101. package/src/stores/share-store.ts +54 -0
@@ -0,0 +1,243 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { AlertTriangle, RefreshCw, Send, Trash2 } from "lucide-react";
3
+ import {
4
+ DEFAULT_SHARE_TARGET_PATH,
5
+ getShareStore,
6
+ type StoredShare,
7
+ } from "@schlessera/brain-ui-sdk/share-target";
8
+
9
+ /**
10
+ * Dev-only stand-in for the system share sheet.
11
+ *
12
+ * A real share needs an installed PWA, and on Android the intent filters are
13
+ * baked into the WebAPK at install time — so every manifest change costs a
14
+ * reinstall and a wait. This posts the same multipart body to the same path
15
+ * from inside the page, which the service worker handles through exactly the
16
+ * same code path: same handler, same stash, same redirect. Everything except
17
+ * the manifest registration itself can be verified here.
18
+ *
19
+ * Two things to watch:
20
+ *
21
+ * - The page must be CONTROLLED by the service worker. If it is not, the POST
22
+ * goes to the network instead, which is what the warning below is for.
23
+ * - A `fetch()` is not a navigation. Its `mode` is "cors" rather than
24
+ * "navigate", and it carries the session cookie, which a real cross-site
25
+ * share never does. So this exercises the handler and the stash faithfully,
26
+ * but never the browser rendering the landing page — and it cannot tell you
27
+ * anything about how an UNAUTHENTICATED share behaves.
28
+ */
29
+
30
+ function formatBytes(bytes: number): string {
31
+ if (bytes < 1024) return `${bytes} B`;
32
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
33
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
34
+ }
35
+
36
+ export function ShareHarness({
37
+ path = DEFAULT_SHARE_TARGET_PATH,
38
+ }: {
39
+ path?: string;
40
+ }) {
41
+ const [title, setTitle] = useState("");
42
+ const [text, setText] = useState("An article worth keeping");
43
+ const [url, setUrl] = useState("https://example.com/post");
44
+ const [files, setFiles] = useState<File[]>([]);
45
+ const [result, setResult] = useState<string | null>(null);
46
+ const [error, setError] = useState<string | null>(null);
47
+ const [pending, setPending] = useState<StoredShare[]>([]);
48
+ const [controlled, setControlled] = useState(true);
49
+
50
+ const refresh = useCallback(async () => {
51
+ try {
52
+ setPending(await getShareStore().list());
53
+ } catch (err) {
54
+ setError(err instanceof Error ? err.message : String(err));
55
+ }
56
+ }, []);
57
+
58
+ useEffect(() => {
59
+ if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
60
+ setControlled(false);
61
+ return;
62
+ }
63
+ // Re-read on takeover: the interesting case is a page that starts
64
+ // uncontrolled and gains a worker, which is exactly when a fixed initial
65
+ // reading would leave the warning lying.
66
+ const update = () => setControlled(navigator.serviceWorker.controller !== null);
67
+ update();
68
+ navigator.serviceWorker.addEventListener("controllerchange", update);
69
+ void refresh();
70
+ return () =>
71
+ navigator.serviceWorker.removeEventListener("controllerchange", update);
72
+ }, [refresh]);
73
+
74
+ async function send() {
75
+ setError(null);
76
+ setResult(null);
77
+ const form = new FormData();
78
+ if (title) form.set("title", title);
79
+ if (text) form.set("text", text);
80
+ if (url) form.set("url", url);
81
+ for (const file of files) form.append("files", file);
82
+
83
+ try {
84
+ // The service worker answers with a 303 and fetch follows it, so the
85
+ // final URL is what the browser would have landed on after a real share
86
+ // — including the ?share= id.
87
+ const response = await fetch(path, { method: "POST", body: form });
88
+ if (response.ok) {
89
+ setResult(response.url);
90
+ } else {
91
+ setError(`${response.status} ${response.statusText}`);
92
+ }
93
+ } catch (err) {
94
+ setError(err instanceof Error ? err.message : String(err));
95
+ } finally {
96
+ // Refresh either way: a failed send can still have stashed something.
97
+ await refresh();
98
+ }
99
+ }
100
+
101
+ async function drop(id: string) {
102
+ await getShareStore().delete(id);
103
+ await refresh();
104
+ }
105
+
106
+ return (
107
+ <div className="mx-auto flex max-w-2xl flex-col gap-6 p-6 text-foreground">
108
+ <header className="flex flex-col gap-1">
109
+ <h1 className="text-xl font-semibold">Share target harness</h1>
110
+ <p className="text-sm text-muted-foreground">
111
+ Posts a multipart share to <code>{path}</code> from inside the page.
112
+ The service worker handles it exactly as it would a real share.
113
+ </p>
114
+ </header>
115
+
116
+ {!controlled && (
117
+ <div className="flex items-start gap-2 rounded-lg border border-border bg-surface-raised p-3 text-sm">
118
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
119
+ <span>
120
+ No service worker controls this page, so the POST goes to the
121
+ network and the server answers with the no-worker fallback rather
122
+ than stashing anything. Load a production build (the worker is
123
+ disabled in dev) and reload once after it installs.
124
+ </span>
125
+ </div>
126
+ )}
127
+
128
+ <div className="flex flex-col gap-3">
129
+ <Field label="title" value={title} onChange={setTitle} />
130
+ <Field label="text" value={text} onChange={setText} />
131
+ <Field label="url" value={url} onChange={setUrl} />
132
+
133
+ <label className="flex flex-col gap-1 text-sm">
134
+ <span className="text-muted-foreground">files</span>
135
+ <input
136
+ type="file"
137
+ multiple
138
+ onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
139
+ className="text-sm"
140
+ />
141
+ </label>
142
+
143
+ <button
144
+ type="button"
145
+ onClick={() => void send()}
146
+ className="flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
147
+ >
148
+ <Send className="h-4 w-4" />
149
+ Share it
150
+ </button>
151
+ </div>
152
+
153
+ {error && (
154
+ <p className="rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm">
155
+ {error}
156
+ </p>
157
+ )}
158
+
159
+ {result && (
160
+ <div className="flex flex-col gap-2 rounded-lg border border-border p-3 text-sm">
161
+ <span className="text-muted-foreground">Landed on</span>
162
+ <code className="break-all">{result}</code>
163
+ <button
164
+ type="button"
165
+ onClick={() => window.location.assign(result)}
166
+ className="self-start rounded-md border border-border px-3 py-1 text-sm"
167
+ >
168
+ Open it
169
+ </button>
170
+ </div>
171
+ )}
172
+
173
+ <section className="flex flex-col gap-2">
174
+ <div className="flex items-center justify-between">
175
+ <h2 className="text-sm font-medium">Stashed shares ({pending.length})</h2>
176
+ <button
177
+ type="button"
178
+ onClick={() => void refresh()}
179
+ className="flex items-center gap-1 text-sm text-muted-foreground"
180
+ >
181
+ <RefreshCw className="h-3.5 w-3.5" />
182
+ Refresh
183
+ </button>
184
+ </div>
185
+
186
+ {pending.length === 0 && (
187
+ <p className="text-sm text-muted-foreground">Nothing stashed.</p>
188
+ )}
189
+
190
+ {pending.map((share) => (
191
+ <div
192
+ key={share.id}
193
+ className="flex flex-col gap-1 rounded-lg border border-border p-3 text-sm"
194
+ >
195
+ <div className="flex items-center justify-between gap-2">
196
+ <code className="break-all text-xs text-muted-foreground">
197
+ {share.id}
198
+ </code>
199
+ <button
200
+ type="button"
201
+ onClick={() => void drop(share.id)}
202
+ aria-label="Delete stashed share"
203
+ className="text-muted-foreground"
204
+ >
205
+ <Trash2 className="h-4 w-4" />
206
+ </button>
207
+ </div>
208
+ {share.title && <div>title: {share.title}</div>}
209
+ {share.text && <div>text: {share.text}</div>}
210
+ {share.url && <div>url: {share.url}</div>}
211
+ {share.files.map((file, i) => (
212
+ <div key={`${file.name}-${i}`} className="text-muted-foreground">
213
+ {file.name} ({file.type || "unknown"}, {formatBytes(file.size)})
214
+ </div>
215
+ ))}
216
+ </div>
217
+ ))}
218
+ </section>
219
+ </div>
220
+ );
221
+ }
222
+
223
+ function Field({
224
+ label,
225
+ value,
226
+ onChange,
227
+ }: {
228
+ label: string;
229
+ value: string;
230
+ onChange: (next: string) => void;
231
+ }) {
232
+ return (
233
+ <label className="flex flex-col gap-1 text-sm">
234
+ <span className="text-muted-foreground">{label}</span>
235
+ <input
236
+ type="text"
237
+ value={value}
238
+ onChange={(e) => onChange(e.target.value)}
239
+ className="rounded-lg border border-border bg-surface px-3 py-2 text-sm"
240
+ />
241
+ </label>
242
+ );
243
+ }
@@ -1,6 +1,7 @@
1
1
  import { FileWarning, Download } from "lucide-react";
2
2
  import type { FileContentResponse } from "@schlessera/brain-ui-sdk/protocol";
3
3
  import { API_BASE } from "../../lib/backend.js";
4
+ import { ZoomableImage } from "../images/zoomable-image.js";
4
5
 
5
6
  export function FileViewerBinary({ content }: { content: FileContentResponse }) {
6
7
  const isImage = content.mime?.startsWith("image/");
@@ -11,7 +12,15 @@ export function FileViewerBinary({ content }: { content: FileContentResponse })
11
12
  return (
12
13
  <div className="flex h-full flex-col items-center justify-center gap-3 p-6">
13
14
  <div className="flex max-h-full max-w-full overflow-auto rounded-lg border border-border bg-surface p-2">
14
- <img src={rawUrl} alt={content.path} className="max-h-[70vh] max-w-full object-contain" />
15
+ {/* Tap to open the zoom viewer: the panel is narrow, so the preview is
16
+ a thumbnail of anything larger than it. */}
17
+ <ZoomableImage
18
+ src={rawUrl}
19
+ alt={content.path}
20
+ className="max-h-[70vh] max-w-full cursor-zoom-in object-contain"
21
+ mime={content.mime}
22
+ bytes={content.size}
23
+ />
15
24
  </div>
16
25
  <div className="flex flex-wrap items-center justify-center gap-2 text-xs text-muted-foreground">
17
26
  <span>
@@ -14,6 +14,7 @@ import { stripMarkdown } from "../../lib/strip-markdown.js";
14
14
  import { inlineMermaidDiagrams, isMermaidPath } from "../../lib/mermaid.js";
15
15
  import { MermaidBlock } from "../chat/mermaid-block.js";
16
16
  import { buildDiagramShareOptions } from "../chat/mermaid-share.js";
17
+ import { buildImageShareOptions } from "../images/image-share.js";
17
18
  import type { FileContentResponse } from "@schlessera/brain-ui-sdk/protocol";
18
19
 
19
20
  export function FileViewer() {
@@ -182,9 +183,17 @@ function formatSize(bytes: number): string {
182
183
  function buildFileShareOptions(content: FileContentResponse, fileName: string): ShareOption[] {
183
184
  const rawUrl = `${API_BASE}/files/content?path=${encodeURIComponent(content.path)}&raw=1`;
184
185
 
185
- // Single-action share for previewable images / PDFs: ship the bytes as-is.
186
186
  if (content.kind === "binary") {
187
- if (content.mime?.startsWith("image/") || content.mime === "application/pdf") {
187
+ // An image gets both: the original bytes, and a lighter re-encode for
188
+ // messaging. The original is first, and nothing is downgraded unasked.
189
+ if (content.mime?.startsWith("image/")) {
190
+ return buildImageShareOptions(rawUrl, fileName, {
191
+ mime: content.mime,
192
+ bytes: content.size,
193
+ });
194
+ }
195
+ // A PDF ships as-is: there is no client-side recompression for one.
196
+ if (content.mime === "application/pdf") {
188
197
  return [
189
198
  {
190
199
  id: "file",
@@ -0,0 +1,57 @@
1
+ import { fetchAsFile, shareFile } from "../../lib/share.js";
2
+ import {
3
+ SHARE_OPTIMIZED,
4
+ formatBytes,
5
+ optimizeImage,
6
+ optimizedFilename,
7
+ } from "../../lib/image-optimize.js";
8
+ import type { ShareOption } from "../share/share-menu.js";
9
+
10
+ /**
11
+ * Share options for one image, used by both the file viewer's header menu and
12
+ * the full-screen image viewer.
13
+ *
14
+ * Two deliberate actions rather than one silently-processed payload:
15
+ *
16
+ * - **Original** ships the bytes untouched. This is the default and the first
17
+ * entry; a generated image is worth what its pixels are worth.
18
+ * - **Optimized** re-encodes down toward a messaging-friendly size, and only
19
+ * when that actually changes something — an image already inside the target
20
+ * falls back to the original rather than being recompressed for show.
21
+ */
22
+ export function buildImageShareOptions(
23
+ url: string,
24
+ filename: string,
25
+ opts: { mime?: string; bytes?: number } = {}
26
+ ): ShareOption[] {
27
+ const shareOriginal = async (): Promise<boolean> => {
28
+ const file = await fetchAsFile(url, filename, opts.mime);
29
+ return shareFile(file, { title: filename });
30
+ };
31
+
32
+ return [
33
+ {
34
+ id: "image-original",
35
+ label: "Share original",
36
+ hint: opts.bytes ? `Full quality · ${formatBytes(opts.bytes)}` : "Full quality",
37
+ run: shareOriginal,
38
+ },
39
+ {
40
+ id: "image-optimized",
41
+ label: "Share optimized",
42
+ hint: `JPEG, up to ${SHARE_OPTIMIZED.maxEdge}px · under ${formatBytes(SHARE_OPTIMIZED.maxBytes)}`,
43
+ run: async () => {
44
+ const original = await fetchAsFile(url, filename, opts.mime);
45
+ const result = await optimizeImage(original, SHARE_OPTIMIZED);
46
+ // null means "nothing to gain" (already small enough) or "could not be
47
+ // decoded". Either way the original is the right thing to send, and
48
+ // silently sending nothing would be worse than sending more bytes.
49
+ if (!result) return shareFile(original, { title: filename });
50
+ const file = new File([result.blob], optimizedFilename(filename), {
51
+ type: "image/jpeg",
52
+ });
53
+ return shareFile(file, { title: filename });
54
+ },
55
+ },
56
+ ];
57
+ }
@@ -0,0 +1,50 @@
1
+ import { useState } from "react";
2
+ import { ShareMenu } from "../share/share-menu.js";
3
+ import { ZoomViewer } from "../viewer/zoom-viewer.js";
4
+ import { buildImageShareOptions } from "./image-share.js";
5
+
6
+ /**
7
+ * Full-screen, pan-and-zoom view of one image — the same surface the mermaid
8
+ * viewer uses, so a diagram and a picture behave identically.
9
+ *
10
+ * `maxFitScale: 1` because raster pixels are finite: opening a 400px image at
11
+ * 250% would show nothing but interpolation. Zooming past 100% is still
12
+ * available, it just is not where the viewer starts.
13
+ */
14
+ export function ImageViewer({
15
+ src,
16
+ alt,
17
+ filename,
18
+ mime,
19
+ bytes,
20
+ onClose,
21
+ }: {
22
+ src: string;
23
+ alt?: string;
24
+ filename: string;
25
+ mime?: string;
26
+ bytes?: number;
27
+ onClose: () => void;
28
+ }) {
29
+ // The natural size is unknown until the decode finishes, and `fit()` measures
30
+ // the DOM — so the load event is what tells the viewer to fit.
31
+ const [loaded, setLoaded] = useState(false);
32
+
33
+ return (
34
+ <ZoomViewer
35
+ onClose={onClose}
36
+ refitKey={loaded ? src : null}
37
+ maxFitScale={1}
38
+ label={`Image viewer: ${filename}`}
39
+ actions={
40
+ <ShareMenu
41
+ options={buildImageShareOptions(src, filename, { mime, bytes })}
42
+ title="Share image"
43
+ />
44
+ }
45
+ >
46
+ {/* Not lazy: this only mounts once the reader has opened the viewer. */}
47
+ <img src={src} alt={alt ?? filename} onLoad={() => setLoaded(true)} draggable={false} />
48
+ </ZoomViewer>
49
+ );
50
+ }
@@ -0,0 +1,93 @@
1
+ import { useState } from "react";
2
+ import { Expand } from "lucide-react";
3
+ import { imageFilenameFromSrc } from "../../lib/image-source.js";
4
+ import { ImageViewer } from "./image-viewer.js";
5
+
6
+ /**
7
+ * An image that opens into the full-screen zoom viewer when tapped — the same
8
+ * treatment a mermaid diagram gets, and for the same reason: the inline preview
9
+ * is only as wide as the viewport, which on a phone means a generated image is
10
+ * visible but not legible.
11
+ *
12
+ * Click-to-open rather than pinch-in-place: both the chat transcript and the
13
+ * file panel scroll, and a zoom surface embedded in a scroller has to steal the
14
+ * gestures that scrolling needs. Opening a dedicated stage keeps both intact.
15
+ *
16
+ * Sharing is deliberately NOT offered here. The hover affordance sits inside
17
+ * markdown prose and inside a file panel, both of which clip overflow, and a
18
+ * share menu that drops out of a clipping ancestor loses its lower entries. The
19
+ * viewer this opens carries the share menu, and the file viewer's own header
20
+ * has one too.
21
+ *
22
+ * The wrapper is a `span`, not a `div`: markdown puts images inside paragraphs,
23
+ * and a block element there is invalid nesting.
24
+ */
25
+ export function ZoomableImage({
26
+ src,
27
+ alt,
28
+ className,
29
+ mime,
30
+ bytes,
31
+ toolbar = true,
32
+ imgProps,
33
+ }: {
34
+ src: string;
35
+ alt?: string;
36
+ /** Classes for the inline `<img>` itself. */
37
+ className?: string;
38
+ mime?: string;
39
+ /** Byte size when known (the file viewer knows it; the chat does not). */
40
+ bytes?: number;
41
+ /**
42
+ * Hover expand button. Off for thumbnails, where a 28px button would cover
43
+ * the picture it is meant to act on — tapping the image still opens it.
44
+ */
45
+ toolbar?: boolean;
46
+ imgProps?: Omit<React.ComponentPropsWithoutRef<"img">, "src" | "alt" | "className">;
47
+ }) {
48
+ const [zoomed, setZoomed] = useState(false);
49
+ const filename = imageFilenameFromSrc(src, mime);
50
+
51
+ return (
52
+ <span className="group relative inline-block max-w-full align-top">
53
+ <img
54
+ {...imgProps}
55
+ src={src}
56
+ alt={alt ?? ""}
57
+ // The whole image is the affordance — the hover button is pointer-only,
58
+ // and this has to work on a touch screen. Focusable and Enter/Space
59
+ // activated because for a thumbnail (toolbar off) this is the ONLY way
60
+ // in, so it cannot be pointer-only either.
61
+ role="button"
62
+ tabIndex={0}
63
+ onClick={() => setZoomed(true)}
64
+ onKeyDown={(e) => {
65
+ if (e.key !== "Enter" && e.key !== " ") return;
66
+ e.preventDefault();
67
+ setZoomed(true);
68
+ }}
69
+ className={className}
70
+ />
71
+ {toolbar && (
72
+ <button
73
+ type="button"
74
+ title="Open image"
75
+ onClick={() => setZoomed(true)}
76
+ className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-md bg-surface-raised/80 text-muted-foreground opacity-0 transition-all hover:bg-surface-overlay hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
77
+ >
78
+ <Expand className="h-3.5 w-3.5" />
79
+ </button>
80
+ )}
81
+ {zoomed && (
82
+ <ImageViewer
83
+ src={src}
84
+ alt={alt}
85
+ filename={filename}
86
+ mime={mime}
87
+ bytes={bytes}
88
+ onClose={() => setZoomed(false)}
89
+ />
90
+ )}
91
+ </span>
92
+ );
93
+ }