@schlessera/brain-ui-react 0.7.2 → 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.
Files changed (72) hide show
  1. package/dist/components/chat/brain-markdown.d.ts.map +1 -1
  2. package/dist/components/chat/brain-markdown.js +93 -55
  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 +10 -1
  6. package/dist/components/chat/chat-page.js.map +1 -1
  7. package/dist/components/chat/copy-button.d.ts +6 -0
  8. package/dist/components/chat/copy-button.d.ts.map +1 -0
  9. package/dist/components/chat/copy-button.js +16 -0
  10. package/dist/components/chat/copy-button.js.map +1 -0
  11. package/dist/components/chat/mermaid-block.d.ts +14 -0
  12. package/dist/components/chat/mermaid-block.d.ts.map +1 -0
  13. package/dist/components/chat/mermaid-block.js +55 -0
  14. package/dist/components/chat/mermaid-block.js.map +1 -0
  15. package/dist/components/chat/mermaid-share.d.ts +5 -0
  16. package/dist/components/chat/mermaid-share.d.ts.map +1 -0
  17. package/dist/components/chat/mermaid-share.js +80 -0
  18. package/dist/components/chat/mermaid-share.js.map +1 -0
  19. package/dist/components/chat/mermaid-viewer.d.ts +6 -0
  20. package/dist/components/chat/mermaid-viewer.d.ts.map +1 -0
  21. package/dist/components/chat/mermaid-viewer.js +266 -0
  22. package/dist/components/chat/mermaid-viewer.js.map +1 -0
  23. package/dist/components/chat/message-share.d.ts.map +1 -1
  24. package/dist/components/chat/message-share.js +7 -4
  25. package/dist/components/chat/message-share.js.map +1 -1
  26. package/dist/components/chat/share-block.d.ts.map +1 -1
  27. package/dist/components/chat/share-block.js +5 -2
  28. package/dist/components/chat/share-block.js.map +1 -1
  29. package/dist/components/files/file-viewer.d.ts.map +1 -1
  30. package/dist/components/files/file-viewer.js +30 -5
  31. package/dist/components/files/file-viewer.js.map +1 -1
  32. package/dist/components/share/share-menu.d.ts +8 -1
  33. package/dist/components/share/share-menu.d.ts.map +1 -1
  34. package/dist/components/share/share-menu.js +10 -5
  35. package/dist/components/share/share-menu.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/lib/client-environment.d.ts +29 -0
  41. package/dist/lib/client-environment.d.ts.map +1 -0
  42. package/dist/lib/client-environment.js +153 -0
  43. package/dist/lib/client-environment.js.map +1 -0
  44. package/dist/lib/mermaid-theme.d.ts +4 -0
  45. package/dist/lib/mermaid-theme.d.ts.map +1 -0
  46. package/dist/lib/mermaid-theme.js +221 -0
  47. package/dist/lib/mermaid-theme.js.map +1 -0
  48. package/dist/lib/mermaid.d.ts +62 -0
  49. package/dist/lib/mermaid.d.ts.map +1 -0
  50. package/dist/lib/mermaid.js +244 -0
  51. package/dist/lib/mermaid.js.map +1 -0
  52. package/dist/stores/file-store.d.ts.map +1 -1
  53. package/dist/stores/file-store.js +9 -2
  54. package/dist/stores/file-store.js.map +1 -1
  55. package/dist/styles.css +1 -1
  56. package/package.json +3 -2
  57. package/src/components/chat/brain-markdown.tsx +109 -80
  58. package/src/components/chat/chat-page.tsx +20 -1
  59. package/src/components/chat/copy-button.tsx +26 -0
  60. package/src/components/chat/mermaid-block.tsx +106 -0
  61. package/src/components/chat/mermaid-share.ts +91 -0
  62. package/src/components/chat/mermaid-viewer.tsx +336 -0
  63. package/src/components/chat/message-share.ts +7 -4
  64. package/src/components/chat/share-block.tsx +5 -2
  65. package/src/components/files/file-viewer.tsx +37 -5
  66. package/src/components/share/share-menu.tsx +20 -6
  67. package/src/index.ts +5 -0
  68. package/src/lib/client-environment.ts +152 -0
  69. package/src/lib/mermaid-theme.ts +246 -0
  70. package/src/lib/mermaid.ts +287 -0
  71. package/src/stores/file-store.ts +12 -2
  72. package/src/theme.css +10 -0
