From 2592471d110873a91909101860b7bd90479b17e6 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 2 Jan 2026 19:37:41 +0100 Subject: [PATCH] feat: Add image load error fallback in MarkdownContent --- .../app/misc/MarkdownContent.svelte | 128 +++++++++++++++++- 1 file changed, 125 insertions(+), 3 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte index cb3ae17a63..a64cc2a1b7 100644 --- a/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte +++ b/tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte @@ -25,6 +25,7 @@ interface Props { content: string; class?: string; + disableMath?: boolean; } interface MarkdownBlock { @@ -32,7 +33,7 @@ html: string; } - let { content, class: className = '' }: Props = $props(); + let { content, class: className = '', disableMath = false }: Props = $props(); let containerRef = $state(); let renderedBlocks = $state([]); @@ -47,6 +48,21 @@ const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`; let processor = $derived(() => { + if (disableMath) { + // Processor without math/LaTeX support + return remark() + .use(remarkGfm) // GitHub Flavored Markdown + .use(remarkBreaks) // Convert line breaks to
+ .use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation + .use(remarkRehype) // Convert Markdown AST to rehype + .use(rehypeHighlight) // Add syntax highlighting + .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g.,
,
    ) inside Markdown tables + .use(rehypeEnhanceLinks) // Add target="_blank" to links + .use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions + .use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string + } + + // Default processor with math/LaTeX support return remark() .use(remarkGfm) // GitHub Flavored Markdown .use(remarkMath) // Parse $inline$ and $$block$$ math @@ -298,6 +314,62 @@ } } + /** + * Attaches error handlers to images to show fallback UI when loading fails (e.g., CORS). + * Uses data-error-bound attribute to prevent duplicate bindings. + */ + function setupImageErrorHandlers() { + if (!containerRef) return; + + const images = containerRef.querySelectorAll('img:not([data-error-bound])'); + + for (const img of images) { + img.dataset.errorBound = 'true'; + img.addEventListener('error', handleImageError); + } + } + + /** + * 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. + */ + function handleImageError(event: Event) { + const img = event.target as HTMLImageElement; + if (!img || !img.src) return; + + // Don't handle data URLs or already-handled images + if (img.src.startsWith('data:') || img.dataset.errorHandled === 'true') return; + img.dataset.errorHandled = 'true'; + + const src = img.src; + const alt = img.alt || 'Image'; + + // Create fallback element + const fallback = document.createElement('div'); + fallback.className = 'image-load-error'; + fallback.innerHTML = ` +
    + + + + + + External image cannot be displayed + + Open in new tab + + + + + + +
    + `; + + // Replace image with fallback + img.parentNode?.replaceChild(fallback, img); + } + /** * Converts a single HAST node to an enhanced HTML string. * Applies link and code block enhancements to the output. @@ -366,6 +438,7 @@ if ((hasRenderedBlocks || hasUnstableBlock) && containerRef) { setupCodeBlockActions(); + setupImageErrorHandlers(); } }); @@ -405,8 +478,8 @@ } /* Base typography styles */ - div :global(p:not(:last-child)) { - margin-bottom: 1rem; + div :global(p) { + margin-block: 1rem; line-height: 1.75; } @@ -867,4 +940,53 @@ 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); + }