@schlessera/brain-ui-react 0.8.0 → 0.9.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/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +10 -1
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/chat/copy-button.d.ts.map +1 -1
- package/dist/components/chat/copy-button.js +3 -1
- package/dist/components/chat/copy-button.js.map +1 -1
- package/dist/components/chat/mermaid-block.d.ts.map +1 -1
- package/dist/components/chat/mermaid-block.js +10 -2
- package/dist/components/chat/mermaid-block.js.map +1 -1
- package/dist/components/chat/mermaid-share.d.ts +5 -0
- package/dist/components/chat/mermaid-share.d.ts.map +1 -0
- package/dist/components/chat/mermaid-share.js +80 -0
- package/dist/components/chat/mermaid-share.js.map +1 -0
- package/dist/components/chat/mermaid-viewer.d.ts +6 -0
- package/dist/components/chat/mermaid-viewer.d.ts.map +1 -0
- package/dist/components/chat/mermaid-viewer.js +266 -0
- package/dist/components/chat/mermaid-viewer.js.map +1 -0
- package/dist/components/files/file-viewer.d.ts.map +1 -1
- package/dist/components/files/file-viewer.js +15 -0
- package/dist/components/files/file-viewer.js.map +1 -1
- package/dist/components/share/share-menu.d.ts +8 -1
- package/dist/components/share/share-menu.d.ts.map +1 -1
- package/dist/components/share/share-menu.js +10 -5
- package/dist/components/share/share-menu.js.map +1 -1
- package/dist/lib/client-environment.d.ts +29 -0
- package/dist/lib/client-environment.d.ts.map +1 -0
- package/dist/lib/client-environment.js +153 -0
- package/dist/lib/client-environment.js.map +1 -0
- package/dist/lib/mermaid-theme.d.ts +4 -0
- package/dist/lib/mermaid-theme.d.ts.map +1 -0
- package/dist/lib/mermaid-theme.js +221 -0
- package/dist/lib/mermaid-theme.js.map +1 -0
- package/dist/lib/mermaid.d.ts +15 -1
- package/dist/lib/mermaid.d.ts.map +1 -1
- package/dist/lib/mermaid.js +88 -38
- package/dist/lib/mermaid.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/chat/chat-page.tsx +20 -1
- package/src/components/chat/copy-button.tsx +3 -0
- package/src/components/chat/mermaid-block.tsx +38 -9
- package/src/components/chat/mermaid-share.ts +91 -0
- package/src/components/chat/mermaid-viewer.tsx +336 -0
- package/src/components/files/file-viewer.tsx +17 -0
- package/src/components/share/share-menu.tsx +20 -6
- package/src/lib/client-environment.ts +152 -0
- package/src/lib/mermaid-theme.ts +246 -0
- package/src/lib/mermaid.ts +90 -36
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { Minus, Plus, Maximize2, X } from "lucide-react";
|
|
4
|
+
import { sizeSvgForExport } from "../../lib/mermaid.js";
|
|
5
|
+
import { ShareMenu } from "../share/share-menu.js";
|
|
6
|
+
import { buildDiagramShareOptions } from "./mermaid-share.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Full-screen, pan-and-zoom view of one diagram.
|
|
10
|
+
*
|
|
11
|
+
* Hand-rolled on pointer events rather than pulling a pan/zoom library: the
|
|
12
|
+
* whole surface is two transforms and the repo has no other dependency of that
|
|
13
|
+
* shape. Touch gestures work because the stage sets `touch-action: none` —
|
|
14
|
+
* the browser hands us every pointer instead of scrolling the page, which is
|
|
15
|
+
* also why no preventDefault is needed on the touch path. Wheel is the one
|
|
16
|
+
* exception: React's onWheel is passive, so it is bound manually.
|
|
17
|
+
*
|
|
18
|
+
* The SVG string is the same one the block already rendered, so opening the
|
|
19
|
+
* viewer costs no mermaid work. Both copies carry mermaid's ids; that is
|
|
20
|
+
* harmless because the markup is byte-identical — a `url(#id)` in either copy
|
|
21
|
+
* resolves to an identical marker.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const MIN_SCALE = 0.15;
|
|
25
|
+
const MAX_SCALE = 12;
|
|
26
|
+
/** Small diagrams may grow to fill the stage, but blowing one up 8x reads as broken. */
|
|
27
|
+
const MAX_FIT_SCALE = 2.5;
|
|
28
|
+
|
|
29
|
+
interface Point {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clampScale(s: number): number {
|
|
35
|
+
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function MermaidViewer({
|
|
39
|
+
svg,
|
|
40
|
+
source,
|
|
41
|
+
onClose,
|
|
42
|
+
}: {
|
|
43
|
+
svg: string;
|
|
44
|
+
source: string;
|
|
45
|
+
onClose: () => void;
|
|
46
|
+
}) {
|
|
47
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
49
|
+
// Mermaid ships `width: 100%` + `max-width` on the svg, which has no
|
|
50
|
+
// intrinsic size to measure or to scale from. Reuse the export sizer to pin
|
|
51
|
+
// it to its viewBox pixels; the transform below does all the scaling.
|
|
52
|
+
const sizedSvg = useMemo(() => sizeSvgForExport(svg), [svg]);
|
|
53
|
+
const [scale, setScale] = useState(1);
|
|
54
|
+
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 });
|
|
55
|
+
const [ready, setReady] = useState(false);
|
|
56
|
+
|
|
57
|
+
// Live gesture state lives in refs: pointer maths must not wait for a render.
|
|
58
|
+
const pointers = useRef(new Map<number, Point>());
|
|
59
|
+
const pinch = useRef<{ dist: number; center: Point } | null>(null);
|
|
60
|
+
const panFrom = useRef<{ pointer: Point; offset: Point } | null>(null);
|
|
61
|
+
const view = useRef({ scale: 1, offset: { x: 0, y: 0 } });
|
|
62
|
+
/** The scale `fit()` last produced — the baseline double-click toggles against. */
|
|
63
|
+
const fitScale = useRef(1);
|
|
64
|
+
/** Set once the user pans/zooms; after that nothing may re-fit behind their back. */
|
|
65
|
+
const touched = useRef(false);
|
|
66
|
+
|
|
67
|
+
const apply = useCallback((next: { scale: number; offset: Point }) => {
|
|
68
|
+
view.current = next;
|
|
69
|
+
setScale(next.scale);
|
|
70
|
+
setOffset(next.offset);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
/** Natural (untransformed) size of the diagram — offsetWidth ignores transforms. */
|
|
74
|
+
const natural = useCallback((): { w: number; h: number } | null => {
|
|
75
|
+
const el = contentRef.current;
|
|
76
|
+
if (!el || !el.offsetWidth || !el.offsetHeight) return null;
|
|
77
|
+
return { w: el.offsetWidth, h: el.offsetHeight };
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const fit = useCallback(() => {
|
|
81
|
+
const stage = stageRef.current;
|
|
82
|
+
const size = natural();
|
|
83
|
+
if (!stage || !size) return;
|
|
84
|
+
const pad = 32;
|
|
85
|
+
const sx = (stage.clientWidth - pad * 2) / size.w;
|
|
86
|
+
const sy = (stage.clientHeight - pad * 2) / size.h;
|
|
87
|
+
const next = clampScale(Math.min(MAX_FIT_SCALE, Math.min(sx, sy)));
|
|
88
|
+
fitScale.current = next;
|
|
89
|
+
apply({
|
|
90
|
+
scale: next,
|
|
91
|
+
offset: {
|
|
92
|
+
x: (stage.clientWidth - size.w * next) / 2,
|
|
93
|
+
y: (stage.clientHeight - size.h * next) / 2,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}, [apply, natural]);
|
|
97
|
+
|
|
98
|
+
useLayoutEffect(() => {
|
|
99
|
+
// Re-runs when the diagram itself changes, which happens while the message
|
|
100
|
+
// is still streaming — so a user who has already panned keeps their view.
|
|
101
|
+
if (!touched.current) fit();
|
|
102
|
+
setReady(true);
|
|
103
|
+
}, [fit, sizedSvg]);
|
|
104
|
+
|
|
105
|
+
/** Zoom about a stage-local anchor, so the point under the cursor/fingers stays put. */
|
|
106
|
+
const zoomAt = useCallback(
|
|
107
|
+
(factor: number, anchor: Point) => {
|
|
108
|
+
// Any deliberate zoom counts as touching the view — including the
|
|
109
|
+
// toolbar buttons and the wheel. Without this a diagram that is still
|
|
110
|
+
// streaming would re-fit itself out from under the reader's zoom.
|
|
111
|
+
touched.current = true;
|
|
112
|
+
const { scale: prev, offset: prevOffset } = view.current;
|
|
113
|
+
const next = clampScale(prev * factor);
|
|
114
|
+
if (next === prev) return;
|
|
115
|
+
const ratio = next / prev;
|
|
116
|
+
apply({
|
|
117
|
+
scale: next,
|
|
118
|
+
offset: {
|
|
119
|
+
x: anchor.x - (anchor.x - prevOffset.x) * ratio,
|
|
120
|
+
y: anchor.y - (anchor.y - prevOffset.y) * ratio,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
[apply]
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const zoomCenter = useCallback(
|
|
128
|
+
(factor: number) => {
|
|
129
|
+
const stage = stageRef.current;
|
|
130
|
+
if (!stage) return;
|
|
131
|
+
zoomAt(factor, { x: stage.clientWidth / 2, y: stage.clientHeight / 2 });
|
|
132
|
+
},
|
|
133
|
+
[zoomAt]
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const stagePoint = (e: { clientX: number; clientY: number }): Point => {
|
|
137
|
+
const rect = stageRef.current?.getBoundingClientRect();
|
|
138
|
+
return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Wheel must be bound manually: React's onWheel is passive, so it cannot
|
|
142
|
+
// preventDefault, and the page behind would scroll (or the browser would
|
|
143
|
+
// pinch-zoom the whole PWA on a ctrl+wheel trackpad gesture).
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const stage = stageRef.current;
|
|
146
|
+
if (!stage) return;
|
|
147
|
+
const onWheel = (e: WheelEvent) => {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
// A trackpad pinch arrives as ctrlKey+wheel with small deltas; a mouse
|
|
150
|
+
// wheel arrives as coarse notches. Both map to the same exponential.
|
|
151
|
+
const factor = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015));
|
|
152
|
+
zoomAt(factor, stagePoint(e));
|
|
153
|
+
};
|
|
154
|
+
stage.addEventListener("wheel", onWheel, { passive: false });
|
|
155
|
+
return () => stage.removeEventListener("wheel", onWheel);
|
|
156
|
+
}, [zoomAt]);
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const onKey = (e: KeyboardEvent) => {
|
|
160
|
+
if (e.key === "Escape") onClose();
|
|
161
|
+
else if (e.key === "+" || e.key === "=") zoomCenter(1.25);
|
|
162
|
+
else if (e.key === "-" || e.key === "_") zoomCenter(0.8);
|
|
163
|
+
else if (e.key === "0") fit();
|
|
164
|
+
};
|
|
165
|
+
document.addEventListener("keydown", onKey);
|
|
166
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
167
|
+
}, [onClose, zoomCenter, fit]);
|
|
168
|
+
|
|
169
|
+
// Lock body scroll while open.
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
const prev = document.body.style.overflow;
|
|
172
|
+
document.body.style.overflow = "hidden";
|
|
173
|
+
return () => {
|
|
174
|
+
document.body.style.overflow = prev;
|
|
175
|
+
};
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
// Re-fit on rotation / resize only while the view is untouched, so a resize
|
|
179
|
+
// never yanks a diagram the user has deliberately panned into place.
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
const onResize = () => {
|
|
182
|
+
if (!touched.current) fit();
|
|
183
|
+
};
|
|
184
|
+
window.addEventListener("resize", onResize);
|
|
185
|
+
return () => window.removeEventListener("resize", onResize);
|
|
186
|
+
}, [fit]);
|
|
187
|
+
|
|
188
|
+
const onPointerDown = (e: React.PointerEvent) => {
|
|
189
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
190
|
+
pointers.current.set(e.pointerId, stagePoint(e));
|
|
191
|
+
if (pointers.current.size === 2) {
|
|
192
|
+
const [a, b] = [...pointers.current.values()];
|
|
193
|
+
pinch.current = {
|
|
194
|
+
dist: Math.hypot(a.x - b.x, a.y - b.y),
|
|
195
|
+
center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
|
196
|
+
};
|
|
197
|
+
panFrom.current = null;
|
|
198
|
+
} else if (pointers.current.size === 1) {
|
|
199
|
+
panFrom.current = { pointer: stagePoint(e), offset: view.current.offset };
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const onPointerMove = (e: React.PointerEvent) => {
|
|
204
|
+
if (!pointers.current.has(e.pointerId)) return;
|
|
205
|
+
const p = stagePoint(e);
|
|
206
|
+
pointers.current.set(e.pointerId, p);
|
|
207
|
+
|
|
208
|
+
if (pointers.current.size >= 2 && pinch.current) {
|
|
209
|
+
const [a, b] = [...pointers.current.values()];
|
|
210
|
+
const dist = Math.hypot(a.x - b.x, a.y - b.y);
|
|
211
|
+
const center = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
212
|
+
if (pinch.current.dist > 0) {
|
|
213
|
+
touched.current = true;
|
|
214
|
+
// Pan and zoom in one step: the midpoint drift moves the diagram, the
|
|
215
|
+
// distance ratio scales it about that same midpoint.
|
|
216
|
+
const drift = {
|
|
217
|
+
x: center.x - pinch.current.center.x,
|
|
218
|
+
y: center.y - pinch.current.center.y,
|
|
219
|
+
};
|
|
220
|
+
// Committed through apply(), not written straight to the ref: two
|
|
221
|
+
// fingers moving in parallel (or a pinch already clamped at min/max)
|
|
222
|
+
// produce no scale change, and zoomAt() returns early — the drift
|
|
223
|
+
// would then be stranded in the ref and the diagram would not pan.
|
|
224
|
+
apply({
|
|
225
|
+
scale: view.current.scale,
|
|
226
|
+
offset: {
|
|
227
|
+
x: view.current.offset.x + drift.x,
|
|
228
|
+
y: view.current.offset.y + drift.y,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
zoomAt(dist / pinch.current.dist, center);
|
|
232
|
+
}
|
|
233
|
+
pinch.current = { dist, center };
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (panFrom.current) {
|
|
238
|
+
touched.current = true;
|
|
239
|
+
apply({
|
|
240
|
+
scale: view.current.scale,
|
|
241
|
+
offset: {
|
|
242
|
+
x: panFrom.current.offset.x + (p.x - panFrom.current.pointer.x),
|
|
243
|
+
y: panFrom.current.offset.y + (p.y - panFrom.current.pointer.y),
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const endPointer = (e: React.PointerEvent) => {
|
|
250
|
+
pointers.current.delete(e.pointerId);
|
|
251
|
+
if (pointers.current.size >= 2) {
|
|
252
|
+
// Still pinching, but with a DIFFERENT pair (a stray third touch got
|
|
253
|
+
// lifted). Rebase the baseline, or the next move would compare the new
|
|
254
|
+
// pair's spread against the old pair's and jump.
|
|
255
|
+
const [a, b] = [...pointers.current.values()];
|
|
256
|
+
pinch.current = {
|
|
257
|
+
dist: Math.hypot(a.x - b.x, a.y - b.y),
|
|
258
|
+
center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
|
259
|
+
};
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
pinch.current = null;
|
|
263
|
+
if (pointers.current.size === 1) {
|
|
264
|
+
// Lifting one finger of a pinch must not teleport the diagram: re-anchor
|
|
265
|
+
// the pan to whichever pointer is still down.
|
|
266
|
+
const [remaining] = [...pointers.current.values()];
|
|
267
|
+
panFrom.current = { pointer: remaining, offset: view.current.offset };
|
|
268
|
+
} else if (pointers.current.size === 0) {
|
|
269
|
+
panFrom.current = null;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const onDoubleClick = (e: React.MouseEvent) => {
|
|
274
|
+
touched.current = true;
|
|
275
|
+
// Compared against the FIT scale, not against 1: a diagram that fits at
|
|
276
|
+
// 125% would otherwise read as "already zoomed" and double-click would
|
|
277
|
+
// re-fit it to exactly where it already was.
|
|
278
|
+
if (view.current.scale > fitScale.current * 1.05) fit();
|
|
279
|
+
else zoomAt(2, stagePoint(e));
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const btn =
|
|
283
|
+
"flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground";
|
|
284
|
+
|
|
285
|
+
// Portalled to <body>: message bubbles animate through framer-motion, and a
|
|
286
|
+
// transformed ancestor becomes the containing block for `fixed`, which would
|
|
287
|
+
// pin this overlay to the bubble instead of the viewport.
|
|
288
|
+
return createPortal(
|
|
289
|
+
<div className="fixed inset-0 z-50 flex flex-col bg-background/95 backdrop-blur-sm">
|
|
290
|
+
<div
|
|
291
|
+
className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2"
|
|
292
|
+
style={{ paddingTop: "calc(0.5rem + env(safe-area-inset-top))" }}
|
|
293
|
+
>
|
|
294
|
+
<span className="px-1 font-[family-name:var(--font-mono)] text-xs tabular-nums text-muted-foreground">
|
|
295
|
+
{Math.round(scale * 100)}%
|
|
296
|
+
</span>
|
|
297
|
+
<div className="flex items-center gap-1">
|
|
298
|
+
<button type="button" title="Zoom out" onClick={() => zoomCenter(0.8)} className={btn}>
|
|
299
|
+
<Minus className="h-4 w-4" />
|
|
300
|
+
</button>
|
|
301
|
+
<button type="button" title="Zoom in" onClick={() => zoomCenter(1.25)} className={btn}>
|
|
302
|
+
<Plus className="h-4 w-4" />
|
|
303
|
+
</button>
|
|
304
|
+
<button type="button" title="Fit to screen" onClick={fit} className={btn}>
|
|
305
|
+
<Maximize2 className="h-4 w-4" />
|
|
306
|
+
</button>
|
|
307
|
+
<ShareMenu options={buildDiagramShareOptions(source)} title="Share diagram" />
|
|
308
|
+
<button type="button" title="Close" onClick={onClose} className={btn}>
|
|
309
|
+
<X className="h-4 w-4" />
|
|
310
|
+
</button>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<div
|
|
315
|
+
ref={stageRef}
|
|
316
|
+
className="relative flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing [&_svg]:max-w-none"
|
|
317
|
+
onPointerDown={onPointerDown}
|
|
318
|
+
onPointerMove={onPointerMove}
|
|
319
|
+
onPointerUp={endPointer}
|
|
320
|
+
onPointerCancel={endPointer}
|
|
321
|
+
onDoubleClick={onDoubleClick}
|
|
322
|
+
>
|
|
323
|
+
<div
|
|
324
|
+
ref={contentRef}
|
|
325
|
+
className="absolute left-0 top-0 origin-top-left"
|
|
326
|
+
style={{
|
|
327
|
+
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
|
328
|
+
visibility: ready ? "visible" : "hidden",
|
|
329
|
+
}}
|
|
330
|
+
dangerouslySetInnerHTML={{ __html: sizedSvg }}
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
</div>,
|
|
334
|
+
document.body
|
|
335
|
+
);
|
|
336
|
+
}
|
|
@@ -13,6 +13,7 @@ import { splitFrontmatter } from "../../lib/frontmatter.js";
|
|
|
13
13
|
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
|
+
import { buildDiagramShareOptions } from "../chat/mermaid-share.js";
|
|
16
17
|
import type { FileContentResponse } from "@schlessera/brain-ui-sdk/protocol";
|
|
17
18
|
|
|
18
19
|
export function FileViewer() {
|
|
@@ -296,6 +297,22 @@ function buildFileShareOptions(content: FileContentResponse, fileName: string):
|
|
|
296
297
|
];
|
|
297
298
|
}
|
|
298
299
|
|
|
300
|
+
// A standalone .mmd/.mermaid file is a diagram, so it shares like one — the
|
|
301
|
+
// same PNG/PDF/SVG/source set the in-chat diagram offers, plus its source file.
|
|
302
|
+
if (content.kind === "text" && isMermaidPath(content.path)) {
|
|
303
|
+
const source = content.content ?? "";
|
|
304
|
+
return [
|
|
305
|
+
...buildDiagramShareOptions(source, { filename: baseName(fileName) }),
|
|
306
|
+
{
|
|
307
|
+
id: "mmd-file",
|
|
308
|
+
label: "Share source file",
|
|
309
|
+
hint: "Original .mmd source",
|
|
310
|
+
run: async () =>
|
|
311
|
+
shareFile(new File([source], fileName, { type: "text/plain" }), { title: fileName }),
|
|
312
|
+
},
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
|
|
299
316
|
// Raw text / unknown: no share for V1 (user limited to previewable types).
|
|
300
317
|
return [];
|
|
301
318
|
}
|
|
@@ -17,8 +17,17 @@ interface ShareMenuProps {
|
|
|
17
17
|
title?: string;
|
|
18
18
|
/** When provided, override the default icon button with a custom trigger. */
|
|
19
19
|
className?: string;
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Optional render-prop for a custom trigger. Receives the click handler plus
|
|
22
|
+
* the status icon, so a custom trigger keeps the busy/done/error feedback
|
|
23
|
+
* instead of silently swallowing it.
|
|
24
|
+
*/
|
|
25
|
+
renderTrigger?: (props: {
|
|
26
|
+
onClick: () => void;
|
|
27
|
+
busy: boolean;
|
|
28
|
+
status: Status;
|
|
29
|
+
icon: React.ReactNode;
|
|
30
|
+
}) => React.ReactNode;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
type Status = "idle" | "busy" | "done" | "error";
|
|
@@ -35,13 +44,18 @@ export function ShareMenu({ options, title = "Share", className, renderTrigger }
|
|
|
35
44
|
if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
|
|
36
45
|
};
|
|
37
46
|
const onKey = (e: KeyboardEvent) => {
|
|
38
|
-
if (e.key
|
|
47
|
+
if (e.key !== "Escape") return;
|
|
48
|
+
// Captured and stopped: an Escape that dismisses this menu must not also
|
|
49
|
+
// reach whatever the menu is layered over (the diagram viewer closes on
|
|
50
|
+
// Escape too, and dismissing both at once reads as a bug).
|
|
51
|
+
e.stopPropagation();
|
|
52
|
+
setOpen(false);
|
|
39
53
|
};
|
|
40
54
|
document.addEventListener("mousedown", onDocClick);
|
|
41
|
-
document.addEventListener("keydown", onKey);
|
|
55
|
+
document.addEventListener("keydown", onKey, true);
|
|
42
56
|
return () => {
|
|
43
57
|
document.removeEventListener("mousedown", onDocClick);
|
|
44
|
-
document.removeEventListener("keydown", onKey);
|
|
58
|
+
document.removeEventListener("keydown", onKey, true);
|
|
45
59
|
};
|
|
46
60
|
}, [open]);
|
|
47
61
|
|
|
@@ -87,7 +101,7 @@ export function ShareMenu({ options, title = "Share", className, renderTrigger }
|
|
|
87
101
|
return (
|
|
88
102
|
<div ref={wrapperRef} className={cn("relative inline-flex", className)}>
|
|
89
103
|
{renderTrigger ? (
|
|
90
|
-
renderTrigger({ onClick: handleTrigger, busy: status === "busy" })
|
|
104
|
+
renderTrigger({ onClick: handleTrigger, busy: status === "busy", status, icon })
|
|
91
105
|
) : (
|
|
92
106
|
<button
|
|
93
107
|
onClick={handleTrigger}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { ClientEnvironment } from "@schlessera/brain-ui-sdk/protocol";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What this browser can actually do, measured rather than guessed.
|
|
5
|
+
*
|
|
6
|
+
* The agent is told this so it stops offering things the device can't do —
|
|
7
|
+
* "share that to WhatsApp" on a desktop with no share sheet, "take a photo of
|
|
8
|
+
* it" on a machine with no camera, "I'll check where you are" with
|
|
9
|
+
* geolocation unavailable. Every check is feature detection; nothing here
|
|
10
|
+
* parses a user-agent string, which is both unreliable and a fingerprinting
|
|
11
|
+
* surface we have no use for.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NOT included: anything that would prompt. Device presence is
|
|
14
|
+
* read from enumerateDevices(), which reports KINDS without permission (labels
|
|
15
|
+
* stay empty) — asking the reader for camera permission in order to write a
|
|
16
|
+
* system prompt would be a hostile trade.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Marks the element whose width is the actual reading column. */
|
|
20
|
+
export const READING_COLUMN_ATTR = "data-reading-column";
|
|
21
|
+
|
|
22
|
+
/** Cached device inventory; undefined until the async probe has answered once. */
|
|
23
|
+
let mediaKinds: { camera: boolean; microphone: boolean } | undefined;
|
|
24
|
+
let probing = false;
|
|
25
|
+
let deviceListenerBound = false;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ask which input devices exist. Cheap, permission-free, and async — so it is
|
|
29
|
+
* primed in the background and read synchronously at send time. The first
|
|
30
|
+
* message of a session may go out without the camera/mic facts rather than
|
|
31
|
+
* blocking on a device enumeration.
|
|
32
|
+
*/
|
|
33
|
+
export async function primeClientEnvironment(force = false): Promise<void> {
|
|
34
|
+
if ((mediaKinds && !force) || probing) return;
|
|
35
|
+
if (typeof navigator === "undefined") return;
|
|
36
|
+
const media = navigator.mediaDevices;
|
|
37
|
+
if (!media || typeof media.enumerateDevices !== "function") return;
|
|
38
|
+
probing = true;
|
|
39
|
+
try {
|
|
40
|
+
const devices = await media.enumerateDevices();
|
|
41
|
+
mediaKinds = {
|
|
42
|
+
camera: devices.some((d) => d.kind === "videoinput"),
|
|
43
|
+
microphone: devices.some((d) => d.kind === "audioinput"),
|
|
44
|
+
};
|
|
45
|
+
// A headset plugged in (or a webcam unplugged) mid-conversation changes
|
|
46
|
+
// the honest answer, so the cache follows the hardware.
|
|
47
|
+
if (!deviceListenerBound && typeof media.addEventListener === "function") {
|
|
48
|
+
deviceListenerBound = true;
|
|
49
|
+
media.addEventListener("devicechange", () => void primeClientEnvironment(true));
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Enumeration blocked (permissions policy, older browser) — leave the
|
|
53
|
+
// capability unreported rather than guessing it exists.
|
|
54
|
+
} finally {
|
|
55
|
+
probing = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @internal Test seam — drops the cached device inventory. */
|
|
60
|
+
export function resetClientEnvironmentCache(): void {
|
|
61
|
+
mediaKinds = undefined;
|
|
62
|
+
probing = false;
|
|
63
|
+
deviceListenerBound = false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function detectClientEnvironment(): ClientEnvironment | undefined {
|
|
67
|
+
if (typeof window === "undefined" || typeof navigator === "undefined") return undefined;
|
|
68
|
+
// Fire-and-forget: answers by the next message even if this one misses it.
|
|
69
|
+
void primeClientEnvironment();
|
|
70
|
+
|
|
71
|
+
const coarse = matches("(pointer: coarse)");
|
|
72
|
+
const env: ClientEnvironment = { formFactor: formFactor(coarse) };
|
|
73
|
+
|
|
74
|
+
if (coarse) env.touch = true;
|
|
75
|
+
if (matches("(display-mode: standalone)") || matches("(display-mode: fullscreen)")) {
|
|
76
|
+
env.standalone = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (mediaKinds?.camera) env.camera = true;
|
|
80
|
+
if (mediaKinds?.microphone) env.microphone = true;
|
|
81
|
+
// getUserMedia and geolocation both require a secure context, which is also
|
|
82
|
+
// what the location tool needs.
|
|
83
|
+
if (navigator.geolocation && window.isSecureContext !== false) env.geolocation = true;
|
|
84
|
+
|
|
85
|
+
if (typeof navigator.share === "function") {
|
|
86
|
+
env.share = true;
|
|
87
|
+
// canShare({files}) needs a real File to answer honestly; a 1-byte probe
|
|
88
|
+
// is what the share paths themselves check before offering an image.
|
|
89
|
+
try {
|
|
90
|
+
const probe = new File([new Uint8Array(1)], "probe.png", { type: "image/png" });
|
|
91
|
+
if (typeof navigator.canShare === "function" && navigator.canShare({ files: [probe] })) {
|
|
92
|
+
env.shareFiles = true;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// No File constructor / canShare threw — leave shareFiles unset.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const column = readingColumnWidth();
|
|
100
|
+
if (column) env.viewportWidth = column;
|
|
101
|
+
|
|
102
|
+
// Both are re-validated server-side against the platform's own locale and
|
|
103
|
+
// timezone databases; anything unrecognized is dropped there.
|
|
104
|
+
const locale = navigator.language;
|
|
105
|
+
if (locale && locale.length <= 35) env.locale = locale;
|
|
106
|
+
try {
|
|
107
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
108
|
+
if (zone && zone.length <= 64) env.timeZone = zone;
|
|
109
|
+
} catch {
|
|
110
|
+
// Intl unavailable — omit.
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return env;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Phone vs tablet from the SHORT edge of the screen, not from the current
|
|
118
|
+
* window width: a phone in landscape is 900+px wide and would otherwise be
|
|
119
|
+
* reported as a tablet, flipping the device description (and busting the
|
|
120
|
+
* prompt cache) every time the reader rotates.
|
|
121
|
+
*/
|
|
122
|
+
function formFactor(coarse: boolean): ClientEnvironment["formFactor"] {
|
|
123
|
+
if (!coarse) return "desktop";
|
|
124
|
+
const w = window.screen?.width ?? window.innerWidth ?? 0;
|
|
125
|
+
const h = window.screen?.height ?? window.innerHeight ?? 0;
|
|
126
|
+
const shortEdge = w && h ? Math.min(w, h) : w || h;
|
|
127
|
+
return shortEdge && shortEdge >= 600 ? "tablet" : "phone";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Width of the column text actually renders into — the chat layout caps it far
|
|
132
|
+
* below the window — rounded to 50px so that dragging a window edge does not
|
|
133
|
+
* rewrite the system prompt (and invalidate its cache) on every message.
|
|
134
|
+
*/
|
|
135
|
+
function readingColumnWidth(): number | undefined {
|
|
136
|
+
const el = document.querySelector(`[${READING_COLUMN_ATTR}]`);
|
|
137
|
+
const measured =
|
|
138
|
+
el?.getBoundingClientRect().width ||
|
|
139
|
+
window.innerWidth ||
|
|
140
|
+
document.documentElement?.clientWidth ||
|
|
141
|
+
0;
|
|
142
|
+
if (!measured) return undefined;
|
|
143
|
+
return Math.max(50, Math.round(measured / 50) * 50);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function matches(query: string): boolean {
|
|
147
|
+
try {
|
|
148
|
+
return typeof window.matchMedia === "function" && window.matchMedia(query).matches;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|