Skip to content

Commit c7a9960

Browse files
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
1 parent 7530a09 commit c7a9960

File tree

2 files changed

+194
-42
lines changed

2 files changed

+194
-42
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<script lang="ts">
2+
import * as Dialog from '$lib/components/ui/dialog';
3+
4+
interface Props {
5+
open: boolean;
6+
code: string;
7+
language: string;
8+
onOpenChange?: (open: boolean) => void;
9+
}
10+
11+
let { open = $bindable(), code, language, onOpenChange }: Props = $props();
12+
13+
let iframeRef = $state<HTMLIFrameElement | null>(null);
14+
15+
$effect(() => {
16+
if (iframeRef) {
17+
if (open) {
18+
iframeRef.srcdoc = code;
19+
} else {
20+
iframeRef.srcdoc = '';
21+
}
22+
}
23+
});
24+
25+
function handleOpenChange(nextOpen: boolean) {
26+
open = nextOpen;
27+
onOpenChange?.(nextOpen);
28+
}
29+
</script>
30+
31+
<Dialog.Root {open} onOpenChange={handleOpenChange}>
32+
<Dialog.Content class="max-w-[calc(100%-1rem)] sm:max-w-4xl md:max-w-5xl">
33+
<Dialog.Header>
34+
<Dialog.Title>HTML Preview</Dialog.Title>
35+
</Dialog.Header>
36+
37+
<div class="preview-container mt-2">
38+
<iframe
39+
bind:this={iframeRef}
40+
title={`Preview ${language}`}
41+
sandbox="allow-scripts"
42+
class="h-[70vh] w-full rounded-md border border-border/40 bg-background"
43+
></iframe>
44+
</div>
45+
</Dialog.Content>
46+
</Dialog.Root>
47+
48+
<style>
49+
.preview-container {
50+
display: flex;
51+
flex-direction: column;
52+
gap: 0.75rem;
53+
}
54+
</style>

tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte

