]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: add HTML/JS preview support to MarkdownContent with sandboxed iframe (#16757)
authorPascal <redacted>
Sat, 1 Nov 2025 16:14:54 +0000 (17:14 +0100)
committerGitHub <redacted>
Sat, 1 Nov 2025 16:14:54 +0000 (17:14 +0100)
* webui: add HTML/JS preview support to MarkdownContent with sandboxed iframe dialog

Extended MarkdownContent to flag previewable code languages,
add a preview button alongside copy controls, manage preview
dialog state, and share styling for the new button group

Introduced CodePreviewDialog.svelte, a sandboxed iframe modal
for rendering HTML/JS previews with consistent dialog controls

* webui: fullscreen HTML preview dialog using bits-ui

* Update tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte

Co-authored-by: Aleksander Grygier <redacted>
* Update tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte

Co-authored-by: Aleksander Grygier <redacted>
* webui: pedantic style tweak for CodePreviewDialog close button

* webui: remove overengineered preview language logic

* chore: update webui static build

---------

Co-authored-by: Aleksander Grygier <redacted>
tools/server/public/index.html.gz
tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte

index a81bae04d1983d992a4b46da93153d1cfa63c704..501fa455a24169b1c00bb2ebe056c3719b4ce1e8 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
diff --git a/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte b/tools/server/webui/src/lib/components/app/misc/CodePreviewDialog.svelte
new file mode 100644 (file)
index 0000000..702519f
--- /dev/null
@@ -0,0 +1,93 @@
+<script lang="ts">
+       import { Dialog as DialogPrimitive } from 'bits-ui';
+       import XIcon from '@lucide/svelte/icons/x';
+
+       interface Props {
+               open: boolean;
+               code: string;
+               language: string;
+               onOpenChange?: (open: boolean) => void;
+       }
+
+       let { open = $bindable(), code, language, onOpenChange }: Props = $props();
+
+       let iframeRef = $state<HTMLIFrameElement | null>(null);
+
+       $effect(() => {
+               if (!iframeRef) return;
+
+               if (open) {
+                       iframeRef.srcdoc = code;
+               } else {
+                       iframeRef.srcdoc = '';
+               }
+       });
+
+       function handleOpenChange(nextOpen: boolean) {
+               open = nextOpen;
+               onOpenChange?.(nextOpen);
+       }
+</script>
+
+<DialogPrimitive.Root {open} onOpenChange={handleOpenChange}>
+       <DialogPrimitive.Portal>
+               <DialogPrimitive.Overlay class="code-preview-overlay" />
+
+               <DialogPrimitive.Content class="code-preview-content">
+                       <iframe
+                               bind:this={iframeRef}
+                               title="Preview {language}"
+                               sandbox="allow-scripts"
+                               class="code-preview-iframe"
+                       ></iframe>
+
+                       <DialogPrimitive.Close
+                               class="code-preview-close absolute top-4 right-4 border-none bg-transparent text-white opacity-70 mix-blend-difference transition-opacity hover:opacity-100 focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-8"
+                               aria-label="Close preview"
+                       >
+                               <XIcon />
+                               <span class="sr-only">Close preview</span>
+                       </DialogPrimitive.Close>
+               </DialogPrimitive.Content>
+       </DialogPrimitive.Portal>
+</DialogPrimitive.Root>
+
+<style lang="postcss">
+       :global(.code-preview-overlay) {
+               position: fixed;
+               inset: 0;
+               background-color: transparent;
+               z-index: 100000;
+       }
+
+       :global(.code-preview-content) {
+               position: fixed;
+               inset: 0;
+               top: 0 !important;
+               left: 0 !important;
+               width: 100dvw;
+               height: 100dvh;
+               margin: 0;
+               padding: 0;
+               border: none;
+               border-radius: 0;
+               background-color: transparent;
+               box-shadow: none;
+               display: block;
+               overflow: hidden;
+               transform: none !important;
+               z-index: 100001;
+       }
+
+       :global(.code-preview-iframe) {
+               display: block;
+               width: 100dvw;
+               height: 100dvh;
+               border: 0;
+       }
+
+       :global(.code-preview-close) {
+               position: absolute;
+               z-index: 100002;
+       }
+</style>
index 1f4caa9003bceaae72ab86743ea9877601f86650..1c069db58d830ec40fc8c0e2c74577d5e7173695 100644 (file)
@@ -15,6 +15,7 @@
        import githubLightCss from 'highlight.js/styles/github.css?inline';
        import { mode } from 'mode-watcher';
        import { remarkLiteralHtml } from '$lib/markdown/literal-html';
+       import CodePreviewDialog from './CodePreviewDialog.svelte';
 
        interface Props {
                content: string;
@@ -25,6 +26,9 @@
 
        let containerRef = $state<HTMLDivElement>();
        let processedHtml = $state('');
+       let previewDialogOpen = $state(false);
+       let previewCode = $state('');
+       let previewLanguage = $state('text');
 
        function loadHighlightTheme(isDark: boolean) {
                if (!browser) return;
 
                        const rawCode = codeElement.textContent || '';
                        const codeId = `code-${Date.now()}-${index}`;
-
                        codeElement.setAttribute('data-code-id', codeId);
                        codeElement.setAttribute('data-raw-code', rawCode);
 
                        copyButton.setAttribute('type', 'button');
 
                        copyButton.innerHTML = `
-                               <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>
-                       `;
+                                <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 actions = document.createElement('div');
+                       actions.className = 'code-block-actions';
+
+                       actions.appendChild(copyButton);
+
+                       if (language.toLowerCase() === 'html') {
+                               const previewButton = document.createElement('button');
+                               previewButton.className = 'preview-code-btn';
+                               previewButton.setAttribute('data-code-id', codeId);
+                               previewButton.setAttribute('title', 'Preview code');
+                               previewButton.setAttribute('type', 'button');
+
+                               previewButton.innerHTML = `
+                                        <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>
+                                `;
+
+                               actions.appendChild(previewButton);
+                       }
 
                        header.appendChild(languageLabel);
-                       header.appendChild(copyButton);
+                       header.appendChild(actions);
                        wrapper.appendChild(header);
 
                        const clonedPre = pre.cloneNode(true) as HTMLElement;
                }
        }
 
-       function setupCopyButtons() {
-               if (!containerRef) return;
+       function getCodeInfoFromTarget(target: HTMLElement) {
+               const wrapper = target.closest('.code-block-wrapper');
 
-               const copyButtons = containerRef.querySelectorAll('.copy-code-btn');
+               if (!wrapper) {
+                       console.error('No wrapper found');
+                       return null;
+               }
 
-               for (const button of copyButtons) {
-                       button.addEventListener('click', async (e) => {
-                               e.preventDefault();
-                               e.stopPropagation();
+               const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
 
-                               const target = e.currentTarget as HTMLButtonElement;
-                               const codeId = target.getAttribute('data-code-id');
+               if (!codeElement) {
+                       console.error('No code element found in wrapper');
+                       return null;
+               }
 
-                               if (!codeId) {
-                                       console.error('No code ID found on button');
-                                       return;
-                               }
+               const rawCode = codeElement.getAttribute('data-raw-code');
 
-                               // Find the code element within the same wrapper
-                               const wrapper = target.closest('.code-block-wrapper');
-                               if (!wrapper) {
-                                       console.error('No wrapper found');
-                                       return;
-                               }
+               if (rawCode === null) {
+                       console.error('No raw code found');
+                       return null;
+               }
 
-                               const codeElement = wrapper.querySelector('code[data-code-id]');
-                               if (!codeElement) {
-                                       console.error('No code element found in wrapper');
-                                       return;
-                               }
+               const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
+               const language = languageLabel?.textContent?.trim() || 'text';
 
-                               const rawCode = codeElement.getAttribute('data-raw-code');
-                               if (!rawCode) {
-                                       console.error('No raw code found');
-                                       return;
-                               }
+               return { rawCode, language };
+       }
 
-                               try {
-                                       await copyCodeToClipboard(rawCode);
-                               } catch (error) {
-                                       console.error('Failed to copy code:', error);
-                               }
-                       });
+       async function handleCopyClick(event: Event) {
+               event.preventDefault();
+               event.stopPropagation();
+
+               const target = event.currentTarget as HTMLButtonElement | null;
+
+               if (!target) {
+                       return;
+               }
+
+               const info = getCodeInfoFromTarget(target);
+
+               if (!info) {
+                       return;
+               }
+
+               try {
+                       await copyCodeToClipboard(info.rawCode);
+               } catch (error) {
+                       console.error('Failed to copy code:', error);
+               }
+       }
+
+       function handlePreviewClick(event: Event) {
+               event.preventDefault();
+               event.stopPropagation();
+
+               const target = event.currentTarget as HTMLButtonElement | null;
+
+               if (!target) {
+                       return;
+               }
+
+               const info = getCodeInfoFromTarget(target);
+
+               if (!info) {
+                       return;
+               }
+
+               previewCode = info.rawCode;
+               previewLanguage = info.language;
+               previewDialogOpen = true;
+       }
+
+       function setupCodeBlockActions() {
+               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);
+                       }
+               }
+       }
+
+       function handlePreviewDialogOpenChange(open: boolean) {
+               previewDialogOpen = open;
+
+               if (!open) {
+                       previewCode = '';
+                       previewLanguage = 'text';
                }
        }
 
 
        $effect(() => {
                if (containerRef && processedHtml) {
-                       setupCopyButtons();
+                       setupCodeBlockActions();
                }
        });
 </script>
        {@html processedHtml}
 </div>
 
+<CodePreviewDialog
+       open={previewDialogOpen}
+       code={previewCode}
+       language={previewLanguage}
+       onOpenChange={handlePreviewDialogOpenChange}
+/>
+
 <style>
        /* Base typography styles */
        div :global(p:not(:last-child)) {
                letter-spacing: 0.05em;
        }
 
-       div :global(.copy-code-btn) {
+       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;
                transition: all 0.2s ease;
        }
 
-       div :global(.copy-code-btn:hover) {
+       div :global(.copy-code-btn:hover),
+       div :global(.preview-code-btn:hover) {
                transform: scale(1.05);
        }
 
-       div :global(.copy-code-btn:active) {
+       div :global(.copy-code-btn:active),
+       div :global(.preview-code-btn:active) {
                transform: scale(0.95);
        }