@djangocfg/ui-tools 2.1.302 → 2.1.303

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 (28) hide show
  1. package/dist/{DocsLayout-ZHNRRAKR.mjs → DocsLayout-6ECALRLD.mjs} +3 -3
  2. package/dist/{DocsLayout-ZHNRRAKR.mjs.map → DocsLayout-6ECALRLD.mjs.map} +1 -1
  3. package/dist/{DocsLayout-4PQLBZHE.cjs → DocsLayout-ASPSECYR.cjs} +48 -48
  4. package/dist/{DocsLayout-4PQLBZHE.cjs.map → DocsLayout-ASPSECYR.cjs.map} +1 -1
  5. package/dist/{Mermaid.client-XFQ74OYN.mjs → Mermaid.client-SXRRI2YW.mjs} +43 -6
  6. package/dist/Mermaid.client-SXRRI2YW.mjs.map +1 -0
  7. package/dist/{Mermaid.client-RSWUUHIL.cjs → Mermaid.client-W76R5AKJ.cjs} +43 -6
  8. package/dist/Mermaid.client-W76R5AKJ.cjs.map +1 -0
  9. package/dist/{chunk-47NGNO5U.mjs → chunk-K35OF7OB.mjs} +68 -37
  10. package/dist/chunk-K35OF7OB.mjs.map +1 -0
  11. package/dist/{chunk-M4BLG3RZ.cjs → chunk-PFKR6ZPZ.cjs} +68 -37
  12. package/dist/chunk-PFKR6ZPZ.cjs.map +1 -0
  13. package/dist/index.cjs +11 -11
  14. package/dist/index.d.cts +7 -0
  15. package/dist/index.d.ts +7 -0
  16. package/dist/index.mjs +5 -5
  17. package/package.json +6 -6
  18. package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +43 -26
  19. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +208 -0
  20. package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +8 -3
  21. package/src/components/markdown/MarkdownMessage/components.tsx +69 -14
  22. package/src/tools/Mermaid/Mermaid.client.tsx +10 -1
  23. package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +76 -3
  24. package/src/tools/Mermaid/index.tsx +7 -0
  25. package/dist/Mermaid.client-RSWUUHIL.cjs.map +0 -1
  26. package/dist/Mermaid.client-XFQ74OYN.mjs.map +0 -1
  27. package/dist/chunk-47NGNO5U.mjs.map +0 -1
  28. package/dist/chunk-M4BLG3RZ.cjs.map +0 -1
