]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: add source toggle to mermaid and svg blocks (#24652)
authorPascal <redacted>
Tue, 16 Jun 2026 12:14:22 +0000 (14:14 +0200)
committerGitHub <redacted>
Tue, 16 Jun 2026 12:14:22 +0000 (14:14 +0200)
* 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

tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte
tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/code-block-utils.ts
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-mermaid-blocks.ts
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/enhance-svg-blocks.ts
tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/pre-transform.ts
tools/ui/src/lib/constants/diagram-blocks.ts [new file with mode: 0644]
tools/ui/src/lib/constants/icons.ts
tools/ui/src/lib/constants/index.ts

index 7139d2e639da9bb4720637767ef5d9ed68f7aed5..8ac7f94483a207208be5fa890e7a927557a9032b 100644 (file)
@@ -41,6 +41,7 @@
                DATA_ERROR_HANDLED_ATTR,
                BOOL_TRUE_STRING,
                SETTINGS_KEYS,
+               CODE_BLOCK_HEADER_CLASS,
                MERMAID_WRAPPER_CLASS,
                MERMAID_BLOCK_CLASS,
                MERMAID_LANGUAGE,
                SVG_TAG_PREFIX,
                SVG_SOURCE_ATTR,
                SVG_RENDERED_ATTR,
-               SVG_INLINE_SHADOW_STYLE
+               SVG_INLINE_SHADOW_STYLE,
+               TOGGLE_SOURCE_BTN_CLASS,
+               DIAGRAM_VIEW_MODE_ATTR,
+               DIAGRAM_VIEW_RENDERED,
+               DIAGRAM_VIEW_SOURCE
        } from '$lib/constants';
        import { ColorMode, UrlProtocol } from '$lib/enums';
        import { FileTypeText } from '$lib/enums/files.enums';
        async function handleMermaidClick(event: MouseEvent) {
                const target = event.target as HTMLElement;
 
+               // Toggle a diagram block between its rendered view and its source view.
+               // Shared by mermaid and svg, css drives the visibility from the wrapper mode.
+               const toggleBtn = target.closest(`.${TOGGLE_SOURCE_BTN_CLASS}`);
+               if (toggleBtn) {
+                       event.preventDefault();
+                       event.stopPropagation();
+
+                       const wrapper = toggleBtn.closest(`.${MERMAID_WRAPPER_CLASS}, .${SVG_WRAPPER_CLASS}`);
+                       if (!wrapper) return;
+
+                       const isSource = wrapper.getAttribute(DIAGRAM_VIEW_MODE_ATTR) === DIAGRAM_VIEW_SOURCE;
+                       const next = isSource ? DIAGRAM_VIEW_RENDERED : DIAGRAM_VIEW_SOURCE;
+                       wrapper.setAttribute(DIAGRAM_VIEW_MODE_ATTR, next);
+                       toggleBtn.setAttribute('aria-pressed', String(!isSource));
+                       return;
+               }
+
                // Check if clicking on copy or preview button in mermaid block
                const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
                const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
                        }
                }
 
+               // A click on the header chrome targets the action buttons, never the
+               // diagram. Guard so a header click can not fall through to the click to
+               // zoom branches below, whatever the scroll position or stacking.
+               if (target.closest(`.${CODE_BLOCK_HEADER_CLASS}`)) return;
+
                // Open preview when clicking the svg block itself. A final block carries its
                // source, a streaming block does not and is mirrored live into the dialog.
                const svgEl = target.closest(`.${SVG_BLOCK_CLASS}`);
index 072db383d860c73734ea559e95992a9828582721..b0e04ca6209a2af72f279588823677479d39a54b 100644 (file)
@@ -300,7 +300,8 @@ div.markdown-user-content :global(.table-wrapper) {
 }
 
 .markdown-content :global(.copy-code-btn),
