Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Next Next commit
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
  • Loading branch information
ServeurpersoCom committed Nov 1, 2025
commit 246d0717d3c6cb5be23297c81db8ecf3695bc079
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';

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) {
if (open) {
iframeRef.srcdoc = code;
} else {
iframeRef.srcdoc = '';
}
}
});

function handleOpenChange(nextOpen: boolean) {
open = nextOpen;
onOpenChange?.(nextOpen);
}
</script>

<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content class="max-w-[calc(100%-1rem)] sm:max-w-4xl md:max-w-5xl">
<Dialog.Header>
<Dialog.Title>HTML Preview</Dialog.Title>
</Dialog.Header>

<div class="preview-container mt-2">
<iframe
bind:this={iframeRef}
title={`Preview ${language}`}
sandbox="allow-scripts"
class="h-[70vh] w-full rounded-md border border-border/40 bg-background"
></iframe>
</div>
</Dialog.Content>
</Dialog.Root>

<style>
.preview-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>
182 changes: 140 additions & 42 deletions tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,11 @@
let containerRef = $state<HTMLDivElement>();
let processedHtml = $state('');
let previewDialogOpen = $state(false);
let previewCode = $state('');
let previewLanguage = $state('text');
const previewableLanguages = new Set(['html', 'htm', 'javascript', 'js', 'svelte']);
function loadHighlightTheme(isDark: boolean) {
if (!browser) return;
Expand Down Expand Up @@ -117,9 +123,11 @@
const rawCode = codeElement.textContent || '';
const codeId = `code-${Date.now()}-${index}`;
const normalizedLanguage = language.toLowerCase();
codeElement.setAttribute('data-code-id', codeId);
codeElement.setAttribute('data-raw-code', rawCode);
codeElement.setAttribute('data-language', normalizedLanguage);
const wrapper = document.createElement('div');
wrapper.className = 'code-block-wrapper';
Expand All @@ -138,11 +146,30 @@
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 (previewableLanguages.has(normalizedLanguage)) {
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;
Expand Down Expand Up @@ -180,49 +207,104 @@
}
}
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 language = codeElement.getAttribute('data-language') || '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';
}
}
Expand All @@ -243,7 +325,7 @@
$effect(() => {
if (containerRef && processedHtml) {
setupCopyButtons();
setupCodeBlockActions();
}
});
</script>
Expand All @@ -253,6 +335,13 @@
{@html processedHtml}
</div>

<CodePreviewDialog
open={previewDialogOpen}
code={previewCode}
language={previewLanguage}
onOpenChange={handlePreviewDialogOpenChange}
/>

<style>
/* Base typography styles */
div :global(p:not(:last-child)) {
Expand Down Expand Up @@ -472,7 +561,14 @@
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;
Expand All @@ -483,11 +579,13 @@
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);
}
Expand Down