Skip to content

Commit e4a7159

Browse files
webui: add HTML/JS preview support to MarkdownContent with sandboxed iframe (ggml-org#16757)
* 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 <[email protected]> * Update tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte Co-authored-by: Aleksander Grygier <[email protected]> * webui: pedantic style tweak for CodePreviewDialog close button * webui: remove overengineered preview language logic * chore: update webui static build --------- Co-authored-by: Aleksander Grygier <[email protected]>
1 parent dd5e8ca commit e4a7159

File tree

3 files changed

+230
-43
lines changed

3 files changed

+230
-43
lines changed

tools/server/public/index.html.gz

-58 Bytes
Binary file not shown.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<script lang="ts">
2+
import { Dialog as DialogPrimitive } from 'bits-ui';
3+
import XIcon from '@lucide/svelte/icons/x';
4+
5+
interface Props {
6+
open: boolean;
7+
code: string;
8+
language: string;
9+
onOpenChange?: (open: boolean) => void;
10+
}
11+
12+
let { open = $bindable(), code, language, onOpenChange }: Props = $props();
13+
14+
let iframeRef = $state<HTMLIFrameElement | null>(null);
15+
16+
$effect(() => {
17+
if (!iframeRef) return;
18+
19+
if (open) {
20+
iframeRef.srcdoc = code;
21+
} else {
22+
iframeRef.srcdoc = '';
23+
}
24+
});
25+
26+
function handleOpenChange(nextOpen: boolean) {
27+
open = nextOpen;
28+
onOpenChange?.(nextOpen);
29+
}
30+
</script>
31+
32+
<DialogPrimitive.Root {open} onOpenChange={handleOpenChange}>
33+
<DialogPrimitive.Portal>
34+
<DialogPrimitive.Overlay class="code-preview-overlay" />
35+
36+
<DialogPrimitive.Content class="code-preview-content">
37+
<iframe
38+
bind:this={iframeRef}
39+
title="Preview {language}"
40+
sandbox="allow-scripts"
41+
class="code-preview-iframe"
42+
></iframe>
43+
44+
<DialogPrimitive.Close
45+
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"
46+
aria-label="Close preview"
47+
>
48+
<XIcon />
49+
<span class="sr-only">Close preview</span>
50+
</DialogPrimitive.Close>
51+
</DialogPrimitive.Content>
52+
</DialogPrimitive.Portal>
53+
</DialogPrimitive.Root>
54+
55+
<style lang="postcss">
56+
:global(.code-preview-overlay) {
57+
position: fixed;
58+
inset: 0;
59+
background-color: transparent;
60+
z-index: 100000;
61+
}
62+
63+
:global(.code-preview-content) {
64+
position: fixed;
65+
inset: 0;
66+
top: 0 !important;
67+
left: 0 !important;
68+
width: 100dvw;
69+
height: 100dvh;
70+
margin: 0;
71+
padding: 0;
72+
border: none;
73+
border-radius: 0;
74+
background-color: transparent;
75+
box-shadow: none;
76+
display: block;
77+
overflow: hidden;
78+
transform: none !important;
79+
z-index: 100001;
80+
}
81+
82+
:global(.code-preview-iframe) {
83+
display: block;
84+
width: 100dvw;
85+
height: 100dvh;
86+
border: 0;
87+
}
88+
89+
:global(.code-preview-close) {
90+
position: absolute;
91+
z-index: 100002;
92+
}
93+
</style>

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

Lines changed: 137 additions & 43 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,9 @@
2526
2627
let containerRef = $state<HTMLDivElement>();
2728
let processedHtml = $state('');
29+
let previewDialogOpen = $state(false);
30+
let previewCode = $state('');
31+
let previewLanguage = $state('text');
2832
2933
function loadHighlightTheme(isDark: boolean) {
3034
if (!browser) return;
@@ -117,7 +121,6 @@
117121
118122
const rawCode = codeElement.textContent || '';
119123
const codeId = `code-${Date.now()}-${index}`;
120-
121124
codeElement.setAttribute('data-code-id', codeId);
122125
codeElement.setAttribute('data-raw-code', rawCode);
123126
@@ -138,11 +141,30 @@
138141
copyButton.setAttribute('type', 'button');
139142
140143
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-
`;
144+
<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>
145+
`;
146+
147+
const actions = document.createElement('div');
148+
actions.className = 'code-block-actions';
149+
150+
actions.appendChild(copyButton);
151+
152+
if (language.toLowerCase() === 'html') {
153+
const previewButton = document.createElement('button');
154+
previewButton.className = 'preview-code-btn';
155+
previewButton.setAttribute('data-code-id', codeId);
156+
previewButton.setAttribute('title', 'Preview code');
157+
previewButton.setAttribute('type', 'button');
158+
159+
previewButton.innerHTML = `
160+
<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>
161+
`;
162+
163+
actions.appendChild(previewButton);
164+
}
143165
144166
header.appendChild(languageLabel);
145-
header.appendChild(copyButton);
167+
header.appendChild(actions);
146168
wrapper.appendChild(header);
147169
148170
const clonedPre = pre.cloneNode(true) as HTMLElement;
@@ -180,49 +202,105 @@
180202
}
181203
}
182204
183-
function setupCopyButtons() {
184-
if (!containerRef) return;
205+
function getCodeInfoFromTarget(target: HTMLElement) {
206+
const wrapper = target.closest('.code-block-wrapper');
185207
186-
const copyButtons = containerRef.querySelectorAll('.copy-code-btn');
208+
if (!wrapper) {
209+
console.error('No wrapper found');
210+
return null;
211+
}
187212
188-
for (const button of copyButtons) {
189-
button.addEventListener('click', async (e) => {
190-
e.preventDefault();
191-
e.stopPropagation();
213+
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
192214
193-
const target = e.currentTarget as HTMLButtonElement;
194-
const codeId = target.getAttribute('data-code-id');
215+
if (!codeElement) {
216+
console.error('No code element found in wrapper');
217+
return null;
218+
}
195219
196-
if (!codeId) {
197-
console.error('No code ID found on button');
198-
return;
199-
}
220+
const rawCode = codeElement.getAttribute('data-raw-code');
200221
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-
}
222+
if (rawCode === null) {
223+
console.error('No raw code found');
224+
return null;
225+
}
207226
208-
const codeElement = wrapper.querySelector('code[data-code-id]');
209-
if (!codeElement) {
210-
console.error('No code element found in wrapper');
211-
return;
212-
}
227+
const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
228+
const language = languageLabel?.textContent?.trim() || 'text';
213229
214-
const rawCode = codeElement.getAttribute('data-raw-code');
215-
if (!rawCode) {
216-
console.error('No raw code found');
217-
return;
218-
}
230+
return { rawCode, language };
231+
}
219232
220-
try {
221-
await copyCodeToClipboard(rawCode);
222-
} catch (error) {
223-
console.error('Failed to copy code:', error);
224-
}
225-
});
233+
async function handleCopyClick(event: Event) {
234+
event.preventDefault();
235+
event.stopPropagation();
236+
237+
const target = event.currentTarget as HTMLButtonElement | null;
238+
239+
if (!target) {
240+
return;
241+
}
242+
243+
const info = getCodeInfoFromTarget(target);
244+
245+
if (!info) {
246+
return;
247+
}
248+
249+
try {
250+
await copyCodeToClipboard(info.rawCode);
251+
} catch (error) {
252+
console.error('Failed to copy code:', error);
253+
}
254+
}
255+
256+
function handlePreviewClick(event: Event) {
257+
event.preventDefault();
258+
event.stopPropagation();
259+
260+
const target = event.currentTarget as HTMLButtonElement | null;
261+
262+
if (!target) {
263+
return;
264+
}
265+
266+
const info = getCodeInfoFromTarget(target);
267+
268+
if (!info) {
269+
return;
270+
}
271+
272+
previewCode = info.rawCode;
273+
previewLanguage = info.language;
274+
previewDialogOpen = true;
275+
}
276+
277+
function setupCodeBlockActions() {
278+
if (!containerRef) return;
279+
280+
const wrappers = containerRef.querySelectorAll<HTMLElement>('.code-block-wrapper');
281+
282+
for (const wrapper of wrappers) {
283+
const copyButton = wrapper.querySelector<HTMLButtonElement>('.copy-code-btn');
284+
const previewButton = wrapper.querySelector<HTMLButtonElement>('.preview-code-btn');
285+
286+
if (copyButton && copyButton.dataset.listenerBound !== 'true') {
287+
copyButton.dataset.listenerBound = 'true';
288+
copyButton.addEventListener('click', handleCopyClick);
289+
}
290+
291+
if (previewButton && previewButton.dataset.listenerBound !== 'true') {
292+
previewButton.dataset.listenerBound = 'true';
293+
previewButton.addEventListener('click', handlePreviewClick);
294+
}
295+
}
296+
}
297+
298+
function handlePreviewDialogOpenChange(open: boolean) {
299+
previewDialogOpen = open;
300+
301+
if (!open) {
302+
previewCode = '';
303+
previewLanguage = 'text';
226304
}
227305
}
228306
@@ -243,7 +321,7 @@
243321
244322
$effect(() => {
245323
if (containerRef && processedHtml) {
246-
setupCopyButtons();
324+
setupCodeBlockActions();
247325
}
248326
});
249327
</script>
@@ -253,6 +331,13 @@
253331
{@html processedHtml}
254332
</div>
255333

334+
<CodePreviewDialog
335+
open={previewDialogOpen}
336+
code={previewCode}
337+
language={previewLanguage}
338+
onOpenChange={handlePreviewDialogOpenChange}
339+
/>
340+
256341
<style>
257342
/* Base typography styles */
258343
div :global(p:not(:last-child)) {
@@ -472,7 +557,14 @@
472557
letter-spacing: 0.05em;
473558
}
474559
475-
div :global(.copy-code-btn) {
560+
div :global(.code-block-actions) {
561+
display: flex;
562+
align-items: center;
563+
gap: 0.5rem;
564+
}
565+
566+
div :global(.copy-code-btn),
567+
div :global(.preview-code-btn) {
476568
display: flex;
477569
align-items: center;
478570
justify-content: center;
@@ -483,11 +575,13 @@
483575
transition: all 0.2s ease;
484576
}
485577
486-
div :global(.copy-code-btn:hover) {
578+
div :global(.copy-code-btn:hover),
579+
div :global(.preview-code-btn:hover) {
487580
transform: scale(1.05);
488581
}
489582
490-
div :global(.copy-code-btn:active) {
583+
div :global(.copy-code-btn:active),
584+
div :global(.preview-code-btn:active) {
491585
transform: scale(0.95);
492586
}
493587

0 commit comments

Comments
 (0)