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}`);
}
.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;
}
.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;
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;
}
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;
top: 0;
left: 0;
right: 0;
+ z-index: 2;
}
.markdown-content :global(.mermaid-block-wrapper .code-block-actions),
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;
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 {
}
/**
- * 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',
className: [className],
[idAttribute]: id,
title,
- type: 'button'
+ type: 'button',
+ ...extraProperties
},
children: [createIconElement(iconSvg)]
};
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.
*/
}
/**
- * 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',
className: [wrapperClass, RELATIVE_CLASS],
...additionalAttributes
} as Element['properties'],
- children: [header, createScrollContainer(preElement, scrollContainerClass)]
+ children: [header, createScrollContainer(preElement, scrollContainerClass), ...extraChildren]
};
}
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';
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
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';
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;
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).
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;
--- /dev/null
+// 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';
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>`;
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';