-.markdown-content :global(.preview-code-btn) {
+.markdown-content :global(.preview-code-btn),
+.markdown-content :global(.toggle-source-btn) {
        display: flex;
        align-items: center;
        justify-content: center;
@@ -312,15 +313,22 @@ div.markdown-user-content :global(.table-wrapper) {
 }
 
 .markdown-content :global(.copy-code-btn:hover),
-.markdown-content :global(.preview-code-btn:hover) {
+.markdown-content :global(.preview-code-btn:hover),
+.markdown-content :global(.toggle-source-btn:hover) {
        transform: scale(1.05);
 }
 
 .markdown-content :global(.copy-code-btn:active),
-.markdown-content :global(.preview-code-btn:active) {
+.markdown-content :global(.preview-code-btn:active),
+.markdown-content :global(.toggle-source-btn:active) {
        transform: scale(0.95);
 }
 
+/* Pressed state marks the source view as active */
+.markdown-content :global(.toggle-source-btn[aria-pressed='true']) {
+       color: var(--primary);
+}
+
 .markdown-content :global(.code-block-wrapper pre) {
        background: transparent;
        margin: 0;
@@ -629,8 +637,8 @@ div.markdown-user-content :global(.table-wrapper) {
        overflow-y: auto;
        overflow-x: auto;
        display: flex;
-       align-items: center;
-       justify-content: center;
+       align-items: safe center;
+       justify-content: safe center;
        padding: 3rem 1rem 1rem;
 }
 
@@ -645,7 +653,9 @@ div.markdown-user-content :global(.table-wrapper) {
        overflow-y: visible;
 }
 
-/* Diagram block uses same header styling as code blocks */
+/* Diagram block uses same header styling as code blocks. The header floats over
+   scrollable diagram content and stays transparent, so the overflow shows up to
+   the box edge. It keeps a z-index so it stays the click target above content. */
 .markdown-content :global(.mermaid-block-wrapper .code-block-header),
 .markdown-content :global(.svg-block-wrapper .code-block-header) {
        display: flex;
@@ -657,6 +667,7 @@ div.markdown-user-content :global(.table-wrapper) {
        top: 0;
        left: 0;
        right: 0;
+       z-index: 2;
 }
 
 .markdown-content :global(.mermaid-block-wrapper .code-block-actions),
@@ -683,6 +694,31 @@ div.markdown-user-content :global(.table-wrapper) {
        padding: 3rem 1rem;
 }
 
+/* Source view stays hidden while the block renders, css swaps the two views
+   from the wrapper mode so the click handler only flips one attribute. The view
+   reuses the code block scroll container, so it matches the app code blocks. */
+.markdown-content :global(.diagram-source) {
+       display: none;
+       text-align: left;
+}
+
+.markdown-content :global(.diagram-source pre) {
+       background: transparent;
+       margin: 0;
+       border-radius: 0;
+       border: none;
+       font-size: 0.875rem;
+}
+
+.markdown-content :global([data-view-mode='source'] .mermaid-scroll-container),
+.markdown-content :global([data-view-mode='source'] .svg-scroll-container) {
+       display: none;
+}
+
+.markdown-content :global([data-view-mode='source'] .diagram-source) {
+       display: block;
+}
+
 /* Streaming mermaid block - empty preview box */
 .mermaid-streaming-block {
        min-height: 300px;
index 73231546490648c38be70a8e2d087b9edc4513fb..f1dd867e817bd882527e7863f1488f62c673a66a 100644 (file)
@@ -7,12 +7,16 @@ import type { Element, ElementContent } from 'hast';
 import {
        CODE_BLOCK_HEADER_CLASS,
        CODE_BLOCK_ACTIONS_CLASS,
+       CODE_BLOCK_SCROLL_CONTAINER_CLASS,
        CODE_LANGUAGE_CLASS,
        COPY_CODE_BTN_CLASS,
        PREVIEW_CODE_BTN_CLASS,
+       TOGGLE_SOURCE_BTN_CLASS,
+       DIAGRAM_SOURCE_CLASS,
        RELATIVE_CLASS,
        COPY_ICON_SVG,
-       PREVIEW_ICON_SVG
+       PREVIEW_ICON_SVG,
+       CODE_ICON_SVG
 } from '$lib/constants';
 
 export interface BlockIdGenerator {
@@ -32,14 +36,16 @@ export function createIconElement(svg: string): Element {
 }
 
 /**
- * Creates a button element with icon.
+ * Creates a button element with icon. Extra properties merge onto the button,
+ * which lets a stateful button carry attributes like aria-pressed.
  */
 export function createButton(
        className: string,
        title: string,
        iconSvg: string,
        id: string,
-       idAttribute: string
+       idAttribute: string,
+       extraProperties: Record<string, string> = {}
 ): Element {
        return {
                type: 'element',
@@ -48,7 +54,8 @@ export function createButton(
                        className: [className],
                        [idAttribute]: id,
                        title,
-                       type: 'button'
+                       type: 'button',
+                       ...extraProperties
                },
                children: [createIconElement(iconSvg)]
        };
@@ -72,6 +79,52 @@ export function createPreviewButton(
        return createButton(PREVIEW_CODE_BTN_CLASS, title, PREVIEW_ICON_SVG, id, idAttribute);
 }
 
+/**
+ * Creates a button that toggles a diagram block between its rendered view and
+ * its source view. aria-pressed starts false, the rendered view is the default.
+ */
+export function createToggleSourceButton(
+       id: string,
+       idAttribute: string,
+       title: string = 'Toggle source'
+): Element {
+       return createButton(TOGGLE_SOURCE_BTN_CLASS, title, CODE_ICON_SVG, id, idAttribute, {
+               'aria-pressed': 'false'
+       });
+}
+
+/**
+ * Creates a source view for a diagram block. It reuses the code block scroll
+ * container so it matches the app code blocks, and wraps the highlighted code
+ * element captured at transform time. A missing code element falls back to a
+ * plain code node built from the raw source.
+ */
+export function createSourceView(
+       codeElement: Element | undefined,
+       source: string,
+       language: string
+): Element {
+       const code: Element = codeElement ?? {
+               type: 'element',
+               tagName: 'code',
+               properties: { className: ['hljs', `language-${language}`] },
+               children: [{ type: 'text', value: source }]
+       };
+       return {
+               type: 'element',
+               tagName: 'div',
+               properties: { className: [DIAGRAM_SOURCE_CLASS, CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
+               children: [
+                       {
+                               type: 'element',
+                               tagName: 'pre',
+                               properties: {},
+                               children: [code]
+                       }
+               ]
+       };
+}
+
 /**
  * Creates a block header with language label and action buttons.
  */
@@ -116,14 +169,17 @@ export function createScrollContainer(preElement: Element, scrollContainerClass:
 }
 
 /**
- * Creates a wrapper element with header and scroll container.
+ * Creates a wrapper element with header and scroll container. Extra children
+ * append after the scroll container, which lets a block carry a source view
+ * alongside its rendered output.
  */
 export function createWrapper(
        header: Element,
        preElement: Element,
        wrapperClass: string,
        scrollContainerClass: string,
-       additionalAttributes?: Record<string, string>
+       additionalAttributes?: Record<string, string>,
+       extraChildren: Element[] = []
 ): Element {
        return {
                type: 'element',
@@ -132,7 +188,7 @@ export function createWrapper(
                        className: [wrapperClass, RELATIVE_CLASS],
                        ...additionalAttributes
                } as Element['properties'],
-               children: [header, createScrollContainer(preElement, scrollContainerClass)]
+               children: [header, createScrollContainer(preElement, scrollContainerClass), ...extraChildren]
        };
 }
 
index f9decf206390c21f11205f8658bbc5a2b6e3acad..4007c20a19d76ffca05c91cba4c2120723c0dcfe 100644 (file)
@@ -19,12 +19,17 @@ import {
        MERMAID_BLOCK_CLASS,
        MERMAID_LANGUAGE,
        MERMAID_SYNTAX_ATTR,
-       MERMAID_ID_ATTR
+       MERMAID_ID_ATTR,
+       DIAGRAM_VIEW_MODE_ATTR,
+       DIAGRAM_VIEW_RENDERED
 } from '$lib/constants';
+import type { DiagramPreData } from './pre-transform';
 import {
        createBlockHeader,
        createCopyButton,
        createPreviewButton,
+       createToggleSourceButton,
+       createSourceView,
        createWrapper,
        generateBlockId
 } from './code-block-utils';
@@ -75,16 +80,23 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
 
                        const actions = [
                                createCopyButton(mermaidId, MERMAID_ID_ATTR, 'Copy mermaid syntax'),
+                               createToggleSourceButton(mermaidId, MERMAID_ID_ATTR, 'Toggle mermaid source'),
                                createPreviewButton(mermaidId, MERMAID_ID_ATTR, 'Preview diagram')
                        ];
 
                        const header = createBlockHeader(MERMAID_LANGUAGE, mermaidId, MERMAID_ID_ATTR, actions);
+                       const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode;
+                       const sourceView = createSourceView(preservedCode, diagramText, MERMAID_LANGUAGE);
                        const wrapper = createWrapper(
                                header,
                                node,
                                MERMAID_WRAPPER_CLASS,
                                MERMAID_SCROLL_CONTAINER_CLASS,
-                               { [MERMAID_ID_ATTR]: mermaidId }
+                               {
+                                       [MERMAID_ID_ATTR]: mermaidId,
+                                       [DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED
+                               },
+                               [sourceView]
                        );
 
                        // Replace pre with wrapper in parent
index e5e7514ed516d8a45a2836689f56d3f7c0b7bb4d..55bcb6065fda13d0fd3fad23f905b28eace4b5e4 100644 (file)
@@ -18,12 +18,17 @@ import {
        SVG_BLOCK_CLASS,
        SVG_LANGUAGE,
        SVG_SOURCE_ATTR,
-       SVG_ID_ATTR
+       SVG_ID_ATTR,
+       DIAGRAM_VIEW_MODE_ATTR,
+       DIAGRAM_VIEW_RENDERED
 } from '$lib/constants';
+import type { DiagramPreData } from './pre-transform';
 import {
        createBlockHeader,
        createCopyButton,
        createPreviewButton,
+       createToggleSourceButton,
+       createSourceView,
        createWrapper,
        generateBlockId
 } from './code-block-utils';
@@ -65,13 +70,24 @@ export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
 
                        const actions = [
                                createCopyButton(svgId, SVG_ID_ATTR, 'Copy svg source'),
+                               createToggleSourceButton(svgId, SVG_ID_ATTR, 'Toggle svg source'),
                                createPreviewButton(svgId, SVG_ID_ATTR, 'Preview svg')
                        ];
 
                        const header = createBlockHeader(SVG_LANGUAGE, svgId, SVG_ID_ATTR, actions);
-                       const wrapper = createWrapper(header, node, SVG_WRAPPER_CLASS, SVG_SCROLL_CONTAINER_CLASS, {
-                               [SVG_ID_ATTR]: svgId
-                       });
+                       const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode;
+                       const sourceView = createSourceView(preservedCode, svgSource, SVG_LANGUAGE);
+                       const wrapper = createWrapper(
+                               header,
+                               node,
+                               SVG_WRAPPER_CLASS,
+                               SVG_SCROLL_CONTAINER_CLASS,
+                               {
+                                       [SVG_ID_ATTR]: svgId,
+                                       [DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED
+                               },
+                               [sourceView]
+                       );
 
                        // Replace pre with wrapper in parent
                        (parent.children as ElementContent[])[index] = wrapper;
index 06848eb26a234b6dc7708a19320fe47695ce6995..7aa967bb81daa98e29688b024abe780dcc43d26a 100644 (file)
@@ -2,6 +2,15 @@ import type { Plugin } from 'unified';
 import type { Root, Element, ElementContent, Text } from 'hast';
 import { visit } from 'unist-util-visit';
 
+/**
+ * Metadata a diagram pre carries on its unist data field. The source code holds
+ * the highlighted code element captured before the pre became a render target,
+ * which the enhancer reuses to build a matching source view.
+ */
+export interface DiagramPreData {
+       sourceCode: Element;
+}
+
 /**
  * Recursively extracts all text content from a HAST node.
  * Handles nested elements (e.g., span wrappers from syntax highlighting).
@@ -69,7 +78,10 @@ export function createPreTransform(
                                        properties: {
                                                className: [targetClass]
                                        },
-                                       children: [{ type: 'text', value: text } as Text]
+                                       children: [{ type: 'text', value: text } as Text],
+                                       // Keep the highlighted code element so the block can offer a source
+                                       // view that matches the app code blocks without re highlighting.
+                                       data: { sourceCode: codeElement } satisfies DiagramPreData
                                };
 
                                (parent.children as ElementContent[])[index] = pre;
diff --git a/tools/ui/src/lib/constants/diagram-blocks.ts b/tools/ui/src/lib/constants/diagram-blocks.ts
new file mode 100644 (file)
index 0000000..caeb6b5
--- /dev/null
@@ -0,0 +1,9 @@
+// Shared constants for diagram blocks (mermaid and svg) that toggle between a
+// rendered view and a source view. The wrapper carries the active mode, css
+// drives the visibility, the click handler only flips the attribute.
+
+export const DIAGRAM_VIEW_MODE_ATTR = 'data-view-mode';
+export const DIAGRAM_VIEW_RENDERED = 'rendered';
+export const DIAGRAM_VIEW_SOURCE = 'source';
+export const DIAGRAM_SOURCE_CLASS = 'diagram-source';
+export const TOGGLE_SOURCE_BTN_CLASS = 'toggle-source-btn';
index a9448c2a6d753eae35be71150baf44d72673972e..6ef02c4cb7cfd707f80ceddadce91d8d3b3eb623 100644 (file)
@@ -39,3 +39,5 @@ export const MODALITY_LABELS = {
 export const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
 
 export const PREVIEW_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye lucide-eye-icon"><path d="M2.062 12.345a1 1 0 0 1 0-.69C3.5 7.73 7.36 5 12 5s8.5 2.73 9.938 6.655a1 1 0 0 1 0 .69C20.5 16.27 16.64 19 12 19s-8.5-2.73-9.938-6.655"/><circle cx="12" cy="12" r="3"/></svg>`;
+
+export const CODE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code lucide-code-icon"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>`;
index 07e441112d01fc03154e7ce869b278ad50d33a6f..c51d84cdc2e32291a240321a14ec5dc26fc05445 100644 (file)
@@ -30,6 +30,7 @@ export * from './literal-html';
 export * from './markdown';
 export * from './mermaid-blocks';
 export * from './svg-blocks';
+export * from './diagram-blocks';
 export * from './max-bundle-size';
 export * from './mcp';
 export * from './mcp-form';