Skip to content

Commit c1304d7

Browse files
ui: add source toggle to mermaid and svg blocks (ggml-org#24652)
* ui: add source toggle to mermaid and svg blocks Add a toggle button next to copy and preview that switches a rendered mermaid or svg block to its source code and back. The button is shared by both block types and the rendered view stays the default. The source view reuses the code block scroll container and the highlighted code element captured at transform time, so it matches the app code blocks without highlighting again. Make tall diagrams scroll like text code blocks: safe centering keeps the diagram centered when it fits and falls back to start alignment when it overflows, so the top stays reachable instead of clipping above. Keep the block header opaque and layered above the scrolled diagram, and ignore header clicks in the zoom handler, so a button click never falls through to the zoom dialog. * ui: transparent diagram block header, address review from @allozaur
1 parent 02810c7 commit c1304d7

9 files changed

Lines changed: 192 additions & 21 deletions

File tree

tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
DATA_ERROR_HANDLED_ATTR,
4242
BOOL_TRUE_STRING,
4343
SETTINGS_KEYS,
44+
CODE_BLOCK_HEADER_CLASS,
4445
MERMAID_WRAPPER_CLASS,
4546
MERMAID_BLOCK_CLASS,
4647
MERMAID_LANGUAGE,
@@ -53,7 +54,11 @@
5354
SVG_TAG_PREFIX,
5455
SVG_SOURCE_ATTR,
5556
SVG_RENDERED_ATTR,
56-
SVG_INLINE_SHADOW_STYLE
57+
SVG_INLINE_SHADOW_STYLE,
58+
TOGGLE_SOURCE_BTN_CLASS,
59+
DIAGRAM_VIEW_MODE_ATTR,
60+
DIAGRAM_VIEW_RENDERED,
61+
DIAGRAM_VIEW_SOURCE
5762
} from '$lib/constants';
5863
import { ColorMode, UrlProtocol } from '$lib/enums';
5964
import { FileTypeText } from '$lib/enums/files.enums';
@@ -501,6 +506,23 @@
501506
async function handleMermaidClick(event: MouseEvent) {
502507
const target = event.target as HTMLElement;
503508
509+
// Toggle a diagram block between its rendered view and its source view.
510+
// Shared by mermaid and svg, css drives the visibility from the wrapper mode.
511+
const toggleBtn = target.closest(`.${TOGGLE_SOURCE_BTN_CLASS}`);
512+
if (toggleBtn) {
513+
event.preventDefault();
514+
event.stopPropagation();
515+
516+
const wrapper = toggleBtn.closest(`.${MERMAID_WRAPPER_CLASS}, .${SVG_WRAPPER_CLASS}`);
517+
if (!wrapper) return;
518+
519+
const isSource = wrapper.getAttribute(DIAGRAM_VIEW_MODE_ATTR) === DIAGRAM_VIEW_SOURCE;
520+
const next = isSource ? DIAGRAM_VIEW_RENDERED : DIAGRAM_VIEW_SOURCE;
521+
wrapper.setAttribute(DIAGRAM_VIEW_MODE_ATTR, next);
522+
toggleBtn.setAttribute('aria-pressed', String(!isSource));
523+
return;
524+
}
525+
504526
// Check if clicking on copy or preview button in mermaid block
505527
const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
506528
const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
@@ -573,6 +595,11 @@
573595
}
574596
}
575597
598+
// A click on the header chrome targets the action buttons, never the
599+
// diagram. Guard so a header click can not fall through to the click to
600+
// zoom branches below, whatever the scroll position or stacking.
601+
if (target.closest(`.${CODE_BLOCK_HEADER_CLASS}`)) return;
602+
576603
// Open preview when clicking the svg block itself. A final block carries its
577604
// source, a streaming block does not and is mirrored live into the dialog.
578605
const svgEl = target.closest(`.${SVG_BLOCK_CLASS}`);

tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,8 @@ div.markdown-user-content :global(.table-wrapper) {
300300
}
301301

