--- /dev/null
+<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>
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;
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);
}