@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.
- package/dist/{DocsLayout-ZHNRRAKR.mjs → DocsLayout-6ECALRLD.mjs} +3 -3
- package/dist/{DocsLayout-ZHNRRAKR.mjs.map → DocsLayout-6ECALRLD.mjs.map} +1 -1
- package/dist/{DocsLayout-4PQLBZHE.cjs → DocsLayout-ASPSECYR.cjs} +48 -48
- package/dist/{DocsLayout-4PQLBZHE.cjs.map → DocsLayout-ASPSECYR.cjs.map} +1 -1
- package/dist/{Mermaid.client-XFQ74OYN.mjs → Mermaid.client-SXRRI2YW.mjs} +43 -6
- package/dist/Mermaid.client-SXRRI2YW.mjs.map +1 -0
- package/dist/{Mermaid.client-RSWUUHIL.cjs → Mermaid.client-W76R5AKJ.cjs} +43 -6
- package/dist/Mermaid.client-W76R5AKJ.cjs.map +1 -0
- package/dist/{chunk-47NGNO5U.mjs → chunk-K35OF7OB.mjs} +68 -37
- package/dist/chunk-K35OF7OB.mjs.map +1 -0
- package/dist/{chunk-M4BLG3RZ.cjs → chunk-PFKR6ZPZ.cjs} +68 -37
- package/dist/chunk-PFKR6ZPZ.cjs.map +1 -0
- package/dist/index.cjs +11 -11
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.mjs +5 -5
- package/package.json +6 -6
- package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +43 -26
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +208 -0
- package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +8 -3
- package/src/components/markdown/MarkdownMessage/components.tsx +69 -14
- package/src/tools/Mermaid/Mermaid.client.tsx +10 -1
- package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +76 -3
- package/src/tools/Mermaid/index.tsx +7 -0
- package/dist/Mermaid.client-RSWUUHIL.cjs.map +0 -1
- package/dist/Mermaid.client-XFQ74OYN.mjs.map +0 -1
- package/dist/chunk-47NGNO5U.mjs.map +0 -1
- 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,
|
|
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={
|
|
31
|
-
customBg=
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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><li></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><Mermaid></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-
|
|
181
|
-
[&_ul]:my-2 [&_ol]:my-2 [&
|
|
182
|
-
[&
|
|
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
|
|
104
|
-
<Mermaid chart={codeContent}
|
|
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=
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 }) =>
|
|
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 }) =>
|
|
144
|
-
|
|
145
|
-
|
|
179
|
+
tr: ({ children }) => (
|
|
180
|
+
<tr className={isUser ? 'border-b border-primary-foreground/15' : 'border-b border-border'}>
|
|
181
|
+
{children}
|
|
182
|
+
</tr>
|
|
146
183
|
),
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|