302302
.markdown-content :global(.copy-code-btn),
303-
.markdown-content :global(.preview-code-btn) {
303+
.markdown-content :global(.preview-code-btn),
304+
.markdown-content :global(.toggle-source-btn) {
304305
display: flex;
305306
align-items: center;
306307
justify-content: center;
@@ -312,15 +313,22 @@ div.markdown-user-content :global(.table-wrapper) {
312313
}
313314

314315
.markdown-content :global(.copy-code-btn:hover),
315-
.markdown-content :global(.preview-code-btn:hover) {
316+
.markdown-content :global(.preview-code-btn:hover),
317+
.markdown-content :global(.toggle-source-btn:hover) {
316318
transform: scale(1.05);
317319
}
318320

319321
.markdown-content :global(.copy-code-btn:active),
320-
.markdown-content :global(.preview-code-btn:active) {
322+
.markdown-content :global(.preview-code-btn:active),
323+
.markdown-content :global(.toggle-source-btn:active) {
321324
transform: scale(0.95);
322325
}
323326

327+
/* Pressed state marks the source view as active */
328+
.markdown-content :global(.toggle-source-btn[aria-pressed='true']) {
329+
color: var(--primary);
330+
}
331+
324332
.markdown-content :global(.code-block-wrapper pre) {
325333
background: transparent;
326334
margin: 0;
@@ -629,8 +637,8 @@ div.markdown-user-content :global(.table-wrapper) {
629637
overflow-y: auto;
630638
overflow-x: auto;
631639
display: flex;
632-
align-items: center;
633-
justify-content: center;
640+
align-items: safe center;
641+
justify-content: safe center;
634642
padding: 3rem 1rem 1rem;
635643
}
636644

@@ -645,7 +653,9 @@ div.markdown-user-content :global(.table-wrapper) {
645653
overflow-y: visible;
646654
}
647655

648-
/* Diagram block uses same header styling as code blocks */
656+
/* Diagram block uses same header styling as code blocks. The header floats over
657+
scrollable diagram content and stays transparent, so the overflow shows up to
658+
the box edge. It keeps a z-index so it stays the click target above content. */
649659
.markdown-content :global(.mermaid-block-wrapper .code-block-header),
650660
.markdown-content :global(.svg-block-wrapper .code-block-header) {
651661
display: flex;
@@ -657,6 +667,7 @@ div.markdown-user-content :global(.table-wrapper) {
657667
top: 0;
658668
left: 0;
659669
right: 0;
670+
z-index: 2;
660671
}
661672

662673
.markdown-content :global(.mermaid-block-wrapper .code-block-actions),
@@ -683,6 +694,31 @@ div.markdown-user-content :global(.table-wrapper) {
683694
padding: 3rem 1rem;
684695
}
685696

697+
/* Source view stays hidden while the block renders, css swaps the two views
698+
from the wrapper mode so the click handler only flips one attribute. The view
699+
reuses the code block scroll container, so it matches the app code blocks. */
700+
.markdown-content :global(.diagram-source) {
701+
display: none;
702+
text-align: left;
703+
}
704+
705+
.markdown-content :global(.diagram-source pre) {
706+
background: transparent;
707+
margin: 0;
708+
border-radius: 0;
709+
border: none;
710+
font-size: 0.875rem;
711+
}
712+
713+
.markdown-content :global([data-view-mode='source'] .mermaid-scroll-container),
714+
.markdown-content :global([data-view-mode='source'] .svg-scroll-container) {
715+
display: none;
716+
}
717+
718+
.markdown-content :global([data-view-mode='source'] .diagram-source) {
719+
display: block;
720+
}
721+
686722
/* Streaming mermaid block - empty preview box */
687723
.mermaid-streaming-block {
688724
min-height: 300px;

tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/code-block-utils.ts

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ import type { Element, ElementContent } from 'hast';
77
import {
88
CODE_BLOCK_HEADER_CLASS,
99
CODE_BLOCK_ACTIONS_CLASS,
10+
CODE_BLOCK_SCROLL_CONTAINER_CLASS,
1011
CODE_LANGUAGE_CLASS,
1112
COPY_CODE_BTN_CLASS,
1213
PREVIEW_CODE_BTN_CLASS,
14+
TOGGLE_SOURCE_BTN_CLASS,
15+
DIAGRAM_SOURCE_CLASS,
1316
RELATIVE_CLASS,
1417
COPY_ICON_SVG,
15-
PREVIEW_ICON_SVG
18+
PREVIEW_ICON_SVG,
19+
CODE_ICON_SVG
1620
} from '$lib/constants';
1721

1822
export interface BlockIdGenerator {
@@ -32,14 +36,16 @@ export function createIconElement(svg: string): Element {
3236
}
3337

3438
/**
35-
* Creates a button element with icon.
39+
* Creates a button element with icon. Extra properties merge onto the button,
40+
* which lets a stateful button carry attributes like aria-pressed.
3641
*/
3742
export function createButton(
3843
className: string,
3944
title: string,
4045
iconSvg: string,
4146
id: string,
42-
idAttribute: string
47+
idAttribute: string,
48+
extraProperties: Record<string, string> = {}
4349
): Element {
4450
return {
4551
type: 'element',
@@ -48,7 +54,8 @@ export function createButton(
4854
className: [className],
4955
[idAttribute]: id,
5056
title,
51-
type: 'button'
57+
type: 'button',
58+
...extraProperties
5259
},
5360
children: [createIconElement(iconSvg)]
5461
};
@@ -72,6 +79,52 @@ export function createPreviewButton(
7279
return createButton(PREVIEW_CODE_BTN_CLASS, title, PREVIEW_ICON_SVG, id, idAttribute);
7380
}
7481

82+
/**
83+
* Creates a button that toggles a diagram block between its rendered view and
84+
* its source view. aria-pressed starts false, the rendered view is the default.
85+
*/
86+
export function createToggleSourceButton(
87+
id: string,
88+
idAttribute: string,
89+
title: string = 'Toggle source'
90+
): Element {
91+
return createButton(TOGGLE_SOURCE_BTN_CLASS, title, CODE_ICON_SVG, id, idAttribute, {
92+
'aria-pressed': 'false'
93+
});
94+
}
95+
96+
/**
97+
* Creates a source view for a diagram block. It reuses the code block scroll
98+
* container so it matches the app code blocks, and wraps the highlighted code
99+
* element captured at transform time. A missing code element falls back to a
100+
* plain code node built from the raw source.
101+
*/
102+
export function createSourceView(
103+
codeElement: Element | undefined,
104+
source: string,
105+
language: string
106+
): Element {
107+
const code: Element = codeElement ?? {
108+
type: 'element',
109+
tagName: 'code',
110+
properties: { className: ['hljs', `language-${language}`] },
111+
children: [{ type: 'text', value: source }]
112+
};
113+
return {
114+
type: 'element',
115+
tagName: 'div',
116+
properties: { className: [DIAGRAM_SOURCE_CLASS, CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
117+
children: [
118+
{
119+
type: 'element',
120+
tagName: 'pre',
121+
properties: {},
122+
children: [code]
123+
}
124+
]
125+
};
126+
}
127+
75128
/**
76129
* Creates a block header with language label and action buttons.
77130
*/
@@ -116,14 +169,17 @@ export function createScrollContainer(preElement: Element, scrollContainerClass:
116169
}
117170

118171
/**
119-
* Creates a wrapper element with header and scroll container.
172+
* Creates a wrapper element with header and scroll container. Extra children
173+
* append after the scroll container, which lets a block carry a source view
174+
* alongside its rendered output.
120175
*/
121176
export function createWrapper(
122177
header: Element,
123178
preElement: Element,
124179
wrapperClass: string,
125180
scrollContainerClass: string,
126-
additionalAttributes?: Record<string, string>
181+
additionalAttributes?: Record<string, string>,
182+
extraChildren: Element[] = []
127183
): Element {
128184
return {
129185
type: 'element',
@@ -132,7 +188,7 @@ export function createWrapper(
132188
className: [wrapperClass, RELATIVE_CLASS],
133189
...additionalAttributes
134190
} as Element['properties'],
135-
children: [header, createScrollContainer(preElement, scrollContainerClass)]
191+
children: [header, createScrollContainer(preElement, scrollContainerClass), ...extraChildren]
136192
};
137193
}
138194

tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,17 @@ import {
1919
MERMAID_BLOCK_CLASS,
2020
MERMAID_LANGUAGE,
2121
MERMAID_SYNTAX_ATTR,
22-
MERMAID_ID_ATTR
22+
MERMAID_ID_ATTR,
23+
DIAGRAM_VIEW_MODE_ATTR,
24+
DIAGRAM_VIEW_RENDERED
2325
} from '$lib/constants';
26+
import type { DiagramPreData } from './pre-transform';
2427
import {
2528
createBlockHeader,
2629
createCopyButton,
2730
createPreviewButton,
31+
createToggleSourceButton,
32+
createSourceView,
2833
createWrapper,
2934
generateBlockId
3035
} from './code-block-utils';
@@ -75,16 +80,23 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
7580

7681
const actions = [
7782
createCopyButton(mermaidId, MERMAID_ID_ATTR, 'Copy mermaid syntax'),
83+
createToggleSourceButton(mermaidId, MERMAID_ID_ATTR, 'Toggle mermaid source'),
7884
createPreviewButton(mermaidId, MERMAID_ID_ATTR, 'Preview diagram')
7985
];
8086

8187
const header = createBlockHeader(MERMAID_LANGUAGE, mermaidId, MERMAID_ID_ATTR, actions);
88+
const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode;
89+
const sourceView = createSourceView(preservedCode, diagramText, MERMAID_LANGUAGE);
8290
const wrapper = createWrapper(
8391
header,
8492
node,
8593
MERMAID_WRAPPER_CLASS,
8694
MERMAID_SCROLL_CONTAINER_CLASS,
87-
{ [MERMAID_ID_ATTR]: mermaidId }
95+
{
96+
[MERMAID_ID_ATTR]: mermaidId,
97+
[DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED
98+
},
99+
[sourceView]
88100
);
89101

90102
// Replace pre with wrapper in parent

tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,17 @@ import {
1818
SVG_BLOCK_CLASS,
1919
SVG_LANGUAGE,
2020
SVG_SOURCE_ATTR,
21-
SVG_ID_ATTR
21+
SVG_ID_ATTR,
22+
DIAGRAM_VIEW_MODE_ATTR,
23+
DIAGRAM_VIEW_RENDERED
2224
} from '$lib/constants';
25+
import type { DiagramPreData } from './pre-transform';
2326
import {
2427
createBlockHeader,
2528
createCopyButton,
2629
createPreviewButton,
30+
createToggleSourceButton,
31+
createSourceView,
2732
createWrapper,
2833
generateBlockId
2934
} from './code-block-utils';
@@ -65,13 +70,24 @@ export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
6570

6671
const actions = [
6772
createCopyButton(svgId, SVG_ID_ATTR, 'Copy svg source'),
73+
createToggleSourceButton(svgId, SVG_ID_ATTR, 'Toggle svg source'),
6874
createPreviewButton(svgId, SVG_ID_ATTR, 'Preview svg')
6975
];
7076

7177
const header = createBlockHeader(SVG_LANGUAGE, svgId, SVG_ID_ATTR, actions);
72-
const wrapper = createWrapper(header, node, SVG_WRAPPER_CLASS, SVG_SCROLL_CONTAINER_CLASS, {
73-
[SVG_ID_ATTR]: svgId
74-
});
78+
const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode;
79+
const sourceView = createSourceView(preservedCode, svgSource, SVG_LANGUAGE);
80+
const wrapper = createWrapper(
81+
header,
82+
node,
83+
SVG_WRAPPER_CLASS,
84+
SVG_SCROLL_CONTAINER_CLASS,
85+
{
86+
[SVG_ID_ATTR]: svgId,
87+
[DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED
88+
},
89+
[sourceView]
90+
);
7591

7692
// Replace pre with wrapper in parent
7793
(parent.children as ElementContent[])[index] = wrapper;

0 commit comments

Comments
 (0)