mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-06-27 23:50:20 -05:00
ui: Mermaid Diagrams in chat + interactive preview (#24032)
This commit is contained in:
parent
9e58d4d692
commit
ee4cf705bb
1024
tools/ui/package-lock.json
generated
1024
tools/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -80,6 +80,7 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"pdfjs-dist": "^5.4.54",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
|
||||
@ -141,6 +141,7 @@
|
||||
@apply bg-background text-foreground;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
overflow: hidden; /* Added due to Mermaid rendering somehow causing the double scrollbar */
|
||||
}
|
||||
|
||||
/* Global scrollbar styling - visible only on hover */
|
||||
|
||||
@ -16,10 +16,23 @@
|
||||
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
|
||||
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
|
||||
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
|
||||
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
|
||||
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
|
||||
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
|
||||
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
|
||||
import { remarkLiteralHtml } from './plugins/remark/literal-html';
|
||||
import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils';
|
||||
import {
|
||||
getHastNodeId,
|
||||
getMdastNodeHash,
|
||||
isAppendMode,
|
||||
getCodeInfoFromTarget
|
||||
} from './markdown-utils';
|
||||
import {
|
||||
preprocessLaTeX,
|
||||
getImageErrorFallbackHtml,
|
||||
copyCodeToClipboard,
|
||||
copyToClipboard
|
||||
} from '$lib/utils';
|
||||
import {
|
||||
IMAGE_NOT_ERROR_BOUND_SELECTOR,
|
||||
DATA_ERROR_BOUND_ATTR,
|
||||
@ -34,7 +47,12 @@
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { CodeBlockActions, DialogCodePreview } from '$lib/components/app';
|
||||
import {
|
||||
CodeBlockActions,
|
||||
DialogCodePreview,
|
||||
DialogMermaidPreview,
|
||||
ActionIconCopyToClipboard
|
||||
} from '$lib/components/app';
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
@ -62,6 +80,9 @@
|
||||
let previewDialogOpen = $state(false);
|
||||
let previewCode = $state('');
|
||||
let previewLanguage = $state('text');
|
||||
let mermaidPreviewOpen = $state(false);
|
||||
let mermaidPreviewSvgHtml = $state('');
|
||||
|
||||
let streamingCodeScrollContainer = $state<HTMLDivElement>();
|
||||
|
||||
// Auto-scroll controller for streaming code block content
|
||||
@ -102,7 +123,9 @@
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
.use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
|
||||
.use(rehypeResolveAttachmentImages, { attachments })
|
||||
.use(rehypeRtlSupport) // Add bidirectional text support
|
||||
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
|
||||
@ -157,73 +180,7 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts code information from a button click target within a code block.
|
||||
* @param target - The clicked button element
|
||||
* @returns Object with rawCode and language, or null if extraction fails
|
||||
*/
|
||||
function getCodeInfoFromTarget(target: HTMLElement) {
|
||||
const wrapper = target.closest('.code-block-wrapper');
|
||||
|
||||
if (!wrapper) {
|
||||
console.error('No wrapper found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
|
||||
|
||||
if (!codeElement) {
|
||||
console.error('No code element found in wrapper');
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawCode = codeElement.textContent ?? '';
|
||||
|
||||
const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
|
||||
const language = languageLabel?.textContent?.trim() || 'text';
|
||||
|
||||
return { rawCode, language };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique identifier for a HAST node based on its position.
|
||||
* Used for stable block identification during incremental rendering.
|
||||
* @param node - The HAST root content node
|
||||
* @param indexFallback - Fallback index if position is unavailable
|
||||
* @returns Unique string identifier for the node
|
||||
*/
|
||||
function getHastNodeId(node: HastRootContent, indexFallback: number): string {
|
||||
const position = node.position;
|
||||
|
||||
if (position?.start?.offset != null && position?.end?.offset != null) {
|
||||
return `hast-${position.start.offset}-${position.end.offset}`;
|
||||
}
|
||||
|
||||
return `${node.type}-${indexFallback}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a hash for MDAST node based on its position.
|
||||
* Used for cache lookup during incremental rendering.
|
||||
*/
|
||||
function getMdastNodeHash(node: unknown, index: number): string {
|
||||
const n = node as {
|
||||
type?: string;
|
||||
position?: { start?: { offset?: number }; end?: { offset?: number } };
|
||||
};
|
||||
|
||||
if (n.position?.start?.offset != null && n.position?.end?.offset != null) {
|
||||
return `${n.type}-${n.position.start.offset}-${n.position.end.offset}`;
|
||||
}
|
||||
|
||||
return `${n.type}-idx${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're in append-only mode (streaming).
|
||||
*/
|
||||
function isAppendMode(newContent: string): boolean {
|
||||
return previousContent.length > 0 && newContent.startsWith(previousContent);
|
||||
}
|
||||
* Transforms a single MDAST node to HTML string with caching.
|
||||
|
||||
/**
|
||||
* Transforms a single MDAST node to HTML string with caching.
|
||||
@ -359,7 +316,7 @@
|
||||
const nextBlocks: MarkdownBlock[] = [];
|
||||
|
||||
// Check if we're in append mode for cache reuse
|
||||
const appendMode = isAppendMode(prefixMarkdown);
|
||||
const appendMode = isAppendMode(prefixMarkdown, previousContent);
|
||||
const previousBlockCount = appendMode ? renderedBlocks.length : 0;
|
||||
|
||||
// All prefix blocks are now stable since code block is separate
|
||||
@ -411,7 +368,7 @@
|
||||
const nextBlocks: MarkdownBlock[] = [];
|
||||
|
||||
// Check if we're in append mode for cache reuse
|
||||
const appendMode = isAppendMode(markdown);
|
||||
const appendMode = isAppendMode(markdown, previousContent);
|
||||
const previousBlockCount = appendMode ? renderedBlocks.length : 0;
|
||||
|
||||
for (let index = 0; index < stableCount; index++) {
|
||||
@ -496,6 +453,118 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the mermaid diagram in a full-screen preview dialog with zoom/pan support.
|
||||
* Also handles copy and preview button clicks for mermaid blocks.
|
||||
* Uses event delegation: a single handler on the container.
|
||||
*/
|
||||
async function handleMermaidClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Check if clicking on copy or preview button in mermaid block
|
||||
const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
|
||||
const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
|
||||
|
||||
if (copyBtn || previewBtn) {
|
||||
const wrapper = target.closest('.mermaid-block-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
const preElement = wrapper.querySelector<HTMLElement>('pre.mermaid[data-mermaid-syntax]');
|
||||
if (!preElement) return;
|
||||
|
||||
const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
|
||||
|
||||
if (copyBtn) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await copyToClipboard(mermaidSyntax);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy mermaid syntax:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewBtn) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const svg = preElement.querySelector('svg');
|
||||
if (!svg) return;
|
||||
mermaidPreviewSvgHtml = svg.outerHTML;
|
||||
mermaidPreviewOpen = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, open preview when clicking on the mermaid diagram itself
|
||||
const mermaidEl = target.closest('.mermaid');
|
||||
if (!mermaidEl) return;
|
||||
|
||||
const svg = mermaidEl.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
mermaidPreviewSvgHtml = svg.outerHTML;
|
||||
mermaidPreviewOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mermaid preview dialog open state changes.
|
||||
* Cleans up SVG content when dialog is closed.
|
||||
*/
|
||||
function handleMermaidPreviewOpenChange(open: boolean) {
|
||||
mermaidPreviewOpen = open;
|
||||
if (!open) {
|
||||
mermaidPreviewSvgHtml = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders mermaid diagrams that haven't been rendered yet.
|
||||
* Called after each markdown content update.
|
||||
* Marks nodes immediately to prevent duplicate renders during streaming.
|
||||
* Reads mode.current before await to ensure reactive tracking.
|
||||
*/
|
||||
async function renderMermaidDiagrams() {
|
||||
if (!containerRef) return;
|
||||
|
||||
const nodes = containerRef.querySelectorAll('pre.mermaid:not([data-mermaid-rendered])');
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
// Mark nodes immediately to prevent duplicate renders if called again during streaming.
|
||||
// This avoids needing a guard that would block node discovery.
|
||||
nodes.forEach((node) => node.setAttribute('data-mermaid-rendered', 'true'));
|
||||
|
||||
// Read mode before await so Svelte tracks it reactively.
|
||||
const isDark = mode.current === ColorMode.DARK;
|
||||
|
||||
// lazy load the mermaid dependecy only when needed to reduce bundle size.
|
||||
const { default: mermaid } = await import('mermaid');
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDark ? 'dark' : 'default',
|
||||
securityLevel: 'strict',
|
||||
flowchart: {
|
||||
useMaxWidth: false,
|
||||
htmlLabels: true
|
||||
},
|
||||
sequence: {
|
||||
useMaxWidth: false
|
||||
},
|
||||
gantt: {
|
||||
useMaxWidth: false
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await mermaid.run({
|
||||
nodes: Array.from(nodes) as unknown as NodeListOf<HTMLElement>
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to render mermaid diagram:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles image load errors by replacing the image with a fallback UI.
|
||||
* Shows a placeholder with a link to open the image in a new tab.
|
||||
@ -577,6 +646,7 @@
|
||||
if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) {
|
||||
setupCodeBlockActions();
|
||||
setupImageErrorHandlers();
|
||||
renderMermaidDiagrams();
|
||||
}
|
||||
});
|
||||
|
||||
@ -596,15 +666,17 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={containerRef}
|
||||
class="{className}{config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
|
||||
onclick={handleMermaidClick}
|
||||
class="markdown-content {className}{config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS]
|
||||
? ' full-height-code-blocks'
|
||||
: ''}"
|
||||
>
|
||||
{#each renderedBlocks as block (block.id)}
|
||||
<div class="markdown-block" data-block-id={block.id} use:fadeInView={{ skipIfVisible: true }}>
|
||||
<!-- eslint-disable-next-line no-at-html-tags -->
|
||||
{@html block.html}
|
||||
</div>
|
||||
{/each}
|
||||
@ -617,34 +689,53 @@
|
||||
{/if}
|
||||
|
||||
{#if incompleteCodeBlock}
|
||||
<div class="code-block-wrapper streaming-code-block relative">
|
||||
<div class="code-block-header">
|
||||
<span class="code-language">{incompleteCodeBlock.language || 'text'}</span>
|
||||
<CodeBlockActions
|
||||
code={incompleteCodeBlock.code}
|
||||
language={incompleteCodeBlock.language || 'text'}
|
||||
disabled
|
||||
onPreview={(code, lang) => {
|
||||
previewCode = code;
|
||||
previewLanguage = lang;
|
||||
previewDialogOpen = true;
|
||||
}}
|
||||
/>
|
||||
{#if incompleteCodeBlock.language === 'mermaid'}
|
||||
<div class="mermaid-block-wrapper streaming-mermaid-block">
|
||||
<div class="code-block-header">
|
||||
<span class="code-language">mermaid</span>
|
||||
<div class="code-block-actions">
|
||||
<ActionIconCopyToClipboard
|
||||
text={incompleteCodeBlock.code}
|
||||
canCopy={false}
|
||||
ariaLabel="Diagram incomplete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mermaid-loading-placeholder">
|
||||
<span class="mermaid-loading-text">Generating diagram...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
bind:this={streamingCodeScrollContainer}
|
||||
class="streaming-code-scroll-container"
|
||||
onscroll={() => streamingAutoScroll.handleScroll()}
|
||||
>
|
||||
<pre class="streaming-code-pre"><code
|
||||
class="hljs language-{incompleteCodeBlock.language || 'text'}"
|
||||
>{@html highlightCode(
|
||||
incompleteCodeBlock.code,
|
||||
incompleteCodeBlock.language || 'text'
|
||||
)}</code
|
||||
></pre>
|
||||
{:else}
|
||||
<div class="code-block-wrapper streaming-code-block relative">
|
||||
<div class="code-block-header">
|
||||
<span class="code-language">{incompleteCodeBlock.language || 'text'}</span>
|
||||
<CodeBlockActions
|
||||
code={incompleteCodeBlock.code}
|
||||
language={incompleteCodeBlock.language || 'text'}
|
||||
disabled
|
||||
onPreview={(code, lang) => {
|
||||
previewCode = code;
|
||||
previewLanguage = lang;
|
||||
previewDialogOpen = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
bind:this={streamingCodeScrollContainer}
|
||||
class="streaming-code-scroll-container"
|
||||
onscroll={() => streamingAutoScroll.handleScroll()}
|
||||
>
|
||||
<pre class="streaming-code-pre"><code
|
||||
class="hljs language-{incompleteCodeBlock.language || 'text'}"
|
||||
>{@html highlightCode(
|
||||
incompleteCodeBlock.code,
|
||||
incompleteCodeBlock.language || 'text'
|
||||
)}</code
|
||||
></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@ -655,566 +746,12 @@
|
||||
onOpenChange={handlePreviewDialogOpenChange}
|
||||
/>
|
||||
|
||||
<DialogMermaidPreview
|
||||
open={mermaidPreviewOpen}
|
||||
svgHtml={mermaidPreviewSvgHtml}
|
||||
onOpenChange={handleMermaidPreviewOpenChange}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.markdown-block--unstable {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Streaming code block uses .code-block-wrapper styles */
|
||||
.streaming-code-block .streaming-code-pre {
|
||||
background: transparent;
|
||||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
overflow-x: visible;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Base typography styles */
|
||||
div :global(p) {
|
||||
margin-block: 1rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
div :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Headers with consistent spacing */
|
||||
div :global(h1) {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
}
|
||||
|
||||
div :global(h2) {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 1.25rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
div :global(h3) {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 1.5rem 0 0.5rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
div :global(h4) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
div :global(h5) {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
div :global(h6) {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Text formatting */
|
||||
div :global(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
div :global(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
div :global(del) {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
div :global(code:not(pre code)) {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
div :global(pre) {
|
||||
display: inline;
|
||||
margin: 0 !important;
|
||||
overflow: hidden !important;
|
||||
background: var(--muted);
|
||||
overflow-x: auto;
|
||||
border-radius: 1rem;
|
||||
border: none;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
div :global(pre code) {
|
||||
padding: 0 !important;
|
||||
display: inline !important;
|
||||
}
|
||||
|
||||
div :global(code) {
|
||||
background: transparent;
|
||||
color: var(--code-foreground);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
div :global(a) {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: color 0.2s ease;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
div :global(a:hover) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
div :global(ul) {
|
||||
list-style-type: disc;
|
||||
margin-inline-start: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
div :global(ol) {
|
||||
list-style-type: decimal;
|
||||
margin-inline-start: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
div :global(li) {
|
||||
margin-bottom: 0.25rem;
|
||||
padding-inline-start: 0.5rem;
|
||||
}
|
||||
|
||||
div :global(li::marker) {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Nested lists */
|
||||
div :global(ul ul) {
|
||||
list-style-type: circle;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
div :global(ol ol) {
|
||||
list-style-type: lower-alpha;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Task lists */
|
||||
div :global(.task-list-item) {
|
||||
list-style: none;
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
div :global(.task-list-item-checkbox) {
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
div :global(blockquote) {
|
||||
border-left: 4px solid var(--border);
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: var(--muted-foreground);
|
||||
background: var(--muted);
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
div :global(table) {
|
||||
width: 100%;
|
||||
margin: 1.5rem 0;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div :global(th) {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
div :global(td) {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
div :global(tr:nth-child(even)) {
|
||||
background: hsl(var(--muted) / 0.1);
|
||||
}
|
||||
|
||||
/* User message markdown should keep table borders visible on light primary backgrounds */
|
||||
div.markdown-user-content :global(table),
|
||||
div.markdown-user-content :global(th),
|
||||
div.markdown-user-content :global(td),
|
||||
div.markdown-user-content :global(.table-wrapper) {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
div :global(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
div :global(img) {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgb(0 0 0 / 0.1),
|
||||
0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
margin: 1.5rem 0;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
|
||||
div :global(.code-block-wrapper) {
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
|
||||
background: var(--code-background);
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
min-height: var(--min-message-height);
|
||||
max-height: var(--max-message-height);
|
||||
}
|
||||
|
||||
:global(.dark) div :global(.code-block-wrapper) {
|
||||
border-color: color-mix(in oklch, var(--border) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Scroll container for code blocks (both streaming and completed) */
|
||||
div :global(.code-block-scroll-container),
|
||||
.streaming-code-scroll-container {
|
||||
min-height: var(--min-message-height);
|
||||
max-height: var(--max-message-height);
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 3rem 1rem 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.full-height-code-blocks :global(.code-block-wrapper) {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.full-height-code-blocks :global(.code-block-scroll-container),
|
||||
.full-height-code-blocks .streaming-code-scroll-container {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
div :global(.code-block-header) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
div :global(.code-language) {
|
||||
color: var(--color-foreground);
|
||||
font-weight: 500;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
|
||||
'Liberation Mono', Menlo, monospace;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
div :global(.code-block-actions) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
div :global(.copy-code-btn),
|
||||
div :global(.preview-code-btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--code-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
div :global(.copy-code-btn:hover),
|
||||
div :global(.preview-code-btn:hover) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
div :global(.copy-code-btn:active),
|
||||
div :global(.preview-code-btn:active) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
div :global(.code-block-wrapper pre) {
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Mentions and hashtags */
|
||||
div :global(.mention) {
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
div :global(.mention:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
div :global(.hashtag) {
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
div :global(.hashtag:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Advanced table enhancements */
|
||||
div :global(table) {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
div :global(table:hover) {
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
||||
0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
div :global(th:hover),
|
||||
div :global(td:hover) {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Disable hover effects when rendering user messages */
|
||||
.markdown-user-content :global(a),
|
||||
.markdown-user-content :global(a:hover) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-user-content :global(table:hover) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.markdown-user-content :global(th:hover),
|
||||
.markdown-user-content :global(td:hover) {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
/* Enhanced blockquotes */
|
||||
div :global(blockquote) {
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div :global(blockquote:hover) {
|
||||
border-left-width: 6px;
|
||||
background: var(--muted);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
div :global(blockquote::before) {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
left: 0.5rem;
|
||||
font-size: 3rem;
|
||||
color: var(--muted-foreground);
|
||||
font-family: serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Enhanced images */
|
||||
div :global(img) {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div :global(img:hover) {
|
||||
transform: scale(1.02);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Image zoom overlay */
|
||||
div :global(.image-zoom-overlay) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div :global(.image-zoom-overlay img) {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
/* Enhanced horizontal rules */
|
||||
div :global(hr) {
|
||||
border: none;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, var(--border), transparent);
|
||||
margin: 2rem 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div :global(hr::after) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--border);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Scrollable tables */
|
||||
div :global(.table-wrapper) {
|
||||
overflow-x: auto;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
div :global(.table-wrapper table) {
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
div :global(h1) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
div :global(h2) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
div :global(h3) {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
div :global(table) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
div :global(th),
|
||||
div :global(td) {
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
div :global(.table-wrapper) {
|
||||
margin: 0.5rem -1rem;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
div :global(blockquote:hover) {
|
||||
background: var(--muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* Image load error fallback */
|
||||
div :global(.image-load-error) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 1.5rem 0;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--muted);
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
div :global(.image-error-content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div :global(.image-error-content svg) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
div :global(.image-error-text) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
div :global(.image-error-link) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
div :global(.image-error-link:hover) {
|
||||
background: var(--muted);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
@import './markdown-content.css';
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,685 @@
|
||||
.markdown-block--unstable {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Streaming code block uses .code-block-wrapper styles */
|
||||
.streaming-code-block .streaming-code-pre {
|
||||
background: transparent;
|
||||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
overflow-x: visible;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Base typography styles */
|
||||
.markdown-content :global(p) {
|
||||
margin-block: 1rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.markdown-content :global(:is(h1, h2, h3, h4, h5, h6):first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Headers with consistent spacing */
|
||||
.markdown-content :global(h1) {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(h2) {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 1.25rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(h3) {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 1.5rem 0 0.5rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markdown-content :global(h4) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(h5) {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(h6) {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Text formatting */
|
||||
.markdown-content :global(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content :global(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-content :global(del) {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.markdown-content :global(code:not(pre code)) {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(pre) {
|
||||
display: inline;
|
||||
margin: 0 !important;
|
||||
overflow: hidden !important;
|
||||
background: var(--muted);
|
||||
overflow-x: auto;
|
||||
border-radius: 1rem;
|
||||
border: none;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.markdown-content :global(pre code) {
|
||||
padding: 0 !important;
|
||||
display: inline !important;
|
||||
}
|
||||
|
||||
.markdown-content :global(code) {
|
||||
background: transparent;
|
||||
color: var(--code-foreground);
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.markdown-content :global(a) {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: color 0.2s ease;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.markdown-content :global(a:hover) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.markdown-content :global(ul) {
|
||||
list-style-type: disc;
|
||||
margin-inline-start: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(ol) {
|
||||
list-style-type: decimal;
|
||||
margin-inline-start: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(li) {
|
||||
margin-bottom: 0.25rem;
|
||||
padding-inline-start: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(li::marker) {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Nested lists */
|
||||
.markdown-content :global(ul ul) {
|
||||
list-style-type: circle;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(ol ol) {
|
||||
list-style-type: lower-alpha;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Task lists */
|
||||
.markdown-content :global(.task-list-item) {
|
||||
list-style: none;
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(.task-list-item-checkbox) {
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
.markdown-content :global(blockquote) {
|
||||
border-left: 4px solid var(--border);
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: var(--muted-foreground);
|
||||
background: var(--muted);
|
||||
border-radius: 0 0.375rem 0.375rem 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.markdown-content :global(table) {
|
||||
width: 100%;
|
||||
margin: 1.5rem 0;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-content :global(th) {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content :global(td) {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(tr:nth-child(even)) {
|
||||
background: hsl(var(--muted) / 0.1);
|
||||
}
|
||||
|
||||
/* User message markdown should keep table borders visible on light primary backgrounds */
|
||||
div.markdown-user-content :global(table),
|
||||
div.markdown-user-content :global(th),
|
||||
div.markdown-user-content :global(td),
|
||||
div.markdown-user-content :global(.table-wrapper) {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
.markdown-content :global(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.markdown-content :global(img) {
|
||||
border-radius: 0.5rem;
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgb(0 0 0 / 0.1),
|
||||
0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
margin: 1.5rem 0;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
|
||||
.markdown-content :global(.code-block-wrapper) {
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
|
||||
background: var(--code-background);
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
min-height: var(--min-message-height);
|
||||
max-height: var(--max-message-height);
|
||||
}
|
||||
|
||||
.markdown-content:global(.dark) :global(.code-block-wrapper) {
|
||||
border-color: color-mix(in oklch, var(--border) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Scroll container for code blocks (both streaming and completed) */
|
||||
.markdown-content :global(.code-block-scroll-container),
|
||||
.streaming-code-scroll-container {
|
||||
min-height: var(--min-message-height);
|
||||
max-height: var(--max-message-height);
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 3rem 1rem 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.full-height-code-blocks :global(.code-block-wrapper) {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.full-height-code-blocks :global(.code-block-scroll-container),
|
||||
.full-height-code-blocks .streaming-code-scroll-container {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.markdown-content :global(.code-block-header) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(.code-language) {
|
||||
color: var(--color-foreground);
|
||||
font-weight: 500;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
|
||||
'Liberation Mono', Menlo, monospace;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.markdown-content :global(.code-block-actions) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(.copy-code-btn),
|
||||
.markdown-content :global(.preview-code-btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--code-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.markdown-content :global(.copy-code-btn:hover),
|
||||
.markdown-content :global(.preview-code-btn:hover) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.markdown-content :global(.copy-code-btn:active),
|
||||
.markdown-content :global(.preview-code-btn:active) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.markdown-content :global(.code-block-wrapper pre) {
|
||||
background: transparent;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Mentions and hashtags */
|
||||
.markdown-content :global(.mention) {
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content :global(.mention:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content :global(.hashtag) {
|
||||
color: hsl(var(--primary));
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content :global(.hashtag:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Advanced table enhancements */
|
||||
.markdown-content :global(table) {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.markdown-content :global(table:hover) {
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
||||
0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.markdown-content :global(th:hover),
|
||||
.markdown-content :global(td:hover) {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Disable hover effects when rendering user messages */
|
||||
.markdown-user-content :global(a),
|
||||
.markdown-user-content :global(a:hover) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-user-content :global(table:hover) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.markdown-user-content :global(th:hover),
|
||||
.markdown-user-content :global(td:hover) {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
/* Enhanced blockquotes */
|
||||
.markdown-content :global(blockquote) {
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-content :global(blockquote:hover) {
|
||||
border-left-width: 6px;
|
||||
background: var(--muted);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.markdown-content :global(blockquote::before) {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
left: 0.5rem;
|
||||
font-size: 3rem;
|
||||
color: var(--muted-foreground);
|
||||
font-family: serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Enhanced images */
|
||||
.markdown-content :global(img) {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown-content :global(img:hover) {
|
||||
transform: scale(1.02);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Image zoom overlay */
|
||||
.markdown-content :global(.image-zoom-overlay) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown-content :global(.image-zoom-overlay img) {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
/* Enhanced horizontal rules */
|
||||
.markdown-content :global(hr) {
|
||||
border: none;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, var(--border), transparent);
|
||||
margin: 2rem 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-content :global(hr::after) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--border);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Scrollable tables */
|
||||
.markdown-content :global(.table-wrapper) {
|
||||
overflow-x: auto;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdown-content :global(.table-wrapper table) {
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.markdown-content :global(h1) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(h2) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(h3) {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(table) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(th),
|
||||
.markdown-content :global(td) {
|
||||
padding: 0.375rem 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(.table-wrapper) {
|
||||
margin: 0.5rem -1rem;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.markdown-content :global(blockquote:hover) {
|
||||
background: var(--muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* Image load error fallback */
|
||||
.markdown-content :global(.image-load-error) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 1.5rem 0;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--muted);
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.markdown-content :global(.image-error-content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.markdown-content :global(.image-error-content svg) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.markdown-content :global(.image-error-text) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.markdown-content :global(.image-error-link) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.markdown-content :global(.image-error-link:hover) {
|
||||
background: var(--muted);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Mermaid diagrams */
|
||||
.markdown-content :global(pre.mermaid) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Hide mermaid code text until rendered - prevents flash */
|
||||
.markdown-content :global(pre.mermaid:not([data-mermaid-rendered])),
|
||||
.markdown-content :global(pre.mermaid[data-mermaid-rendered]:not(:has(svg))) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.markdown-content :global(pre.mermaid:hover) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.markdown-content :global(pre.mermaid svg) {
|
||||
max-width: 90%;
|
||||
margin: 0 auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
/* Mermaid block wrapper - matches code block styling */
|
||||
.markdown-content :global(.mermaid-block-wrapper) {
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
|
||||
background: var(--code-background);
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
position: relative;
|
||||
min-height: var(--min-message-height);
|
||||
max-height: var(--max-message-height);
|
||||
}
|
||||
|
||||
.markdown-content:global(.dark) :global(.mermaid-block-wrapper) {
|
||||
border-color: color-mix(in oklch, var(--border) 20%, transparent);
|
||||
}
|
||||
|
||||
.markdown-content :global(.mermaid-scroll-container) {
|
||||
min-height: 350px;
|
||||
max-height: var(--max-message-height);
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.full-height-code-blocks :global(.mermaid-block-wrapper) {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.full-height-code-blocks :global(.mermaid-scroll-container) {
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
/* Mermaid block uses same header styling as code blocks */
|
||||
.markdown-content :global(.mermaid-block-wrapper .code-block-header) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.markdown-content :global(.mermaid-block-wrapper .code-block-actions) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mermaid pre element - remove default margins */
|
||||
.markdown-content :global(.mermaid-block-wrapper pre.mermaid) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Mermaid SVG should be bigger */
|
||||
.markdown-content :global(.mermaid-block-wrapper pre.mermaid svg) {
|
||||
width: unset !important;
|
||||
height: auto;
|
||||
display: block;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
/* Streaming mermaid block - empty preview box */
|
||||
.mermaid-streaming-block {
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mermaid-loading-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.mermaid-loading-text {
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
}
|
||||
@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Event handler factories for markdown content components.
|
||||
* Uses dependency injection pattern to avoid direct component state access.
|
||||
*/
|
||||
|
||||
import { copyCodeToClipboard, copyToClipboard } from '$lib/utils';
|
||||
|
||||
export interface PreviewState {
|
||||
previewDialogOpen: boolean;
|
||||
previewCode: string;
|
||||
previewLanguage: string;
|
||||
setPreviewDialogOpen: (open: boolean) => void;
|
||||
setPreviewCode: (code: string) => void;
|
||||
setPreviewLanguage: (lang: string) => void;
|
||||
}
|
||||
|
||||
export interface MermaidPreviewState {
|
||||
mermaidPreviewOpen: boolean;
|
||||
mermaidPreviewSvgHtml: string;
|
||||
setMermaidPreviewOpen: (open: boolean) => void;
|
||||
setMermaidPreviewSvgHtml: (html: string) => void;
|
||||
}
|
||||
|
||||
export interface RenderedBlocksState {
|
||||
renderedBlocks: Array<{ id: string; html: string; contentHash?: string }>;
|
||||
setRenderedBlocks: (blocks: Array<{ id: string; html: string; contentHash?: string }>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a click handler for copy buttons in code blocks.
|
||||
* Copies the code content to clipboard.
|
||||
*/
|
||||
export function createHandleCopyClick() {
|
||||
return async function handleCopyClick(event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const target = event.currentTarget as HTMLButtonElement | null;
|
||||
if (!target) return;
|
||||
|
||||
const wrapper = target.closest('.code-block-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
|
||||
if (!codeElement) return;
|
||||
|
||||
const rawCode = codeElement.textContent ?? '';
|
||||
|
||||
try {
|
||||
await copyCodeToClipboard(rawCode);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy code:', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for preview dialog open state changes.
|
||||
* Clears preview content when dialog is closed.
|
||||
*/
|
||||
export function createHandlePreviewDialogOpenChange(previewState: PreviewState) {
|
||||
return function handlePreviewDialogOpenChange(open: boolean) {
|
||||
previewState.setPreviewDialogOpen(open);
|
||||
|
||||
if (!open) {
|
||||
previewState.setPreviewCode('');
|
||||
previewState.setPreviewLanguage('text');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a click handler for preview buttons within HTML code blocks.
|
||||
* Opens a preview dialog with the rendered HTML content.
|
||||
*/
|
||||
export function createHandlePreviewClick(previewState: PreviewState) {
|
||||
return async function handlePreviewClick(event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const target = event.currentTarget as HTMLButtonElement | null;
|
||||
if (!target) return;
|
||||
|
||||
const wrapper = target.closest('.code-block-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
|
||||
if (!codeElement) return;
|
||||
|
||||
const rawCode = codeElement.textContent ?? '';
|
||||
const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
|
||||
const language = languageLabel?.textContent?.trim() || 'text';
|
||||
|
||||
previewState.setPreviewCode(rawCode);
|
||||
previewState.setPreviewLanguage(language);
|
||||
previewState.setPreviewDialogOpen(true);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a click handler for mermaid block interactions.
|
||||
* Handles copy, preview, and diagram click events via event delegation.
|
||||
*/
|
||||
export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
|
||||
return async function handleMermaidClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Check if clicking on copy or preview button in mermaid block
|
||||
const copyBtn = target.closest('.mermaid-block-wrapper .copy-code-btn');
|
||||
const previewBtn = target.closest('.mermaid-block-wrapper .preview-code-btn');
|
||||
|
||||
if (copyBtn || previewBtn) {
|
||||
const wrapper = target.closest('.mermaid-block-wrapper');
|
||||
if (!wrapper) return;
|
||||
|
||||
const preElement = wrapper.querySelector<HTMLElement>('pre.mermaid[data-mermaid-syntax]');
|
||||
if (!preElement) return;
|
||||
|
||||
const mermaidSyntax = preElement.dataset.mermaidSyntax ?? '';
|
||||
|
||||
if (copyBtn) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await copyToClipboard(mermaidSyntax);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy mermaid syntax:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewBtn) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const svg = preElement.querySelector('svg');
|
||||
if (!svg) return;
|
||||
mermaidState.setMermaidPreviewSvgHtml(svg.outerHTML);
|
||||
mermaidState.setMermaidPreviewOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, open preview when clicking on the mermaid diagram itself
|
||||
const mermaidEl = target.closest('.mermaid');
|
||||
if (!mermaidEl) return;
|
||||
|
||||
const svg = mermaidEl.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
mermaidState.setMermaidPreviewSvgHtml(svg.outerHTML);
|
||||
mermaidState.setMermaidPreviewOpen(true);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a handler for mermaid preview dialog open state changes.
|
||||
* Cleans up SVG content when dialog is closed.
|
||||
*/
|
||||
export function createHandleMermaidPreviewOpenChange(mermaidState: MermaidPreviewState) {
|
||||
return function handleMermaidPreviewOpenChange(open: boolean) {
|
||||
mermaidState.setMermaidPreviewOpen(open);
|
||||
if (!open) {
|
||||
mermaidState.setMermaidPreviewSvgHtml('');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error handler for images that fail to load (e.g., CORS issues).
|
||||
* Shows fallback UI for broken images.
|
||||
*/
|
||||
export function createHandleImageError(
|
||||
renderedBlocksState: RenderedBlocksState,
|
||||
IMAGE_NOT_ERROR_BOUND_SELECTOR: string,
|
||||
DATA_ERROR_BOUND_ATTR: string,
|
||||
BOOL_TRUE_STRING: string
|
||||
) {
|
||||
return async function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
if (!img) return;
|
||||
|
||||
const blockId = img.closest('[data-block-id]')?.getAttribute('data-block-id');
|
||||
if (!blockId) return;
|
||||
|
||||
const block = renderedBlocksState.renderedBlocks.find((b) => b.id === blockId);
|
||||
if (!block) return;
|
||||
|
||||
// Skip if already handled
|
||||
if (img.dataset[DATA_ERROR_BOUND_ATTR] === BOOL_TRUE_STRING) return;
|
||||
img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
|
||||
|
||||
// Get the fallback HTML and replace the image
|
||||
const fallbackHtml = `<div class="image-error-placeholder" data-original-src="${img.src}">
|
||||
<span class="image-error-icon">⚠️</span>
|
||||
<span class="image-error-text">Failed to load image</span>
|
||||
</div>`;
|
||||
|
||||
// Replace the img element with fallback in the block's HTML
|
||||
const newHtml = block.html.replace(/img[^>]*src=["']([^"']*)[^>]*>/g, (match, src) => {
|
||||
if (src === img.src) {
|
||||
return fallbackHtml.replace('data-original-src=""', `data-original-src="${src}"`);
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// Update the block
|
||||
const newBlocks = renderedBlocksState.renderedBlocks.map((b) =>
|
||||
b.id === blockId ? { ...b, html: newHtml } : b
|
||||
);
|
||||
renderedBlocksState.setRenderedBlocks(newBlocks);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a function to set up code block action event listeners.
|
||||
* Binds click handlers to copy and preview buttons within code blocks.
|
||||
*/
|
||||
export function createSetupCodeBlockActions(
|
||||
handleCopyClick: (event: Event) => void,
|
||||
handlePreviewClick: (event: Event) => void
|
||||
) {
|
||||
return function setupCodeBlockActions(containerRef: HTMLElement | null) {
|
||||
if (!containerRef) return;
|
||||
|
||||
const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
|
||||
|
||||
for (const wrapper of wrappers) {
|
||||
const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
|
||||
const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
|
||||
|
||||
if (copyButton && copyButton.dataset.listenerBound !== 'true') {
|
||||
copyButton.dataset.listenerBound = 'true';
|
||||
copyButton.addEventListener('click', handleCopyClick);
|
||||
}
|
||||
|
||||
if (previewButton && previewButton.dataset.listenerBound !== 'true') {
|
||||
previewButton.dataset.listenerBound = 'true';
|
||||
previewButton.addEventListener('click', handlePreviewClick);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a function to set up image error handlers.
|
||||
* Attaches error handlers to images to show fallback UI when loading fails.
|
||||
*/
|
||||
export function createSetupImageErrorHandlers(
|
||||
handleImageError: (event: Event) => void,
|
||||
IMAGE_NOT_ERROR_BOUND_SELECTOR: string,
|
||||
DATA_ERROR_BOUND_ATTR: string,
|
||||
BOOL_TRUE_STRING: string
|
||||
) {
|
||||
return function setupImageErrorHandlers(containerRef: HTMLElement | null) {
|
||||
if (!containerRef) return;
|
||||
|
||||
const images = containerRef.querySelectorAll<HTMLImageElement>(IMAGE_NOT_ERROR_BOUND_SELECTOR);
|
||||
|
||||
for (const img of images) {
|
||||
img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
|
||||
img.addEventListener('error', handleImageError);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Utility functions for markdown processing in MarkdownContent component.
|
||||
*/
|
||||
|
||||
import type { RootContent as HastRootContent } from 'hast';
|
||||
|
||||
/**
|
||||
* Generates a unique identifier for a HAST node based on its position.
|
||||
* Used for stable block identification during incremental rendering.
|
||||
* @param node - The HAST root content node
|
||||
* @param indexFallback - Fallback index if position is unavailable
|
||||
* @returns Unique string identifier for the node
|
||||
*/
|
||||
export function getHastNodeId(node: HastRootContent, indexFallback: number): string {
|
||||
const position = node.position;
|
||||
|
||||
if (position?.start?.offset != null && position?.end?.offset != null) {
|
||||
return `hast-${position.start.offset}-${position.end.offset}`;
|
||||
}
|
||||
|
||||
return `${node.type}-${indexFallback}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a hash for MDAST node based on its position.
|
||||
* Used for cache lookup during incremental rendering.
|
||||
*/
|
||||
export function getMdastNodeHash(node: unknown, index: number): string {
|
||||
const n = node as {
|
||||
type?: string;
|
||||
position?: { start?: { offset?: number }; end?: { offset?: number } };
|
||||
};
|
||||
|
||||
if (n.position?.start?.offset != null && n.position?.end?.offset != null) {
|
||||
return `${n.type}-${n.position.start.offset}-${n.position.end.offset}`;
|
||||
}
|
||||
|
||||
return `${n.type}-idx${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the new content is an append (new content added to existing blocks).
|
||||
* This is used to optimize cache reuse during streaming updates.
|
||||
*
|
||||
* @param newContent - The new markdown content
|
||||
* @param previousContent - The previous markdown content to check against
|
||||
* @returns true if the content appears to be an append operation
|
||||
*/
|
||||
export function isAppendMode(newContent: string, previousContent: string): boolean {
|
||||
return previousContent.length > 0 && newContent.startsWith(previousContent);
|
||||
}
|
||||
|
||||
export interface CodeInfo {
|
||||
rawCode: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts code information from a button click target within a code block.
|
||||
* @param target - The clicked button element
|
||||
* @returns Object with rawCode and language, or null if extraction fails
|
||||
*/
|
||||
export function getCodeInfoFromTarget(target: HTMLElement): CodeInfo | null {
|
||||
const wrapper = target.closest('.code-block-wrapper');
|
||||
|
||||
if (!wrapper) {
|
||||
console.error('No wrapper found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
|
||||
|
||||
if (!codeElement) {
|
||||
console.error('No code element found in wrapper');
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawCode = codeElement.textContent ?? '';
|
||||
|
||||
const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
|
||||
const language = languageLabel?.textContent?.trim() || 'text';
|
||||
|
||||
return { rawCode, language };
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Shared utilities for enhanced code blocks and mermaid diagram blocks.
|
||||
* Contains common HAST element creation functions to avoid code duplication.
|
||||
*/
|
||||
|
||||
import type { Element, ElementContent } from 'hast';
|
||||
import {
|
||||
CODE_BLOCK_HEADER_CLASS,
|
||||
CODE_BLOCK_ACTIONS_CLASS,
|
||||
CODE_LANGUAGE_CLASS,
|
||||
COPY_CODE_BTN_CLASS,
|
||||
PREVIEW_CODE_BTN_CLASS,
|
||||
RELATIVE_CLASS,
|
||||
COPY_ICON_SVG,
|
||||
PREVIEW_ICON_SVG
|
||||
} from '$lib/constants';
|
||||
|
||||
export interface BlockIdGenerator {
|
||||
(id: number): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an icon element with the given SVG content.
|
||||
*/
|
||||
export function createIconElement(svg: string): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {},
|
||||
children: [{ type: 'raw', value: svg } as unknown as ElementContent]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a button element with icon.
|
||||
*/
|
||||
export function createButton(
|
||||
className: string,
|
||||
title: string,
|
||||
iconSvg: string,
|
||||
id: string,
|
||||
idAttribute: string
|
||||
): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'button',
|
||||
properties: {
|
||||
className: [className],
|
||||
[idAttribute]: id,
|
||||
title,
|
||||
type: 'button'
|
||||
},
|
||||
children: [createIconElement(iconSvg)]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy button element.
|
||||
*/
|
||||
export function createCopyButton(id: string, idAttribute: string, title: string = 'Copy'): Element {
|
||||
return createButton(COPY_CODE_BTN_CLASS, title, COPY_ICON_SVG, id, idAttribute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a preview button element.
|
||||
*/
|
||||
export function createPreviewButton(
|
||||
id: string,
|
||||
idAttribute: string,
|
||||
title: string = 'Preview'
|
||||
): Element {
|
||||
return createButton(PREVIEW_CODE_BTN_CLASS, title, PREVIEW_ICON_SVG, id, idAttribute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a block header with language label and action buttons.
|
||||
*/
|
||||
export function createBlockHeader(
|
||||
language: string,
|
||||
id: string,
|
||||
idAttribute: string,
|
||||
actions: Element[],
|
||||
languageClassName: string = CODE_LANGUAGE_CLASS
|
||||
): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: { className: [CODE_BLOCK_HEADER_CLASS] },
|
||||
children: [
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: { className: [languageClassName] },
|
||||
children: [{ type: 'text', value: language }]
|
||||
},
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
|
||||
children: actions
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scroll container element.
|
||||
*/
|
||||
export function createScrollContainer(preElement: Element, scrollContainerClass: string): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: { className: [scrollContainerClass] },
|
||||
children: [preElement]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a wrapper element with header and scroll container.
|
||||
*/
|
||||
export function createWrapper(
|
||||
header: Element,
|
||||
preElement: Element,
|
||||
wrapperClass: string,
|
||||
scrollContainerClass: string,
|
||||
additionalAttributes?: Record<string, string>
|
||||
): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: {
|
||||
className: [wrapperClass, RELATIVE_CLASS],
|
||||
...additionalAttributes
|
||||
} as Element['properties'],
|
||||
children: [header, createScrollContainer(preElement, scrollContainerClass)]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique block ID using a global counter.
|
||||
*/
|
||||
export function generateBlockId(prefix: string, windowKey: keyof Window): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
const idx = window[windowKey] as number | undefined;
|
||||
const next = (idx ?? 0) + 1;
|
||||
(window as unknown as Record<string, number>)[windowKey] = next;
|
||||
return `${prefix}-${next}`;
|
||||
}
|
||||
// Fallback for SSR - use timestamp + random
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
@ -13,16 +13,14 @@
|
||||
import type { Plugin } from 'unified';
|
||||
import type { Root, Element, ElementContent } from 'hast';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { CODE_BLOCK_SCROLL_CONTAINER_CLASS, CODE_BLOCK_WRAPPER_CLASS } from '$lib/constants';
|
||||
import {
|
||||
CODE_BLOCK_SCROLL_CONTAINER_CLASS,
|
||||
CODE_BLOCK_WRAPPER_CLASS,
|
||||
CODE_BLOCK_HEADER_CLASS,
|
||||
CODE_BLOCK_ACTIONS_CLASS,
|
||||
CODE_LANGUAGE_CLASS,
|
||||
COPY_CODE_BTN_CLASS,
|
||||
PREVIEW_CODE_BTN_CLASS,
|
||||
RELATIVE_CLASS
|
||||
} from '$lib/constants';
|
||||
createBlockHeader,
|
||||
createCopyButton,
|
||||
createPreviewButton,
|
||||
createWrapper,
|
||||
generateBlockId
|
||||
} from './code-block-utils';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -30,87 +28,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
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>`;
|
||||
|
||||
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>`;
|
||||
|
||||
function createIconElement(svg: string): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {},
|
||||
children: [{ type: 'raw', value: svg } as unknown as ElementContent]
|
||||
};
|
||||
}
|
||||
|
||||
function createButton(className: string, title: string, iconSvg: string, codeId: string): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'button',
|
||||
properties: {
|
||||
className: [className],
|
||||
'data-code-id': codeId,
|
||||
title,
|
||||
type: 'button'
|
||||
},
|
||||
children: [createIconElement(iconSvg)]
|
||||
};
|
||||
}
|
||||
|
||||
function createCopyButton(codeId: string): Element {
|
||||
return createButton(COPY_CODE_BTN_CLASS, 'Copy code', COPY_ICON_SVG, codeId);
|
||||
}
|
||||
|
||||
function createPreviewButton(codeId: string): Element {
|
||||
return createButton(PREVIEW_CODE_BTN_CLASS, 'Preview code', PREVIEW_ICON_SVG, codeId);
|
||||
}
|
||||
|
||||
function createHeader(language: string, codeId: string): Element {
|
||||
const actions: Element[] = [createCopyButton(codeId)];
|
||||
|
||||
if (language.toLowerCase() === 'html') {
|
||||
actions.push(createPreviewButton(codeId));
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: { className: [CODE_BLOCK_HEADER_CLASS] },
|
||||
children: [
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: { className: [CODE_LANGUAGE_CLASS] },
|
||||
children: [{ type: 'text', value: language }]
|
||||
},
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
|
||||
children: actions
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function createScrollContainer(preElement: Element): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: { className: [CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
|
||||
children: [preElement]
|
||||
};
|
||||
}
|
||||
|
||||
function createWrapper(header: Element, preElement: Element): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'div',
|
||||
properties: { className: [CODE_BLOCK_WRAPPER_CLASS, RELATIVE_CLASS] },
|
||||
children: [header, createScrollContainer(preElement)]
|
||||
};
|
||||
}
|
||||
|
||||
function extractLanguage(codeElement: Element): string {
|
||||
const className = codeElement.properties?.className;
|
||||
if (!Array.isArray(className)) return 'text';
|
||||
@ -124,17 +41,6 @@ function extractLanguage(codeElement: Element): string {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique code block ID using a global counter.
|
||||
*/
|
||||
function generateCodeId(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return `code-${(window.idxCodeBlock = (window.idxCodeBlock ?? 0) + 1)}`;
|
||||
}
|
||||
// Fallback for SSR - use timestamp + random
|
||||
return `code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehype plugin to enhance code blocks with wrapper, header, and action buttons.
|
||||
* This plugin wraps <pre><code> elements with a container that includes:
|
||||
@ -154,15 +60,26 @@ export const rehypeEnhanceCodeBlocks: Plugin<[], Root> = () => {
|
||||
if (!codeElement) return;
|
||||
|
||||
const language = extractLanguage(codeElement);
|
||||
const codeId = generateCodeId();
|
||||
const codeId = generateBlockId('code', 'idxCodeBlock');
|
||||
|
||||
codeElement.properties = {
|
||||
...codeElement.properties,
|
||||
'data-code-id': codeId
|
||||
};
|
||||
|
||||
const header = createHeader(language, codeId);
|
||||
const wrapper = createWrapper(header, node);
|
||||
const actions: Element[] = [createCopyButton(codeId, 'data-code-id', 'Copy code')];
|
||||
|
||||
if (language.toLowerCase() === 'html') {
|
||||
actions.push(createPreviewButton(codeId, 'data-code-id', 'Preview code'));
|
||||
}
|
||||
|
||||
const header = createBlockHeader(language, codeId, 'data-code-id', actions);
|
||||
const wrapper = createWrapper(
|
||||
header,
|
||||
node,
|
||||
CODE_BLOCK_WRAPPER_CLASS,
|
||||
CODE_BLOCK_SCROLL_CONTAINER_CLASS
|
||||
);
|
||||
|
||||
// Replace pre with wrapper in parent
|
||||
(parent.children as ElementContent[])[index] = wrapper;
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Rehype plugin to enhance mermaid diagram blocks with wrapper, header, and action buttons.
|
||||
*
|
||||
* Wraps <pre class="mermaid"> elements with a container that includes:
|
||||
* - Language label ("mermaid")
|
||||
* - Copy button (copies mermaid syntax to clipboard)
|
||||
* - Preview button (opens fullscreen preview dialog)
|
||||
*
|
||||
* This operates directly on the HAST tree for better performance,
|
||||
* avoiding the need to stringify and re-parse HTML.
|
||||
*/
|
||||
|
||||
import type { Plugin } from 'unified';
|
||||
import type { Root, Element, ElementContent } from 'hast';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { MERMAID_WRAPPER_CLASS, MERMAID_SCROLL_CONTAINER_CLASS } from '$lib/constants';
|
||||
import {
|
||||
createBlockHeader,
|
||||
createCopyButton,
|
||||
createPreviewButton,
|
||||
createWrapper,
|
||||
generateBlockId
|
||||
} from './code-block-utils';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
idxMermaidBlock?: number;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehype plugin to enhance mermaid diagram blocks with wrapper, header, and action buttons.
|
||||
* This plugin wraps <pre class="mermaid"> elements with a container that includes:
|
||||
* - Language label ("mermaid")
|
||||
* - Copy button
|
||||
* - Preview button
|
||||
*/
|
||||
export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'element', (node: Element, index, parent) => {
|
||||
if (node.tagName !== 'pre' || !parent || index === undefined) return;
|
||||
|
||||
const className = node.properties?.className;
|
||||
if (!Array.isArray(className)) return;
|
||||
|
||||
const isMermaid = className.some((cls) => typeof cls === 'string' && cls === 'mermaid');
|
||||
|
||||
if (!isMermaid) return;
|
||||
|
||||
const mermaidId = generateBlockId('mermaid', 'idxMermaidBlock');
|
||||
|
||||
// Extract the mermaid syntax (text content of the pre element)
|
||||
const diagramText = node.children
|
||||
.map((child) => {
|
||||
if (child.type === 'text') return child.value;
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
|
||||
// Store the mermaid syntax in data attribute for copy functionality
|
||||
node.properties = {
|
||||
...node.properties,
|
||||
'data-mermaid-syntax': diagramText,
|
||||
'data-mermaid-id': mermaidId
|
||||
};
|
||||
|
||||
const actions = [
|
||||
createCopyButton(mermaidId, 'data-mermaid-id', 'Copy mermaid syntax'),
|
||||
createPreviewButton(mermaidId, 'data-mermaid-id', 'Preview diagram')
|
||||
];
|
||||
|
||||
const header = createBlockHeader('mermaid', mermaidId, 'data-mermaid-id', actions);
|
||||
const wrapper = createWrapper(
|
||||
header,
|
||||
node,
|
||||
MERMAID_WRAPPER_CLASS,
|
||||
MERMAID_SCROLL_CONTAINER_CLASS,
|
||||
{ 'data-mermaid-id': mermaidId }
|
||||
);
|
||||
|
||||
// Replace pre with wrapper in parent
|
||||
(parent.children as ElementContent[])[index] = wrapper;
|
||||
});
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,67 @@
|
||||
import type { Plugin } from 'unified';
|
||||
import type { Root, Element, ElementContent, Text } from 'hast';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
/**
|
||||
* Recursively extracts all text content from a HAST node.
|
||||
* Handles nested elements (e.g., span wrappers from syntax highlighting).
|
||||
*/
|
||||
function extractText(node: ElementContent): string {
|
||||
if (node.type === 'text') return node.value;
|
||||
if (node.type === 'element') {
|
||||
return (node.children ?? []).map(extractText).join('');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehype plugin to convert mermaid code blocks to <pre class="mermaid"> elements.
|
||||
*
|
||||
* Transforms:
|
||||
* <pre><code class="language-mermaid">graph TD; A-->B</code></pre>
|
||||
* into:
|
||||
* <pre class="mermaid">graph TD; A-->B</pre>
|
||||
*
|
||||
* The mermaid library renders these client-side via mermaid.run().
|
||||
*
|
||||
* Must run BEFORE rehypeEnhanceCodeBlocks so mermaid blocks are not wrapped
|
||||
* with code block headers/buttons (they have no <code> child, so they're skipped).
|
||||
*/
|
||||
export const rehypeMermaidPre: Plugin<[], Root> = () => {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'element', (node: Element, index, parent) => {
|
||||
if (node.tagName !== 'pre' || !parent || index === undefined) return;
|
||||
|
||||
const codeElement = node.children.find(
|
||||
(child): child is Element => child.type === 'element' && child.tagName === 'code'
|
||||
);
|
||||
|
||||
if (!codeElement) return;
|
||||
|
||||
const className = codeElement.properties?.className;
|
||||
if (!Array.isArray(className)) return;
|
||||
|
||||
const isMermaid = className.some(
|
||||
(cls) => typeof cls === 'string' && cls === 'language-mermaid'
|
||||
);
|
||||
|
||||
if (!isMermaid) return;
|
||||
|
||||
// Recursively extract text to handle nested spans from syntax highlighting
|
||||
const diagramText = codeElement.children.map(extractText).join('').trim();
|
||||
|
||||
if (!diagramText) return;
|
||||
|
||||
const mermaidPre: Element = {
|
||||
type: 'element',
|
||||
tagName: 'pre',
|
||||
properties: {
|
||||
className: ['mermaid']
|
||||
},
|
||||
children: [{ type: 'text', value: diagramText } as Text]
|
||||
};
|
||||
|
||||
(parent.children as ElementContent[])[index] = mermaidPre;
|
||||
});
|
||||
};
|
||||
};
|
||||
126
tools/ui/src/lib/components/app/content/MermaidPreview.svelte
Normal file
126
tools/ui/src/lib/components/app/content/MermaidPreview.svelte
Normal file
@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import MermaidPreviewControls from './MermaidPreviewControls.svelte';
|
||||
|
||||
interface Props {
|
||||
svgHtml: string;
|
||||
}
|
||||
|
||||
let { svgHtml }: Props = $props();
|
||||
|
||||
// Zoom and pan state
|
||||
let scale = $state(1);
|
||||
let translateX = $state(0);
|
||||
let translateY = $state(0);
|
||||
let isDragging = $state(false);
|
||||
const containerRef = { current: null as HTMLDivElement | null };
|
||||
|
||||
// Drag start position
|
||||
let dragStartX = 0;
|
||||
let dragStartY = 0;
|
||||
let dragStartTranslateX = 0;
|
||||
let dragStartTranslateY = 0;
|
||||
|
||||
const MIN_SCALE = 0.1;
|
||||
const MAX_SCALE = 10;
|
||||
const ZOOM_STEP = 0.15;
|
||||
|
||||
function resetView() {
|
||||
scale = 1;
|
||||
translateX = 0;
|
||||
translateY = 0;
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale = Math.min(scale + ZOOM_STEP, MAX_SCALE);
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
scale = Math.max(scale - ZOOM_STEP, MIN_SCALE);
|
||||
}
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
const delta = event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||
scale = Math.min(Math.max(scale + delta, MIN_SCALE), MAX_SCALE);
|
||||
}
|
||||
|
||||
// Imperatively attach a non-passive wheel listener so preventDefault() actually works
|
||||
// (Svelte 5 wheel listeners are passive by default, making preventDefault() a no-op)
|
||||
$effect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
handleWheel(e);
|
||||
}
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
});
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0 && event.pointerType === 'mouse') return;
|
||||
|
||||
isDragging = true;
|
||||
dragStartX = event.clientX;
|
||||
dragStartY = event.clientY;
|
||||
dragStartTranslateX = translateX;
|
||||
dragStartTranslateY = translateY;
|
||||
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
|
||||
translateX = dragStartTranslateX + (event.clientX - dragStartX);
|
||||
translateY = dragStartTranslateY + (event.clientY - dragStartY);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
isDragging = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={containerRef.current}
|
||||
class="mermaid-preview relative flex items-center justify-center overflow-hidden bg-muted/20"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mermaid-preview-diagram transform-origin-center inline-block min-h-fit min-w-fit will-change-transform {isDragging &&
|
||||
'select-none'}"
|
||||
style="transform: translate({translateX}px, {translateY}px) scale({scale}); cursor: {isDragging
|
||||
? 'grabbing'
|
||||
: 'grab'};"
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
onpointerleave={handlePointerUp}
|
||||
>
|
||||
<!-- eslint-disable-next-line no-at-html-tags -->
|
||||
{@html svgHtml}
|
||||
</div>
|
||||
|
||||
<MermaidPreviewControls
|
||||
{scale}
|
||||
{svgHtml}
|
||||
onZoomIn={zoomIn}
|
||||
onZoomOut={zoomOut}
|
||||
onResetView={resetView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="postcss" scoped>
|
||||
/* Styles for SVGs rendered via {@html} — no Tailwind class can target child elements */
|
||||
.mermaid-preview-diagram :global(svg) {
|
||||
min-height: min(50vh, 12rem);
|
||||
min-width: min(80vw, 20rem);
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
height: auto !important;
|
||||
width: auto !important;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { Download } from '@lucide/svelte';
|
||||
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
|
||||
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
|
||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
||||
|
||||
interface Props {
|
||||
scale: number;
|
||||
svgHtml: string;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onResetView: () => void;
|
||||
}
|
||||
|
||||
let { scale, svgHtml, onZoomIn, onZoomOut, onResetView }: Props = $props();
|
||||
|
||||
function downloadSvg() {
|
||||
if (!svgHtml) return;
|
||||
const blob = new Blob([svgHtml], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'diagram.svg';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="mermaid-preview-controls absolute bottom-8 flex shrink-0 items-center justify-center p-3"
|
||||
>
|
||||
<div class="mermaid-preview-controls-inner flex items-center gap-1 rounded-lg bg-muted p-1">
|
||||
<button
|
||||
class="mermaid-preview-btn flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-foreground transition-colors hover:bg-muted-foreground/15 active:bg-muted-foreground/25"
|
||||
onclick={onZoomOut}
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOutIcon class="mermaid-preview-btn-icon h-4 w-4" />
|
||||
</button>
|
||||
<span
|
||||
class="mermaid-preview-zoom-label min-w-[3.5rem] px-0.5 text-center text-xs font-medium text-muted-foreground tabular-nums select-none"
|
||||
>{Math.round(scale * 100)}%</span
|
||||
>
|
||||
<button
|
||||
class="mermaid-preview-btn flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-foreground transition-colors hover:bg-muted-foreground/15 active:bg-muted-foreground/25"
|
||||
onclick={onZoomIn}
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomInIcon class="mermaid-preview-btn-icon h-4 w-4" />
|
||||
</button>
|
||||
<div class="mermaid-preview-controls-separator mx-1 h-5 w-px bg-border/50"></div>
|
||||
|
||||
<button
|
||||
class="mermaid-preview-btn flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-foreground transition-colors hover:bg-muted-foreground/15 active:bg-muted-foreground/25"
|
||||
onclick={onResetView}
|
||||
title="Reset view"
|
||||
aria-label="Reset view"
|
||||
>
|
||||
<RotateCcwIcon class="mermaid-preview-btn-icon h-4 w-4" />
|
||||
</button>
|
||||
<div class="mermaid-preview-controls-separator mx-1 h-5 w-px bg-border/50"></div>
|
||||
|
||||
<button
|
||||
class="mermaid-preview-btn flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-foreground transition-colors hover:bg-muted-foreground/15 active:bg-muted-foreground/25"
|
||||
onclick={downloadSvg}
|
||||
title="Download SVG"
|
||||
aria-label="Download SVG"
|
||||
>
|
||||
<Download class="mermaid-preview-btn-icon h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -77,3 +77,22 @@ export { default as SyntaxHighlightedCode } from './SyntaxHighlightedCode.svelte
|
||||
* ```
|
||||
*/
|
||||
export { default as CollapsibleContentBlock } from './CollapsibleContentBlock.svelte';
|
||||
|
||||
/**
|
||||
* **MermaidPreview** - Interactive Mermaid diagram viewer
|
||||
*
|
||||
* Renders Mermaid-generated SVG diagrams with zoom, pan, and fit-to-view controls.
|
||||
*
|
||||
* **Features:**
|
||||
* - Mouse wheel zoom in/out
|
||||
* - Click-drag panning with pointer capture
|
||||
* - Fit to view and reset view controls
|
||||
* - Download as SVG
|
||||
* - Responsive scaling with viewBox detection
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <MermaidPreview svgHtml={diagramSvg} />
|
||||
* ```
|
||||
*/
|
||||
export { default as MermaidPreview } from './MermaidPreview.svelte';
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { MermaidPreview } from '$lib/components/app/content';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
svgHtml: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), svgHtml, onOpenChange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
<Dialog.Content
|
||||
class="z-999999 grid max-h-full max-w-full! grid-rows-[1fr_auto] overflow-hidden p-0 md:h-[90vh] md:max-w-[90vw]!"
|
||||
>
|
||||
<MermaidPreview {svgHtml} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@ -474,3 +474,35 @@ export { default as DialogMcpResourcesBrowser } from './DialogMcpResourcesBrowse
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.svelte';
|
||||
|
||||
/**
|
||||
* **DialogMermaidPreview** - Full-screen Mermaid diagram preview with zoom and pan
|
||||
*
|
||||
* Full-screen dialog for previewing Mermaid diagrams with interactive controls.
|
||||
* Supports mouse wheel zoom, drag-to-pan, and toolbar buttons for zoom in/out,
|
||||
* fit to view, and reset.
|
||||
*
|
||||
* **Architecture:**
|
||||
* - Uses UI dialog components (`Dialog.Root`, `Dialog.Overlay`, `Dialog.Content`)
|
||||
* for consistent styling, animations, and accessibility
|
||||
* - CSS transform-based zoom and pan (no external dependencies)
|
||||
* - Pointer events for cross-device drag support (mouse + touch)
|
||||
* - Wheel events for zoom-to-cursor functionality
|
||||
*
|
||||
* **Features:**
|
||||
* - Scroll wheel zoom centered on cursor position
|
||||
* - Click and drag to pan the diagram
|
||||
* - Toolbar with zoom in, zoom out, fit to view, reset controls
|
||||
* - Zoom percentage indicator
|
||||
* - Keyboard accessible close button
|
||||
* - Dark/light theme support
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <DialogMermaidPreview
|
||||
* bind:open={showMermaidPreview}
|
||||
* svgHtml={mermaidSvgContent}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
|
||||
|
||||
@ -34,3 +34,8 @@ export const MODALITY_LABELS = {
|
||||
[ModelModality.AUDIO]: 'Audio',
|
||||
[ModelModality.VIDEO]: 'Video'
|
||||
} as const;
|
||||
|
||||
// Shared SVG icon strings for copy and preview buttons
|
||||
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>`;
|
||||
|
||||
@ -15,6 +15,7 @@ export * from './cache';
|
||||
export * from './chat-form';
|
||||
export * from './cli-flags';
|
||||
export * from './code-blocks';
|
||||
export * from './icons';
|
||||
export * from './code';
|
||||
export * from './context-keys';
|
||||
export * from './control-actions';
|
||||
@ -26,6 +27,7 @@ export * from './icons';
|
||||
export * from './latex-protection';
|
||||
export * from './literal-html';
|
||||
export * from './markdown';
|
||||
export * from './mermaid-blocks';
|
||||
export * from './max-bundle-size';
|
||||
export * from './mcp';
|
||||
export * from './mcp-form';
|
||||
|
||||
2
tools/ui/src/lib/constants/mermaid-blocks.ts
Normal file
2
tools/ui/src/lib/constants/mermaid-blocks.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const MERMAID_WRAPPER_CLASS = 'mermaid-block-wrapper';
|
||||
export const MERMAID_SCROLL_CONTAINER_CLASS = 'mermaid-scroll-container';
|
||||
Loading…
x
Reference in New Issue
Block a user