mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2025-11-10 10:27:03 +00:00
webui: add HTML/JS preview support to MarkdownContent with sandboxed iframe (#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 <aleksander.grygier@gmail.com> * Update tools/server/webui/src/lib/components/app/misc/MarkdownContent.svelte Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com> * webui: pedantic style tweak for CodePreviewDialog close button * webui: remove overengineered preview language logic * chore: update webui static build --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
This commit is contained in:
Binary file not shown.
@@ -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>
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
|
import { remarkLiteralHtml } from '$lib/markdown/literal-html';
|
||||||
|
import CodePreviewDialog from './CodePreviewDialog.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -25,6 +26,9 @@
|
|||||||
|
|
||||||
let containerRef = $state<HTMLDivElement>();
|
let containerRef = $state<HTMLDivElement>();
|
||||||
let processedHtml = $state('');
|
let processedHtml = $state('');
|
||||||
|
let previewDialogOpen = $state(false);
|
||||||
|
let previewCode = $state('');
|
||||||
|
let previewLanguage = $state('text');
|
||||||
|
|
||||||
function loadHighlightTheme(isDark: boolean) {
|
function loadHighlightTheme(isDark: boolean) {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
@@ -117,7 +121,6 @@
|
|||||||
|
|
||||||
const rawCode = codeElement.textContent || '';
|
const rawCode = codeElement.textContent || '';
|
||||||
const codeId = `code-${Date.now()}-${index}`;
|
const codeId = `code-${Date.now()}-${index}`;
|
||||||
|
|
||||||
codeElement.setAttribute('data-code-id', codeId);
|
codeElement.setAttribute('data-code-id', codeId);
|
||||||
codeElement.setAttribute('data-raw-code', rawCode);
|
codeElement.setAttribute('data-raw-code', rawCode);
|
||||||
|
|
||||||
@@ -141,8 +144,27 @@
|
|||||||
<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(languageLabel);
|
||||||
header.appendChild(copyButton);
|
header.appendChild(actions);
|
||||||
wrapper.appendChild(header);
|
wrapper.appendChild(header);
|
||||||
|
|
||||||
const clonedPre = pre.cloneNode(true) as HTMLElement;
|
const clonedPre = pre.cloneNode(true) as HTMLElement;
|
||||||
@@ -180,49 +202,105 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupCopyButtons() {
|
function getCodeInfoFromTarget(target: HTMLElement) {
|
||||||
if (!containerRef) return;
|
|
||||||
|
|
||||||
const copyButtons = containerRef.querySelectorAll('.copy-code-btn');
|
|
||||||
|
|
||||||
for (const button of copyButtons) {
|
|
||||||
button.addEventListener('click', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
const target = e.currentTarget as HTMLButtonElement;
|
|
||||||
const codeId = target.getAttribute('data-code-id');
|
|
||||||
|
|
||||||
if (!codeId) {
|
|
||||||
console.error('No code ID found on button');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the code element within the same wrapper
|
|
||||||
const wrapper = target.closest('.code-block-wrapper');
|
const wrapper = target.closest('.code-block-wrapper');
|
||||||
|
|
||||||
if (!wrapper) {
|
if (!wrapper) {
|
||||||
console.error('No wrapper found');
|
console.error('No wrapper found');
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeElement = wrapper.querySelector('code[data-code-id]');
|
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
|
||||||
|
|
||||||
if (!codeElement) {
|
if (!codeElement) {
|
||||||
console.error('No code element found in wrapper');
|
console.error('No code element found in wrapper');
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawCode = codeElement.getAttribute('data-raw-code');
|
const rawCode = codeElement.getAttribute('data-raw-code');
|
||||||
if (!rawCode) {
|
|
||||||
|
if (rawCode === null) {
|
||||||
console.error('No raw code found');
|
console.error('No raw code found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
|
||||||
|
const language = languageLabel?.textContent?.trim() || 'text';
|
||||||
|
|
||||||
|
return { rawCode, language };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await copyCodeToClipboard(rawCode);
|
await copyCodeToClipboard(info.rawCode);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy code:', 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +321,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (containerRef && processedHtml) {
|
if (containerRef && processedHtml) {
|
||||||
setupCopyButtons();
|
setupCodeBlockActions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -253,6 +331,13 @@
|
|||||||
{@html processedHtml}
|
{@html processedHtml}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CodePreviewDialog
|
||||||
|
open={previewDialogOpen}
|
||||||
|
code={previewCode}
|
||||||
|
language={previewLanguage}
|
||||||
|
onOpenChange={handlePreviewDialogOpenChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Base typography styles */
|
/* Base typography styles */
|
||||||
div :global(p:not(:last-child)) {
|
div :global(p:not(:last-child)) {
|
||||||
@@ -472,7 +557,14 @@
|
|||||||
letter-spacing: 0.05em;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -483,11 +575,13 @@
|
|||||||
transition: all 0.2s ease;
|
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);
|
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);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user