@@ -0,0 +1,106 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Code, ChartNetwork, Expand } from "lucide-react";
3
+ import { peekMermaidSvg, renderMermaidSvg } from "../../lib/mermaid.js";
4
+ import { ShareMenu } from "../share/share-menu.js";
5
+ import { CopyButton } from "./copy-button.js";
6
+ import { buildDiagramShareOptions } from "./mermaid-share.js";
7
+ import { MermaidViewer } from "./mermaid-viewer.js";
8
+
9
+ /**
10
+ * A ```mermaid fence, rendered as a diagram.
11
+ *
12
+ * Streaming-safe by construction: while the fence is still arriving (or the
13
+ * source is invalid) the raw source shows as an ordinary code block; each
14
+ * debounced parse that succeeds swaps in the fresh SVG, and a parse that
15
+ * fails keeps the last good diagram instead of flashing an error. Renders
16
+ * are cached module-wide, so the per-token re-render of a streaming message
17
+ * costs a cache lookup, not a mermaid parse.
18
+ */
19
+ export function MermaidBlock({ source }: { source: string }) {
20
+ const [svg, setSvg] = useState<string | null>(() => peekMermaidSvg(source));
21
+ const [showSource, setShowSource] = useState(false);
22
+ const [zoomed, setZoomed] = useState(false);
23
+ const latest = useRef(0);
24
+ const lastAttempt = useRef(0);
25
+
26
+ useEffect(() => {
27
+ const id = ++latest.current;
28
+ const cached = peekMermaidSvg(source);
29
+ if (cached) {
30
+ setSvg(cached);
31
+ return;
32
+ }
33
+ // Debounce with a throttle floor: a pure debounce would starve during a
34
+ // continuous stream of WS deltas and only render once the stream pauses.
35
+ // Attempting at least every 400ms lets the diagram grow mid-stream while
36
+ // still coalescing per-token churn.
37
+ const wait = Date.now() - lastAttempt.current > 400 ? 0 : 150;
38
+ const t = setTimeout(() => {
39
+ lastAttempt.current = Date.now();
40
+ void renderMermaidSvg(source).then((result) => {
41
+ if (latest.current !== id || !result) return;
42
+ setSvg(result);
43
+ });
44
+ }, wait);
45
+ return () => clearTimeout(t);
46
+ }, [source]);
47
+
48
+ const diagramReady = svg !== null;
49
+ const showDiagram = diagramReady && !showSource;
50
+
51
+ const iconBtn =
52
+ "flex h-7 w-7 items-center justify-center rounded-md bg-surface-raised/80 text-muted-foreground transition-all hover:bg-surface-overlay hover:text-foreground";
53
+
54
+ return (
55
+ // No `overflow-hidden` here: the share menu drops out of the toolbar and a
56
+ // clipping ancestor would cut off its lower entries on a short diagram.
57
+ // Children carry their own padding, so nothing bleeds past the radius.
58
+ <div className="group relative my-3 rounded-lg border border-border bg-surface">
59
+ {showDiagram ? (
60
+ <div
61
+ className="cursor-zoom-in overflow-x-auto p-4 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full"
62
+ // The whole diagram is the affordance: a phone-sized flowchart is
63
+ // unreadable in place, and the hover toolbar is pointer-only.
64
+ onClick={() => setZoomed(true)}
65
+ dangerouslySetInnerHTML={{ __html: svg ?? "" }}
66
+ />
67
+ ) : (
68
+ <pre className="overflow-x-auto p-4 font-[family-name:var(--font-mono)] text-[13px] leading-relaxed">
69
+ <code>{source}</code>
70
+ </pre>
71
+ )}
72
+ <div className="absolute right-2 top-2 flex items-center gap-1 opacity-0 transition-all focus-within:opacity-100 group-hover:opacity-100">
73
+ {showDiagram && (
74
+ <button type="button" title="Open diagram" onClick={() => setZoomed(true)} className={iconBtn}>
75
+ <Expand className="h-3.5 w-3.5" />
76
+ </button>
77
+ )}
78
+ {diagramReady && (
79
+ <ShareMenu
80
+ options={buildDiagramShareOptions(source)}
81
+ title="Share diagram"
82
+ renderTrigger={({ onClick, busy, icon }) => (
83
+ <button type="button" title="Share diagram" onClick={onClick} disabled={busy} className={iconBtn}>
84
+ {icon}
85
+ </button>
86
+ )}
87
+ />
88
+ )}
89
+ {diagramReady && (
90
+ <button
91
+ type="button"
92
+ title={showDiagram ? "Show source" : "Show diagram"}
93
+ onClick={() => setShowSource((s) => !s)}
94
+ className={iconBtn}
95
+ >
96
+ {showDiagram ? <Code className="h-3.5 w-3.5" /> : <ChartNetwork className="h-3.5 w-3.5" />}
97
+ </button>
98
+ )}
99
+ <CopyButton getText={() => source} className={iconBtn} />
100
+ </div>
101
+ {zoomed && svg && (
102
+ <MermaidViewer svg={svg} source={source} onClose={() => setZoomed(false)} />
103
+ )}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,91 @@
1
+ import { uiConfig } from "../../config.js";
2
+ import { renderMermaidSvg, sizeSvgForExport } from "../../lib/mermaid.js";
3
+ import { renderAndShare, shareFile, shareText } from "../../lib/share.js";
4
+ import type { ShareOption } from "../share/share-menu.js";
5
+
6
+ /**
7
+ * Share options for a single diagram.
8
+ *
9
+ * Exports are rendered in the LIGHT theme, not the dark one on screen: a share
10
+ * lands in WhatsApp, a mail client or a printer, all of which assume paper.
11
+ * PNG and PDF go through the same /api/render path as every other share, so a
12
+ * diagram in a shared message and a diagram shared on its own are the same
13
+ * pixels.
14
+ */
15
+
16
+ /** Guard: the render route rejects a body over 512 KiB, so fail with a readable message. */
17
+ const MAX_CONTENT_CHARS = 480_000;
18
+
19
+ function figureHtml(svg: string, tight: boolean): string {
20
+ // `width: fit-content` is what makes a PNG crop to the diagram instead of to
21
+ // the render viewport — the screenshot clips to the body box. PDF keeps the
22
+ // template's normal page flow, since an A4 page has a fixed width anyway.
23
+ const style = tight
24
+ ? "<style>body{width:fit-content;padding:24px}.mermaid-figure{margin:0}</style>"
25
+ : "";
26
+ return `${style}<div class="mermaid-figure">${svg.replace(/[\r\n]+/g, " ")}</div>`;
27
+ }
28
+
29
+ async function exportSvg(source: string): Promise<string> {
30
+ const svg = await renderMermaidSvg(source, "light");
31
+ if (!svg) throw new Error("This diagram can't be rendered yet");
32
+ return sizeSvgForExport(svg);
33
+ }
34
+
35
+ async function exportHtml(source: string, tight: boolean): Promise<string> {
36
+ const html = figureHtml(await exportSvg(source), tight);
37
+ if (html.length > MAX_CONTENT_CHARS) {
38
+ throw new Error("Diagram is too large to share as an image");
39
+ }
40
+ return html;
41
+ }
42
+
43
+ export function buildDiagramShareOptions(
44
+ source: string,
45
+ opts: { filename?: string } = {}
46
+ ): ShareOption[] {
47
+ const filename = opts.filename ?? "diagram";
48
+ return [
49
+ {
50
+ id: "image",
51
+ label: "Image (PNG)",
52
+ hint: "Cropped to the diagram",
53
+ run: async () =>
54
+ renderAndShare({
55
+ content: await exportHtml(source, true),
56
+ contentType: "html",
57
+ format: "png",
58
+ filename,
59
+ title: uiConfig.shareTitle,
60
+ }),
61
+ },
62
+ {
63
+ id: "pdf",
64
+ label: "PDF",
65
+ hint: "Vector PDF, A4",
66
+ run: async () =>
67
+ renderAndShare({
68
+ content: await exportHtml(source, false),
69
+ contentType: "html",
70
+ format: "pdf",
71
+ filename,
72
+ title: uiConfig.shareTitle,
73
+ }),
74
+ },
75
+ {
76
+ id: "svg",
77
+ label: "Vector (SVG)",
78
+ hint: "Scales to any size",
79
+ run: async () =>
80
+ shareFile(new File([await exportSvg(source)], `${filename}.svg`, { type: "image/svg+xml" }), {
81
+ title: uiConfig.shareTitle,
82
+ }),
83
+ },
84
+ {
85
+ id: "source",
86
+ label: "Mermaid source",
87
+ hint: "Paste into another tool",
88
+ run: () => shareText({ text: source, title: uiConfig.shareTitle }),
89
+ },
90
+ ];
91
+ }
@@ -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
+ }
@@ -2,6 +2,7 @@ import { uiConfig } from "../../config.js";
2
2
  import type { RefObject } from "react";
3
3
  import { renderAndShare, shareFile, shareText, copyRichText } from "../../lib/share.js";
4
4
  import { stripMarkdown } from "../../lib/strip-markdown.js";
5
+ import { inlineMermaidDiagrams } from "../../lib/mermaid.js";
5
6
  import type { ShareOption } from "../share/share-menu.js";
6
7
 
7
8
  interface BuildOpts {
@@ -18,9 +19,11 @@ export function buildMessageShareOptions({ content, renderedRef }: BuildOpts): S
18
19
  id: "image",
19
20
  label: "Image (PNG)",
20
21
  hint: "Rendered snapshot",
21
- run: () =>
22
+ run: async () =>
22
23
  renderAndShare({
23
- content,
24
+ // The render page runs without JavaScript, so mermaid fences are
25
+ // pre-rendered to inline SVG here on the client.
26
+ content: await inlineMermaidDiagrams(content),
24
27
  contentType: "markdown",
25
28
  format: "png",
26
29
  filename: "message",
@@ -31,9 +34,9 @@ export function buildMessageShareOptions({ content, renderedRef }: BuildOpts): S
31
34
  id: "pdf",
32
35
  label: "PDF",
33
36
  hint: "Vector PDF, A4",
34
- run: () =>
37
+ run: async () =>
35
38
  renderAndShare({
36
- content,
39
+ content: await inlineMermaidDiagrams(content),
37
40
  contentType: "markdown",
38
41
  format: "pdf",
39
42
  filename: "message",
@@ -4,6 +4,7 @@ import { BrainMarkdown } from "./brain-markdown.js";
4
4
  import { ShareMenu, type ShareOption } from "../share/share-menu.js";
5
5
  import { renderAndShare, shareFile, shareText, copyRichText } from "../../lib/share.js";
6
6
  import { stripMarkdown } from "../../lib/strip-markdown.js";
7
+ import { inlineMermaidDiagrams } from "../../lib/mermaid.js";
7
8
  import { Share2 } from "lucide-react";
8
9
 
9
10
  export type ShareBlockFormat = "image" | "pdf" | "text" | "markdown" | "richtext";
@@ -86,7 +87,9 @@ async function runFormat(
86
87
  switch (fmt) {
87
88
  case "image":
88
89
  return renderAndShare({
89
- content: body,
90
+ // The render page runs without JavaScript — mermaid fences are
91
+ // pre-rendered to inline SVG on the client.
92
+ content: await inlineMermaidDiagrams(body),
90
93
  contentType: "markdown",
91
94
  format: "png",
92
95
  filename,
@@ -94,7 +97,7 @@ async function runFormat(
94
97
  });
95
98
  case "pdf":
96
99
  return renderAndShare({
97
- content: body,
100
+ content: await inlineMermaidDiagrams(body),
98
101
  contentType: "markdown",
99
102
  format: "pdf",
100
103
  filename,
@@ -11,6 +11,9 @@ import { API_BASE } from "../../lib/backend.js";
11
11
  import { fetchAsFile, shareFile, renderAndShare } from "../../lib/share.js";
12
12
  import { splitFrontmatter } from "../../lib/frontmatter.js";
13
13
  import { stripMarkdown } from "../../lib/strip-markdown.js";
14
+ import { inlineMermaidDiagrams, isMermaidPath } from "../../lib/mermaid.js";
15
+ import { MermaidBlock } from "../chat/mermaid-block.js";
16
+ import { buildDiagramShareOptions } from "../chat/mermaid-share.js";
14
17
  import type { FileContentResponse } from "@schlessera/brain-ui-sdk/protocol";
15
18
 
16
19
  export function FileViewer() {
@@ -32,7 +35,10 @@ export function FileViewer() {
32
35
  }
33
36
 
34
37
  const fileName = currentPath.split("/").pop() ?? currentPath;
35
- const previewAvailable = content?.kind === "markdown" || content?.kind === "html";
38
+ const previewAvailable =
39
+ content?.kind === "markdown" ||
40
+ content?.kind === "html" ||
41
+ (content?.kind === "text" && isMermaidPath(content.path));
36
42
 
37
43
  return (
38
44
  <div className="flex h-full flex-col">
@@ -70,6 +76,14 @@ function ViewerBody({ content, viewMode }: { content: NonNullable<ReturnType<typ
70
76
  if (content.kind === "binary") {
71
77
  return <FileViewerBinary content={content} />;
72
78
  }
79
+ // Standalone mermaid source files (.mmd / .mermaid) preview as a diagram.
80
+ if (viewMode === "preview" && content.kind === "text" && isMermaidPath(content.path)) {
81
+ return (
82
+ <div className="brain-prose max-w-none px-6 py-4">
83
+ <MermaidBlock source={content.content ?? ""} />
84
+ </div>
85
+ );
86
+ }
73
87
  if (viewMode === "raw" || (content.kind !== "markdown" && content.kind !== "html")) {
74
88
  return <FileViewerRaw content={content.content ?? ""} fileName={content.path} />;
75
89
  }
@@ -216,9 +230,11 @@ function buildFileShareOptions(content: FileContentResponse, fileName: string):
216
230
  id: "md-png",
217
231
  label: "Share as image",
218
232
  hint: "Rendered PNG snapshot",
219
- run: () =>
233
+ run: async () =>
220
234
  renderAndShare({
221
- content: body,
235
+ // The render page runs without JavaScript — mermaid fences are
236
+ // pre-rendered to inline SVG on the client.
237
+ content: await inlineMermaidDiagrams(body),
222
238
  contentType: "markdown",
223
239
  format: "png",
224
240
  filename: baseName(fileName),
@@ -229,9 +245,9 @@ function buildFileShareOptions(content: FileContentResponse, fileName: string):
229
245
  id: "md-pdf",
230
246
  label: "Share as PDF",
231
247
  hint: "Vector PDF, A4",
232
- run: () =>
248
+ run: async () =>
233
249
  renderAndShare({
234
- content: body,
250
+ content: await inlineMermaidDiagrams(body),
235
251
  contentType: "markdown",
236
252
  format: "pdf",
237
253
  filename: baseName(fileName),
@@ -281,6 +297,22 @@ function buildFileShareOptions(content: FileContentResponse, fileName: string):
281
297
  ];
282
298
  }
283
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
+
284
316
  // Raw text / unknown: no share for V1 (user limited to previewable types).
285
317
  return [];
286
318
  }