@schlessera/brain-ui-react 0.12.1 → 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.
- package/dist/components/chat/brain-markdown.d.ts.map +1 -1
- package/dist/components/chat/brain-markdown.js +12 -5
- package/dist/components/chat/brain-markdown.js.map +1 -1
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +2 -1
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/chat/mermaid-viewer.d.ts +11 -1
- package/dist/components/chat/mermaid-viewer.d.ts.map +1 -1
- package/dist/components/chat/mermaid-viewer.js +8 -250
- package/dist/components/chat/mermaid-viewer.js.map +1 -1
- package/dist/components/chat/message-bubble.d.ts.map +1 -1
- package/dist/components/chat/message-bubble.js +2 -1
- package/dist/components/chat/message-bubble.js.map +1 -1
- package/dist/components/chat/session-drawer.d.ts.map +1 -1
- package/dist/components/chat/session-drawer.js +11 -3
- package/dist/components/chat/session-drawer.js.map +1 -1
- package/dist/components/chat/share-card.d.ts +9 -0
- package/dist/components/chat/share-card.d.ts.map +1 -0
- package/dist/components/chat/share-card.js +58 -0
- package/dist/components/chat/share-card.js.map +1 -0
- package/dist/components/dev/share-harness.d.ts +4 -0
- package/dist/components/dev/share-harness.d.ts.map +1 -0
- package/dist/components/dev/share-harness.js +104 -0
- package/dist/components/dev/share-harness.js.map +1 -0
- package/dist/components/files/file-viewer-binary.d.ts.map +1 -1
- package/dist/components/files/file-viewer-binary.js +2 -1
- package/dist/components/files/file-viewer-binary.js.map +1 -1
- package/dist/components/files/file-viewer.d.ts.map +1 -1
- package/dist/components/files/file-viewer.js +11 -2
- package/dist/components/files/file-viewer.js.map +1 -1
- package/dist/components/images/image-share.d.ts +18 -0
- package/dist/components/images/image-share.d.ts.map +1 -0
- package/dist/components/images/image-share.js +47 -0
- package/dist/components/images/image-share.js.map +1 -0
- package/dist/components/images/image-viewer.d.ts +17 -0
- package/dist/components/images/image-viewer.d.ts.map +1 -0
- package/dist/components/images/image-viewer.js +20 -0
- package/dist/components/images/image-viewer.js.map +1 -0
- package/dist/components/images/zoomable-image.d.ts +35 -0
- package/dist/components/images/zoomable-image.d.ts.map +1 -0
- package/dist/components/images/zoomable-image.js +40 -0
- package/dist/components/images/zoomable-image.js.map +1 -0
- package/dist/components/viewer/zoom-viewer.d.ts +26 -0
- package/dist/components/viewer/zoom-viewer.d.ts.map +1 -0
- package/dist/components/viewer/zoom-viewer.js +254 -0
- package/dist/components/viewer/zoom-viewer.js.map +1 -0
- package/dist/hooks/use-share-intake.d.ts +6 -0
- package/dist/hooks/use-share-intake.d.ts.map +1 -0
- package/dist/hooks/use-share-intake.js +153 -0
- package/dist/hooks/use-share-intake.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts +13 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +24 -1
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/image-optimize.d.ts +62 -0
- package/dist/lib/image-optimize.d.ts.map +1 -0
- package/dist/lib/image-optimize.js +152 -0
- package/dist/lib/image-optimize.js.map +1 -0
- package/dist/lib/image-source.d.ts +13 -0
- package/dist/lib/image-source.d.ts.map +1 -0
- package/dist/lib/image-source.js +57 -0
- package/dist/lib/image-source.js.map +1 -0
- package/dist/lib/share-intake.d.ts +49 -0
- package/dist/lib/share-intake.d.ts.map +1 -0
- package/dist/lib/share-intake.js +203 -0
- package/dist/lib/share-intake.js.map +1 -0
- package/dist/stores/chat-store.d.ts +7 -1
- package/dist/stores/chat-store.d.ts.map +1 -1
- package/dist/stores/chat-store.js +13 -3
- package/dist/stores/chat-store.js.map +1 -1
- package/dist/stores/share-store.d.ts +29 -0
- package/dist/stores/share-store.d.ts.map +1 -0
- package/dist/stores/share-store.js +19 -0
- package/dist/stores/share-store.js.map +1 -0
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/chat/brain-markdown.tsx +15 -9
- package/src/components/chat/chat-page.tsx +6 -0
- package/src/components/chat/mermaid-viewer.tsx +15 -310
- package/src/components/chat/message-bubble.tsx +6 -2
- package/src/components/chat/session-drawer.tsx +19 -3
- package/src/components/chat/share-card.tsx +185 -0
- package/src/components/dev/share-harness.tsx +243 -0
- package/src/components/files/file-viewer-binary.tsx +10 -1
- package/src/components/files/file-viewer.tsx +11 -2
- package/src/components/images/image-share.ts +57 -0
- package/src/components/images/image-viewer.tsx +50 -0
- package/src/components/images/zoomable-image.tsx +93 -0
- package/src/components/viewer/zoom-viewer.tsx +354 -0
- package/src/hooks/use-share-intake.ts +190 -0
- package/src/hooks/use-websocket.ts +24 -1
- package/src/index.ts +21 -1
- package/src/lib/image-optimize.ts +195 -0
- package/src/lib/image-source.ts +53 -0
- package/src/lib/share-intake.ts +231 -0
- package/src/stores/chat-store.ts +25 -4
- 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
|
-
|
|
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
|
-
|
|
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
|
+
}
|