@@ -19,18 +19,34 @@ interface CodeBlockProps {
19
19
  * branch below still ships its own button because the plain <pre>
20
20
  * has no toolbar of its own.
21
21
  */
22
- export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isUser, isCompact = false }) => {
22
+ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isCompact = false }) => {
23
23
  const theme = useResolvedTheme();
24
24
 
25
+ // Hoist derived values out of JSX (COMPONENTS.md
26
+ // "Data Preparation Before Render"):
27
+ // `textSizeClass` — chat-density font tier, so the JSX line
28
+ // stays a one-liner.
29
+ // The `--code` token (ui-core/styles/theme/tokens.css) handles the
30
+ // palette in both themes / both bubble roles; we don't branch on
31
+ // `isUser` here on purpose.
32
+ const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
33
+
25
34
  return (
26
35
  <div className="my-3">
27
36
  <PrettyCode
28
37
  data={code}
29
38
  language={language}
30
- className={isCompact ? 'text-xs' : 'text-sm'}
31
- customBg={isUser ? 'bg-white/10' : 'bg-muted dark:bg-muted'}
39
+ className={textSizeClass}
40
+ customBg="bg-code"
32
41
  mode={theme}
33
42
  isCompact={isCompact}
43
+ // Disable click-to-scroll-isolation in chat markdown: code
44
+ // fences here are part of an assistant reply, not a docs
45
+ // viewer. Forcing the user to click into a small block to
46
+ // scroll past it interrupts the reading flow. The standalone
47
+ // PrettyCode use cases (docs, diff viewers, long code panes)
48
+ // keep the default `true`.
49
+ scrollIsolation={false}
34
50
  />
35
51
  </div>
36
52
  );
@@ -38,26 +54,27 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isUser, is
38
54
 
39
55
  /** Simple `<pre>` fallback used when CodeBlock throws (lazy module
40
56
  * failure, missing PrettyCode peer, etc). */
41
- export const CodeBlockFallback: React.FC<CodeBlockProps> = ({ code, isUser }) => (
42
- <div className="relative group my-3">
43
- <CopyButton
44
- value={code}
45
- variant="ghost"
46
- className={`
47
- absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
48
- h-8 w-8
49
- ${isUser
50
- ? 'hover:bg-white/20 text-white'
51
- : 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
52
- }
53
- `}
54
- title="Copy code"
55
- />
56
- <pre className={`
57
- p-3 rounded text-xs font-mono overflow-x-auto
58
- ${isUser ? 'bg-white/10 text-white' : 'bg-muted text-foreground'}
59
- `}>
60
- <code>{code}</code>
61
- </pre>
62
- </div>
63
- );
57
+ export const CodeBlockFallback: React.FC<CodeBlockProps> = ({ code, isUser }) => {
58
+ // See CodeBlock above for the spirit of this layout — palette
59
+ // pre-computed before render.
60
+ const copyHoverClass = isUser
61
+ ? 'hover:bg-white/20 text-white'
62
+ : 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground';
63
+ const copyButtonClass =
64
+ `absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity ` +
65
+ `h-8 w-8 ${copyHoverClass}`;
66
+
67
+ return (
68
+ <div className="relative group my-3">
69
+ <CopyButton
70
+ value={code}
71
+ variant="ghost"
72
+ className={copyButtonClass}
73
+ title="Copy code"
74
+ />
75
+ <pre className="p-3 rounded text-xs font-mono overflow-x-auto bg-code text-code-foreground border border-code-border">
76
+ <code>{code}</code>
77
+ </pre>
78
+ </div>
79
+ );
80
+ };
@@ -213,6 +213,117 @@ curl -sSL cmdop.com/install.sh | bash
213
213
 
214
214
  Если что-то не работает — скажи.`;
215
215
 
216
+ // Mermaid payload — exercises the `mermaid` fence branch in
217
+ // components.tsx (lines 101–106). MarkdownMessage hands the code body
218
+ // to <Mermaid> instead of PrettyCode when the fence language is
219
+ // `mermaid`. We pick a small flowchart + a sequence diagram so the
220
+ // story reflects what an LLM typically emits.
221
+ const MERMAID_CONTENT = `Here's the chat session lifecycle:
222
+
223
+ \`\`\`mermaid
224
+ flowchart LR
225
+ A[User types] --> B[ChatProvider]
226
+ B --> C{streaming?}
227
+ C -->|yes| D[CancelMessage]
228
+ C -->|no| E[SendMessage]
229
+ D --> E
230
+ E --> F[Agent stream]
231
+ F --> G[Tokens arrive]
232
+ G --> H[Reducer dispatch]
233
+ H --> I[Bubble re-renders]
234
+ \`\`\`
235
+
236
+ And the cancel flow as a sequence:
237
+
238
+ \`\`\`mermaid
239
+ sequenceDiagram
240
+ participant U as User
241
+ participant C as ChatInput
242
+ participant P as ChatProvider
243
+ participant S as ChatService
244
+ U->>C: Press Enter (mid-stream)
245
+ C->>P: sendMessage(text)
246
+ P->>S: CancelMessage
247
+ S-->>P: STREAM_CANCELLED
248
+ P->>S: SendMessage(text)
249
+ S-->>P: STREAM_START
250
+ \`\`\`
251
+
252
+ The renderer routes \`mermaid\` fences to the diagram component;
253
+ non-mermaid fences (above) keep going through PrettyCode.`;
254
+
255
+ // Kitchen-sink: every block element the renderer can produce, in one
256
+ // payload, so any spacing regression is visible at a glance. Pairs
257
+ // neighbouring elements that are easy to break — `p → hr → p`,
258
+ // `code → list`, `list → table → list`, blockquote-with-paragraphs.
259
+ const KITCHEN_SINK = `# Top-level heading
260
+
261
+ Short intro paragraph that sits right under the heading. The next
262
+ paragraph should have a clear gap from this one, not be glued to it.
263
+
264
+ Second paragraph — verifies \`p + p\` spacing.
265
+
266
+ ---
267
+
268
+ After the divider, the next paragraph should still breathe. The
269
+ divider itself is a soft hairline, not a heavy 1px gray bar.
270
+
271
+ ## Subheading h2
272
+
273
+ Plain *italic*, **bold**, and \`inline code\` mid-sentence. Inline
274
+ code should sit on the line, not push it taller.
275
+
276
+ ### Subheading h3
277
+
278
+ A short list:
279
+
280
+ - Bullet one with a [link](https://example.com)
281
+ - Bullet two with \`inline code\`
282
+ - Bullet three — short
283
+
284
+ A numbered list:
285
+
286
+ 1. First step
287
+ 2. Second step with **bold** inside
288
+ 3. Third step
289
+
290
+ A loose list (paragraph-wrapped \`<li>\`):
291
+
292
+ - First item
293
+
294
+ with a follow-up paragraph that should NOT explode the list.
295
+
296
+ - Second item, also loose.
297
+
298
+ #### Heading h4
299
+
300
+ > Blockquote without italic, with a left border. Lighter colour for
301
+ > de-emphasis. Multi-line quotes wrap cleanly.
302
+ >
303
+ > Second paragraph inside the same blockquote.
304
+
305
+ A code fence:
306
+
307
+ \`\`\`go
308
+ func add(a, b int) int {
309
+ // verifies code block spacing, rounded corners, dark bg
310
+ return a + b
311
+ }
312
+ \`\`\`
313
+
314
+ A table — wraps inside a horizontally scrollable container:
315
+
316
+ | Field | Type | Notes |
317
+ |---|---|---|
318
+ | id | string | UUID |
319
+ | name | string | display name |
320
+ | online | bool | last-heartbeat ≤ 60s |
321
+
322
+ ---
323
+
324
+ Closing paragraph after a second divider, to verify spacing holds at
325
+ the very end too.`;
326
+
216
327
  function ChatBubble({
217
328
  role,
218
329
  content,
@@ -383,3 +494,100 @@ export const ChatBubbles = () => (
383
494
  </div>
384
495
  </div>
385
496
  );
497
+
498
+ // Kitchen-sink: stress-test every element the renderer can emit in a
499
+ // single bubble. If something regresses (e.g. paragraphs collapse, hr
500
+ // shows a heavy gray bar, table loses borders, list items glue to
501
+ // each other), it's visible at a glance.
502
+ //
503
+ // Numbers in MarkdownMessage.tsx come from research on ChatGPT /
504
+ // Claude.ai / Cursor 2025 — see KitchenSink output as the visual
505
+ // contract. Don't tune values by feel; pair-check against this view.
506
+ export const KitchenSink = () => (
507
+ <div className="mx-auto max-w-2xl space-y-6 p-6">
508
+ <div>
509
+ <h3 className="mb-2 text-sm font-semibold">
510
+ Every block element in one bubble
511
+ </h3>
512
+ <p className="mb-3 text-xs text-muted-foreground">
513
+ Visual contract for the renderer. Heading sizes downscaled
514
+ chat-style (h1 ≈ body), <code>p</code>/<code>ul</code>/
515
+ <code>ol</code>/<code>pre</code>/<code>blockquote</code>/
516
+ <code>hr</code>/<code>table</code> all share a coherent
517
+ rhythm. Loose lists keep their density — no exploded gap on
518
+ paragraph-wrapped <code>&lt;li&gt;</code>.
519
+ </p>
520
+ <div className="rounded-lg border border-border bg-card p-4">
521
+ <ChatBubble
522
+ role="assistant"
523
+ content={KITCHEN_SINK}
524
+ rules={subtleRules}
525
+ />
526
+ </div>
527
+ </div>
528
+
529
+ <div>
530
+ <h3 className="mb-2 text-sm font-semibold">
531
+ Same payload on the user-side palette
532
+ </h3>
533
+ <p className="mb-3 text-xs text-muted-foreground">
534
+ Verifies that <code>prose-invert</code> doesn't break any of
535
+ the new rules — saturated bubbles need the same spacing.
536
+ </p>
537
+ <div className="rounded-lg border border-border bg-card p-4">
538
+ <ChatBubble
539
+ role="user"
540
+ content={KITCHEN_SINK}
541
+ rules={onPrimaryRules}
542
+ />
543
+ </div>
544
+ </div>
545
+ </div>
546
+ );
547
+
548
+ // Mermaid diagrams. Live-renders the fence body via the <Mermaid>
549
+ // tool (lazy-loaded). Useful as a manual smoke test for: (1) the
550
+ // `language === 'mermaid'` branch in components.tsx, (2) the SVG
551
+ // scaling inside a chat-density bubble, (3) interaction with the
552
+ // surrounding markdown spacing rules (paragraph above/below the
553
+ // diagram should keep the same rhythm as around a code fence).
554
+ export const MermaidDiagrams = () => (
555
+ <div className="mx-auto max-w-2xl space-y-6 p-6">
556
+ <div>
557
+ <h3 className="mb-2 text-sm font-semibold">
558
+ Mermaid in an assistant bubble
559
+ </h3>
560
+ <p className="mb-3 text-xs text-muted-foreground">
561
+ Two diagrams (flowchart + sequence) wrapped in surrounding
562
+ prose. Verifies that <code>```mermaid</code> fences route to{' '}
563
+ <code>&lt;Mermaid&gt;</code> and that the SVG fits the
564
+ bubble's max-width without breaking the layout.
565
+ </p>
566
+ <div className="rounded-lg border border-border bg-card p-4">
567
+ <ChatBubble
568
+ role="assistant"
569
+ content={MERMAID_CONTENT}
570
+ rules={subtleRules}
571
+ />
572
+ </div>
573
+ </div>
574
+
575
+ <div>
576
+ <h3 className="mb-2 text-sm font-semibold">
577
+ Same payload on the user-side palette
578
+ </h3>
579
+ <p className="mb-3 text-xs text-muted-foreground">
580
+ On a saturated <code>bg-primary</code> bubble the diagram
581
+ keeps its own surface — Mermaid renders SVG with its own
582
+ background, independent of the bubble palette.
583
+ </p>
584
+ <div className="rounded-lg border border-border bg-card p-4">
585
+ <ChatBubble
586
+ role="user"
587
+ content={MERMAID_CONTENT}
588
+ rules={onPrimaryRules}
589
+ />
590
+ </div>
591
+ </div>
592
+ </div>
593
+ );
@@ -177,9 +177,14 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
177
177
  ${isUser ? 'prose-invert' : 'dark:prose-invert'}
178
178
  [&>*]:leading-relaxed
179
179
  [&>*:first-child]:mt-0 [&>*:last-child]:mb-0
180
- [&_p]:my-0 [&_p+p]:mt-2
181
- [&_ul]:my-2 [&_ol]:my-2 [&_pre]:my-2 [&_blockquote]:my-2
182
- [&_h1]:mt-3 [&_h1]:mb-1 [&_h2]:mt-3 [&_h2]:mb-1 [&_h3]:mt-2 [&_h3]:mb-1
180
+ [&_p]:my-2
181
+ [&_ul]:my-2 [&_ol]:my-2 [&_ul]:pl-5 [&_ol]:pl-5
182
+ [&_li]:my-1 [&_li>p]:my-0
183
+ [&_pre]:my-3
184
+ [&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold
185
+ [&_h2]:mt-3.5 [&_h2]:mb-1.5 [&_h2]:text-[15px] [&_h2]:font-semibold
186
+ [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-medium
187
+ [&_h4]:mt-3 [&_h4]:mb-1 [&_h4]:text-sm [&_h4]:font-medium
183
188
  `}
184
189
  style={{
185
190
  // Inherit colors from parent — fixes issues with external
@@ -99,9 +99,15 @@ export function createMarkdownComponents(
99
99
  }
100
100
 
101
101
  if (language === 'mermaid') {
102
+ // Inline render fits the bubble width; the Mermaid component
103
+ // owns its own click-to-fullscreen modal which sizes against
104
+ // the viewport, so we don't cap it here. A previous version
105
+ // hardcoded `max-w-[600px]` and that constraint leaked into
106
+ // the fullscreen modal too — diagram rendered at 600px in the
107
+ // middle of an empty viewport.
102
108
  return (
103
- <div className="my-3 max-w-full overflow-x-auto">
104
- <Mermaid chart={codeContent} className="max-w-[600px] mx-auto" isCompact={isCompact} />
109
+ <div className="my-3 w-full">
110
+ <Mermaid chart={codeContent} isCompact={isCompact} />
105
111
  </div>
106
112
  );
107
113
  }
@@ -120,33 +126,82 @@ export function createMarkdownComponents(
120
126
  if (className?.includes('language-')) {
121
127
  return <code className={className}>{children}</code>;
122
128
  }
129
+ // Inline `<code>` uses the design system's `--code-inline`
130
+ // token. One semantic chip surface across both themes; on a
131
+ // user bubble we still palette-switch with `text-primary-
132
+ // foreground` because the inline chip blends INTO the bubble
133
+ // — there's no panel boundary like a fence has. We trade a
134
+ // perfect "code surface" tone here for legible body inheritance,
135
+ // matching ChatGPT's behaviour in coloured user bubbles.
136
+ const inlineCodeClass = isUser
137
+ ? 'bg-primary-foreground/15 text-primary-foreground'
138
+ : 'bg-code-inline text-code-inline-foreground';
123
139
  return (
124
- <code className="px-1.5 py-0.5 rounded text-xs font-mono bg-muted text-foreground break-all">
140
+ <code className={`px-1 py-0.5 rounded font-mono text-[0.875em] ${inlineCodeClass} break-all`}>
125
141
  {extractTextFromChildren(children)}
126
142
  </code>
127
143
  );
128
144
  },
129
145
 
130
- blockquote: ({ children }) => (
131
- <blockquote className={`${textSize} border-l-2 border-border pl-3 my-2 italic text-muted-foreground break-words`}>
132
- {children}
133
- </blockquote>
134
- ),
146
+ // Modern chat convention drops italic on blockquotes — italic +
147
+ // tight bubble = hard to read. Border-left at 2px (4px reads
148
+ // heavy in a 320–480px bubble). On the saturated user bubble we
149
+ // use a primary-foreground tint; on the assistant bubble we use
150
+ // the muted-foreground role for de-emphasis.
151
+ blockquote: ({ children }) => {
152
+ const cls = isUser
153
+ ? 'border-primary-foreground/40 text-primary-foreground/80'
154
+ : 'border-border text-muted-foreground';
155
+ return (
156
+ <blockquote className={`${textSize} border-l-2 pl-3 my-3 break-words ${cls}`}>
157
+ {children}
158
+ </blockquote>
159
+ );
160
+ },
135
161
 
162
+ // Tables: outer wrapper handles overflow, inner `<table>`
163
+ // inherits the chat-density text size. Borders / header use
164
+ // semantic tokens — `border-code-border` for the assistant
165
+ // (matches the code-fence panel for visual cohesion when both
166
+ // appear in the same reply); primary-foreground/N for the user
167
+ // bubble so lines read against the saturated `bg-primary`.
136
168
  table: ({ children }) => (
137
169
  <div className="overflow-x-auto my-3">
138
170
  <table className={`min-w-full ${textSize} border-collapse`}>{children}</table>
139
171
  </div>
140
172
  ),
141
- thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
173
+ thead: ({ children }) => (
174
+ <thead className={isUser ? 'bg-primary-foreground/10' : 'bg-muted/40'}>
175
+ {children}
176
+ </thead>
177
+ ),
142
178
  tbody: ({ children }) => <tbody>{children}</tbody>,
143
- tr: ({ children }) => <tr className="border-b border-border/50">{children}</tr>,
144
- th: ({ children }) => (
145
- <th className="px-2 py-1 text-left font-medium break-words">{children}</th>
179
+ tr: ({ children }) => (
180
+ <tr className={isUser ? 'border-b border-primary-foreground/15' : 'border-b border-border'}>
181
+ {children}
182
+ </tr>
146
183
  ),
147
- td: ({ children }) => <td className="px-2 py-1 break-words">{children}</td>,
184
+ th: ({ children }) => {
185
+ const borderCls = isUser ? 'border-primary-foreground/25' : 'border-border';
186
+ return (
187
+ <th className={`px-2 py-1.5 text-left font-semibold border-b ${borderCls} break-words`}>
188
+ {children}
189
+ </th>
190
+ );
191
+ },
192
+ td: ({ children }) => <td className="px-2 py-1.5 break-words">{children}</td>,
148
193
 
149
- hr: () => <hr className="my-3 border-0 h-px bg-border" />,
194
+ // Soft separator. ChatGPT / Slack / Linear strip the visible
195
+ // line, Claude.ai keeps a hairline. We follow Claude — present
196
+ // but quiet. Palette switches by role so the hairline reads on
197
+ // both surfaces.
198
+ hr: () => (
199
+ <hr
200
+ className={`my-4 border-0 h-px ${
201
+ isUser ? 'bg-primary-foreground/20' : 'bg-border'
202
+ }`}
203
+ />
204
+ ),
150
205
 
151
206
  strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
152
207
  em: ({ children }) => <em className="italic">{children}</em>,
@@ -15,6 +15,14 @@ interface MermaidProps {
15
15
  isCompact?: boolean;
16
16
  /** Enable click-to-fullscreen functionality (default: true) */
17
17
  fullscreen?: boolean;
18
+ /**
19
+ * Enable the FloatingToolbar's "click to scroll" lock overlay.
20
+ * Defaults to `false` — Mermaid SVGs are short, the diagram itself
21
+ * doesn't scroll internally, and a lock overlay just steals
22
+ * wheel events from the page. Standalone callers can opt in if
23
+ * they have a reason to.
24
+ */
25
+ scrollIsolation?: boolean;
18
26
  }
19
27
 
20
28
  const Mermaid: React.FC<MermaidProps> = ({
@@ -22,6 +30,7 @@ const Mermaid: React.FC<MermaidProps> = ({
22
30
  className = '',
23
31
  isCompact = false,
24
32
  fullscreen = true,
33
+ scrollIsolation = false,
25
34
  }) => {
26
35
  const containerRef = useRef<HTMLDivElement>(null);
27
36
  const theme = useResolvedTheme();
@@ -58,7 +67,7 @@ const Mermaid: React.FC<MermaidProps> = ({
58
67
  )}
59
68
 
60
69
  {svgContent && !isRendering && (
61
- <FloatingToolbar containerRef={containerRef}>
70
+ <FloatingToolbar containerRef={containerRef} scrollIsolation={scrollIsolation}>
62
71
  <CopyAction value={chart} title="Copy source" />
63
72
  {fullscreen && (
64
73
  <FullscreenAction onToggle={openFullscreen} title="Fullscreen" />
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { useEffect } from 'react';
3
+ import React, { useEffect, useState } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { X, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
6
6
  import { TransformWrapper, TransformComponent, useControls } from 'react-zoom-pan-pinch';
@@ -47,6 +47,58 @@ export const MermaidFullscreenModal: React.FC<MermaidFullscreenModalProps> = ({
47
47
  onClose,
48
48
  onBackdropClick,
49
49
  }) => {
50
+ // Auto-fit scale on open. Two failure modes drove this design:
51
+ //
52
+ // 1. Stale state across re-opens. Without a reset, the second
53
+ // open would still see the previous fit value in state; if
54
+ // the new SVG had the same dimensions the `key` swap below
55
+ // wouldn't fire and TransformWrapper would skip re-init.
56
+ // 2. SVG not in DOM yet at first rAF. Mermaid renders into
57
+ // `fullscreenRef` after the modal portal mounts; on a fast
58
+ // paint the first `querySelector('svg')` returned null and
59
+ // the scale stayed at the fallback `1`. Retry across a few
60
+ // frames until the bbox is real, then commit.
61
+ //
62
+ // `openSeq` increments on every open so the `key` always changes,
63
+ // forcing a fresh TransformWrapper instance even when the fit
64
+ // value happens to repeat.
65
+ const [initialScale, setInitialScale] = useState<number | null>(null);
66
+ const [openSeq, setOpenSeq] = useState(0);
67
+ useEffect(() => {
68
+ if (!isOpen) {
69
+ // Reset so the next open recomputes from scratch.
70
+ setInitialScale(null);
71
+ return;
72
+ }
73
+ setOpenSeq((n) => n + 1);
74
+ let cancelled = false;
75
+ let attempts = 0;
76
+ const tick = () => {
77
+ if (cancelled) return;
78
+ attempts += 1;
79
+ const svg = fullscreenRef.current?.querySelector('svg');
80
+ const bbox = svg?.getBoundingClientRect();
81
+ if (svg && bbox && bbox.width > 1 && bbox.height > 1) {
82
+ const targetW = window.innerWidth * 0.9;
83
+ const targetH = window.innerHeight * 0.9;
84
+ const fit = Math.min(targetW / bbox.width, targetH / bbox.height);
85
+ setInitialScale(Math.max(1, Math.min(fit, 6)));
86
+ return;
87
+ }
88
+ // Give Mermaid up to ~30 frames (~0.5s @ 60fps) to paint
89
+ // before settling for the unscaled fallback.
90
+ if (attempts < 30) {
91
+ requestAnimationFrame(tick);
92
+ } else {
93
+ setInitialScale(1);
94
+ }
95
+ };
96
+ requestAnimationFrame(tick);
97
+ return () => {
98
+ cancelled = true;
99
+ };
100
+ }, [isOpen, svgContent, fullscreenRef]);
101
+
50
102
  // Apply text colors
51
103
  useEffect(() => {
52
104
  if (isOpen && fullscreenRef.current) {
@@ -78,6 +130,13 @@ export const MermaidFullscreenModal: React.FC<MermaidFullscreenModalProps> = ({
78
130
 
79
131
  if (!isOpen || typeof document === 'undefined') return null;
80
132
 
133
+ // Hoist derived values out of JSX (COMPONENTS.md "Data Preparation
134
+ // Before Render"). Keeps the returned tree pure markup, makes it
135
+ // obvious at the top of the function which inputs feed which
136
+ // node, and surfaces every dependency to a reader at a glance.
137
+ const transformInitialScale = initialScale ?? 1;
138
+ const transformKey = `${openSeq}-${initialScale ?? 'pending'}`;
139
+
81
140
  return createPortal(
82
141
  <div
83
142
  className="fixed inset-0 z-9999 bg-background/95 backdrop-blur-sm"
@@ -93,9 +152,23 @@ export const MermaidFullscreenModal: React.FC<MermaidFullscreenModalProps> = ({
93
152
  <X className="h-5 w-5" />
94
153
  </Button>
95
154
 
96
- {/* Zoomable diagram */}
155
+ {/* Zoomable diagram. `key={openSeq}-${initialScale ?? 'pending'}`
156
+ forces a fresh TransformWrapper:
157
+ - on every modal open (openSeq increments) so the
158
+ re-opened modal never inherits the prior session's
159
+ transform;
160
+ - whenever the auto-fit value lands (null → number)
161
+ so the wrapper, which only reads `initialScale`
162
+ at mount time, picks up the freshly measured fit.
163
+ We can't gate the whole subtree on `initialScale != null`
164
+ because the SVG host (`fullscreenRef` div) lives inside
165
+ TransformComponent — without it in the DOM, the rAF
166
+ measure loop has nothing to read and we'd deadlock at
167
+ null forever. Mounting with placeholder `1` first and
168
+ re-mounting once we know the fit is the cheap fix. */}
97
169
  <TransformWrapper
98
- initialScale={1}
170
+ key={transformKey}
171
+ initialScale={transformInitialScale}
99
172
  minScale={0.1}
100
173
  maxScale={10}
101
174
  centerOnInit
@@ -25,6 +25,13 @@ export interface MermaidProps {
25
25
  isCompact?: boolean;
26
26
  /** Enable click-to-fullscreen functionality (default: true) */
27
27
  fullscreen?: boolean;
28
+ /**
29
+ * Enable the FloatingToolbar's "click to scroll" lock overlay.
30
+ * Defaults to `false` — Mermaid diagrams don't scroll internally,
31
+ * so the lock overlay just steals page wheel events. See the
32
+ * Mermaid.client implementation for the full rationale.
33
+ */
34
+ scrollIsolation?: boolean;
28
35
  }
29
36
 
30
37
  const Mermaid: React.FC<MermaidProps> = (props) => {