Lines changed: 140 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import githubLightCss from 'highlight.js/styles/github.css?inline';
1616
import { mode } from 'mode-watcher';
1717
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
18+
import CodePreviewDialog from './CodePreviewDialog.svelte';
1819
1920
interface Props {
2021
content: string;
@@ -25,6 +26,11 @@
2526
2627
let containerRef = $state<HTMLDivElement>();
2728
let processedHtml = $state('');
29+
let previewDialogOpen = $state(false);
30+
let previewCode = $state('');
31+
let previewLanguage = $state('text');
32+
33+
const previewableLanguages = new Set(['html', 'htm', 'javascript', 'js', 'svelte']);
2834
2935
function loadHighlightTheme(isDark: boolean) {
3036
if (!browser) return;
@@ -117,9 +123,11 @@
117123
118124
const rawCode = codeElement.textContent || '';
119125
const codeId = `code-${Date.now()}-${index}`;
126+
const normalizedLanguage = language.toLowerCase();
120127
121128
codeElement.setAttribute('data-code-id', codeId);
122129
codeElement.setAttribute('data-raw-code', rawCode);
130+
codeElement.setAttribute('data-language', normalizedLanguage);
123131
124132
const wrapper = document.createElement('div');
125133
wrapper.className = 'code-block-wrapper';
@@ -138,11 +146,30 @@
138146
copyButton.setAttribute('type', 'button');
139147
140148
copyButton.innerHTML = `
141-
<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>
142-
`;
149+
<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>
150+
`;
151+
152+
const actions = document.createElement('div');
153+
actions.className = 'code-block-actions';
154+
155+
actions.appendChild(copyButton);
156+
157+
if (previewableLanguages.has(normalizedLanguage)) {
158+
const previewButton = document.createElement('button');
159+
previewButton.className = 'preview-code-btn';
160+
previewButton.setAttribute('data-code-id', codeId);
161+
previewButton.setAttribute('title', 'Preview code');
162+
previewButton.setAttribute('type', 'button');
163+
164+
previewButton.innerHTML = `
165+
<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>
166+
`;
167+
168+
actions.appendChild(previewButton);
169+
}
143170
144171
header.appendChild(languageLabel);
145-
header.appendChild(copyButton);
172+
header.appendChild(actions);
146173
wrapper.appendChild(header);
147174
148175
const clonedPre = pre.cloneNode(true) as HTMLElement;
@@ -180,49 +207,104 @@
180207
}
181208
}
182209
183-
function setupCopyButtons() {
184-
if (!containerRef) return;
210+
function getCodeInfoFromTarget(target: HTMLElement) {
211+
const wrapper = target.closest('.code-block-wrapper');
185212
186-
const copyButtons = containerRef.querySelectorAll('.copy-code-btn');
213+
if (!wrapper) {
214+
console.error('No wrapper found');
215+
return null;
216+
}
187217
188-
for (const button of copyButtons) {
189-
button.addEventListener('click', async (e) => {
190-
e.preventDefault();
191-
e.stopPropagation();
218+
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
192219
193-
const target = e.currentTarget as HTMLButtonElement;
194-
const codeId = target.getAttribute('data-code-id');
220+
if (!codeElement) {
221+
console.error('No code element found in wrapper');
222+
return null;
223+
}
195224
196-
if (!codeId) {
197-
console.error('No code ID found on button');
198-
return;
199-
}
225+
const rawCode = codeElement.getAttribute('data-raw-code');
200226
201-
// Find the code element within the same wrapper
202-
const wrapper = target.closest('.code-block-wrapper');
203-
if (!wrapper) {
204-
console.error('No wrapper found');
205-
return;
206-
}
227+
if (rawCode === null) {
228+
console.error('No raw code found');
229+
return null;
230+
}
207231
208-
const codeElement = wrapper.querySelector('code[data-code-id]');
209-
if (!codeElement) {
210-
console.error('No code element found in wrapper');
211-
return;
212-
}
232+
const language = codeElement.getAttribute('data-language') || 'text';
213233
214-
const rawCode = codeElement.getAttribute('data-raw-code');
215-
if (!rawCode) {
216-
console.error('No raw code found');
217-
return;
218-
}
234+
return { rawCode, language };
235+
}
219236
220-
try {
221-
await copyCodeToClipboard(rawCode);
222-
} catch (error) {
223-
console.error('Failed to copy code:', error);
224-
}
225-
});
237+
async function handleCopyClick(event: Event) {
238+
event.preventDefault();
239+
event.stopPropagation();
240+
241+
const target = event.currentTarget as HTMLButtonElement | null;
242+
243+
if (!target) {
244+
return;
245+
}
246+
247+
const info = getCodeInfoFromTarget(target);
248+
249+
if (!info) {
250+
return;
251+
}
252+
253+
try {
254+
await copyCodeToClipboard(info.rawCode);
255+
} catch (error) {
256+
console.error('Failed to copy code:', error);
257+
}
258+
}
259+
260+
function handlePreviewClick(event: Event) {
261+
event.preventDefault();
262+
event.stopPropagation();
263+
264+
const target = event.currentTarget as HTMLButtonElement | null;
265+
266+
if (!target) {
267+
return;
268+
}
269+
270+
const info = getCodeInfoFromTarget(target);
271+
272+
if (!info) {
273+
return;
274+
}
275+
276+
previewCode = info.rawCode;
277+
previewLanguage = info.language;
278+
previewDialogOpen = true;
279+
}
280+
281+
function setupCodeBlockActions() {
282+
if (!containerRef) return;
283+
284+
const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
285+
286+
for (const wrapper of wrappers) {
287+
const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
288+
const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
289+
290+
if (copyButton && copyButton.dataset.listenerBound !== 'true') {
291+
copyButton.dataset.listenerBound = 'true';
292+
copyButton.addEventListener('click', handleCopyClick);
293+
}
294+
295+
if (previewButton && previewButton.dataset.listenerBound !== 'true') {
296+
previewButton.dataset.listenerBound = 'true';
297+
previewButton.addEventListener('click', handlePreviewClick);
298+
}
299+
}
300+
}
301+
302+
function handlePreviewDialogOpenChange(open: boolean) {
303+
previewDialogOpen = open;
304+
305+
if (!open) {
306+
previewCode = '';
307+
previewLanguage = 'text';
226308
}
227309
}
228310
@@ -243,7 +325,7 @@
243325
244326
$effect(() => {
245327
if (containerRef && processedHtml) {
246-
setupCopyButtons();
328+
setupCodeBlockActions();
247329
}
248330
});
249331
</script>
@@ -253,6 +335,13 @@
253335
{@html processedHtml}
254336
</div>
255337

338+
<CodePreviewDialog
339+
open={previewDialogOpen}
340+
code={previewCode}
341+
language={previewLanguage}
342+
onOpenChange={handlePreviewDialogOpenChange}
343+
/>
344+
256345
<style>
257346
/* Base typography styles */
258347
div :global(p:not(:last-child)) {
@@ -472,7 +561,14 @@
472561
letter-spacing: 0.05em;
473562
}
474563
475-
div :global(.copy-code-btn) {
564+
div :global(.code-block-actions) {
565+
display: flex;
566+
align-items: center;
567+
gap: 0.5rem;
568+
}
569+
570+
div :global(.copy-code-btn),
571+
div :global(.preview-code-btn) {
476572
display: flex;
477573
align-items: center;
478574
justify-content: center;
@@ -483,11 +579,13 @@
483579
transition: all 0.2s ease;
484580
}
485581
486-
div :global(.copy-code-btn:hover) {
582+
div :global(.copy-code-btn:hover),
583+
div :global(.preview-code-btn:hover) {
487584
transform: scale(1.05);
488585
}
489586
490-
div :global(.copy-code-btn:active) {
587+
div :global(.copy-code-btn:active),
588+
div :global(.preview-code-btn:active) {
491589
transform: scale(0.95);
492590
}
493591

0 commit comments

Comments
 (0)