ui: Linting & Formatting scripts (#26819)

This commit is contained in:
Aleksander Grygier
2026-08-10 08:38:37 +02:00
committed by GitHub
parent 1e396e72a8
commit 92d1bb0c99
538 changed files with 8806 additions and 6036 deletions
@@ -1,8 +1,8 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { cn } from '$lib/components/ui/utils';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import type { Snippet } from 'svelte';
import type { Component } from 'svelte';
@@ -21,17 +21,17 @@
}
let {
open = $bindable(false),
children,
class: className = '',
icon: IconComponent,
iconClass = ICON_CLASS_DEFAULT,
iconUrl = null,
title = '',
titleSnippet,
subtitle,
shimmerTitle = false,
onToggle,
children
open = $bindable(false),
shimmerTitle = false,
subtitle,
title = '',
titleSnippet
}: Props = $props();
function hideBrokenIcon(event: Event) {
@@ -21,17 +21,17 @@
}
let {
open = $bindable(false),
children,
class: className = '',
icon: IconComponent,
iconClass = ICON_CLASS_DEFAULT,
iconUrl = null,
title = '',
titleSnippet,
subtitle,
shimmerTitle = false,
onToggle,
children
open = $bindable(false),
shimmerTitle = false,
subtitle,
title = '',
titleSnippet
}: Props = $props();
function hideBrokenIcon(event: Event) {
@@ -1,85 +1,85 @@
<script lang="ts">
import '$styles/katex-custom.scss';
import {
getCodeInfoFromTarget,
getHastNodeId,
getMdastNodeHash,
isAppendMode
} from './markdown-utils';
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
import { rehypeFileBadge } from './plugins/rehype/file-badge';
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
import { rehypeSvgPre } from './plugins/rehype/svg-pre';
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
import { remarkLiteralHtml } from './plugins/remark/literal-html';
import { browser } from '$app/environment';
import {
ActionIconCopyToClipboard,
CodeBlockActions,
DialogCodePreview,
DialogMermaidPreview
} from '$lib/components/app';
import {
BOOL_TRUE_STRING,
CODE_BLOCK_HEADER_CLASS,
DATA_ERROR_BOUND_ATTR,
DATA_ERROR_HANDLED_ATTR,
DIAGRAM_VIEW_MODE_ATTR,
DIAGRAM_VIEW_RENDERED,
DIAGRAM_VIEW_SOURCE,
IMAGE_NOT_ERROR_BOUND_SELECTOR,
MERMAID_BLOCK_CLASS,
MERMAID_LANGUAGE,
MERMAID_RENDERED_ATTR,
MERMAID_SYNTAX_ATTR,
MERMAID_WRAPPER_CLASS,
SETTINGS_KEYS,
SVG_BLOCK_CLASS,
SVG_INLINE_SHADOW_STYLE,
SVG_LANGUAGE,
SVG_RENDERED_ATTR,
SVG_SOURCE_ATTR,
SVG_TAG_PREFIX,
SVG_WRAPPER_CLASS,
TOGGLE_SOURCE_BTN_CLASS,
XML_LANGUAGE
} from '$lib/constants';
import { ColorMode, UrlProtocol } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files.enums';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
import {
copyCodeToClipboard,
copyToClipboard,
getImageErrorFallbackHtml,
preprocessLaTeX,
splitGluedClosingCodeFences
} from '$lib/utils';
import { detectIncompleteCodeBlock, highlightCode, type IncompleteCodeBlock } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import { all as lowlightAll } from 'lowlight';
import type { Root as MdastRoot } from 'mdast';
import { mode } from 'mode-watcher';
import rehypeHighlight from 'rehype-highlight';
import rehypeKatex from 'rehype-katex';
import rehypeStringify from 'rehype-stringify';
import { remark } from 'remark';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeHighlight from 'rehype-highlight';
import { all as lowlightAll } from 'lowlight';
import remarkRehype from 'remark-rehype';
import rehypeKatex from 'rehype-katex';
import rehypeStringify from 'rehype-stringify';
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
import type { Root as MdastRoot } from 'mdast';
import { browser } from '$app/environment';
import { onDestroy, tick } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
import { rehypeFileBadge } from './plugins/rehype/file-badge';
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
import { rehypeSvgPre } from './plugins/rehype/svg-pre';
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
import { remarkLiteralHtml } from './plugins/remark/literal-html';
import {
getHastNodeId,
getMdastNodeHash,
isAppendMode,
getCodeInfoFromTarget
} from './markdown-utils';
import {
preprocessLaTeX,
getImageErrorFallbackHtml,
copyCodeToClipboard,
copyToClipboard,
splitGluedClosingCodeFences
} from '$lib/utils';
import {
IMAGE_NOT_ERROR_BOUND_SELECTOR,
DATA_ERROR_BOUND_ATTR,
DATA_ERROR_HANDLED_ATTR,
BOOL_TRUE_STRING,
SETTINGS_KEYS,
CODE_BLOCK_HEADER_CLASS,
MERMAID_WRAPPER_CLASS,
MERMAID_BLOCK_CLASS,
MERMAID_LANGUAGE,
MERMAID_SYNTAX_ATTR,
MERMAID_RENDERED_ATTR,
SVG_WRAPPER_CLASS,
SVG_BLOCK_CLASS,
SVG_LANGUAGE,
XML_LANGUAGE,
SVG_TAG_PREFIX,
SVG_SOURCE_ATTR,
SVG_RENDERED_ATTR,
SVG_INLINE_SHADOW_STYLE,
TOGGLE_SOURCE_BTN_CLASS,
DIAGRAM_VIEW_MODE_ATTR,
DIAGRAM_VIEW_RENDERED,
DIAGRAM_VIEW_SOURCE
} from '$lib/constants';
import { ColorMode, UrlProtocol } from '$lib/enums';
import { FileTypeText } from '$lib/enums/files.enums';
import { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import '$styles/katex-custom.scss';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { mode } from 'mode-watcher';
import {
CodeBlockActions,
DialogCodePreview,
DialogMermaidPreview,
ActionIconCopyToClipboard
} from '$lib/components/app';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { config } from '$lib/stores/settings.svelte';
interface Props {
attachments?: DatabaseMessageExtra[];
@@ -94,7 +94,7 @@
contentHash?: string;
}
let { content, attachments, class: className = '', disableMath = false }: Props = $props();
let { attachments, class: className = '', content, disableMath = false }: Props = $props();
let containerRef = $state<HTMLDivElement>();
let renderedBlocks = $state<MarkdownBlock[]>([]);
@@ -102,10 +102,14 @@
let incompleteCodeBlock = $state<IncompleteCodeBlock | null>(null);
const streamingSvgCode = $derived.by(() => {
const block = incompleteCodeBlock;
if (!block) return null;
if (block.language === SVG_LANGUAGE) return block.code;
if (block.language === XML_LANGUAGE && block.code.trimStart().startsWith(SVG_TAG_PREFIX))
return block.code;
return null;
});
const liveSvgHtml = $derived(streamingSvgCode !== null ? sanitizeSvg(streamingSvgCode) : '');
@@ -171,8 +175,8 @@
return proc
.use(rehypeHighlight, {
languages: lowlightAll,
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] }
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] },
languages: lowlightAll
}) // Add syntax highlighting
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
.use(rehypeEnhanceLinks) // Add target="_blank" to links
@@ -214,6 +218,7 @@
if (!browser) return;
const existingTheme = document.getElementById(themeStyleId);
existingTheme?.remove();
}
@@ -226,9 +231,11 @@
if (!browser) return;
const existingTheme = document.getElementById(themeStyleId);
existingTheme?.remove();
const style = document.createElement('style');
style.id = themeStyleId;
style.textContent = isDark ? githubDarkCss : githubLightCss;
@@ -254,19 +261,19 @@
index: number
): Promise<{ html: string; hash: string }> {
const hash = getMdastNodeHash(node, index);
const cached = transformCache.get(hash);
if (cached) {
return { html: cached, hash };
return { hash, html: cached };
}
const singleNodeRoot = { type: 'root', children: [node] };
const singleNodeRoot = { children: [node], type: 'root' };
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
const html = processorInstance.stringify(transformedRoot);
transformCache.set(hash, html);
return { html, hash };
return { hash, html };
}
/**
@@ -358,6 +365,7 @@
unstableBlockHtml = '';
incompleteCodeBlock = null;
previousContent = '';
return;
}
@@ -374,7 +382,6 @@
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const nextBlocks: MarkdownBlock[] = [];
// Check if we're in append mode for cache reuse
const appendMode = isAppendMode(prefixMarkdown, previousContent);
const previousBlockCount = appendMode ? renderedBlocks.length : 0;
@@ -396,13 +403,13 @@
}
// Transform this block (with caching)
const { html, hash } = await transformMdastNode(processorInstance, child, index);
const { hash, html } = await transformMdastNode(processorInstance, child, index);
const id = getHastNodeId(
{ position: (child as { position?: unknown }).position } as HastRootContent,
index
);
nextBlocks.push({ id, html, contentHash: hash });
nextBlocks.push({ contentHash: hash, html, id });
}
renderedBlocks = nextBlocks;
@@ -426,7 +433,6 @@
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
const stableCount = Math.max(mdastChildren.length - 1, 0);
const nextBlocks: MarkdownBlock[] = [];
// Check if we're in append mode for cache reuse
const appendMode = isAppendMode(markdown, previousContent);
const previousBlockCount = appendMode ? renderedBlocks.length : 0;
@@ -438,6 +444,7 @@
if (appendMode && index < previousBlockCount) {
const prevBlock = renderedBlocks[index];
const currentHash = getMdastNodeHash(child, index);
if (prevBlock?.contentHash === currentHash) {
nextBlocks.push(prevBlock);
@@ -446,20 +453,20 @@
}
// Transform this block (with caching)
const { html, hash } = await transformMdastNode(processorInstance, child, index);
const { hash, html } = await transformMdastNode(processorInstance, child, index);
const id = getHastNodeId(
{ position: (child as { position?: unknown }).position } as HastRootContent,
index
);
nextBlocks.push({ id, html, contentHash: hash });
nextBlocks.push({ contentHash: hash, html, id });
}
let unstableHtml = '';
if (mdastChildren.length > stableCount) {
const unstableChild = mdastChildren[stableCount];
const singleNodeRoot = { type: 'root', children: [unstableChild] };
const singleNodeRoot = { children: [unstableChild], type: 'root' };
const transformedRoot = (await processorInstance.run(
singleNodeRoot as MdastRoot
)) as HastRoot;
@@ -520,21 +527,24 @@
*/
async function handleMermaidClick(event: MouseEvent) {
const target = event.target as HTMLElement;
// Toggle a diagram block between its rendered view and its source view.
// Shared by mermaid and svg, css drives the visibility from the wrapper mode.
const toggleBtn = target.closest(`.${TOGGLE_SOURCE_BTN_CLASS}`);
if (toggleBtn) {
event.preventDefault();
event.stopPropagation();
const wrapper = toggleBtn.closest(`.${MERMAID_WRAPPER_CLASS}, .${SVG_WRAPPER_CLASS}`);
if (!wrapper) return;
const isSource = wrapper.getAttribute(DIAGRAM_VIEW_MODE_ATTR) === DIAGRAM_VIEW_SOURCE;
const next = isSource ? DIAGRAM_VIEW_RENDERED : DIAGRAM_VIEW_SOURCE;
wrapper.setAttribute(DIAGRAM_VIEW_MODE_ATTR, next);
toggleBtn.setAttribute('aria-pressed', String(!isSource));
return;
}
@@ -544,11 +554,13 @@
if (copyBtn || previewBtn) {
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
);
if (!preElement) return;
const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
@@ -561,6 +573,7 @@
} catch (error) {
console.error('Failed to copy mermaid syntax:', error);
}
return;
}
@@ -568,10 +581,13 @@
event.preventDefault();
event.stopPropagation();
const svg = preElement.querySelector('svg');
if (!svg) return;
mermaidPreviewSvgHtml = svg.outerHTML;
svgPreviewLive = false;
mermaidPreviewOpen = true;
return;
}
}
@@ -582,11 +598,13 @@
if (svgCopyBtn || svgPreviewBtn) {
const wrapper = target.closest(`.${SVG_WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${SVG_BLOCK_CLASS}[${SVG_SOURCE_ATTR}]`
);
if (!preElement) return;
if (svgCopyBtn) {
@@ -597,6 +615,7 @@
} catch (error) {
console.error('Failed to copy svg source:', error);
}
return;
}
@@ -606,6 +625,7 @@
mermaidPreviewSvgHtml = sanitizeSvg(preElement.getAttribute(SVG_SOURCE_ATTR) ?? '');
svgPreviewLive = false;
mermaidPreviewOpen = true;
return;
}
}
@@ -618,23 +638,29 @@
// Open preview when clicking the svg block itself. A final block carries its
// source, a streaming block does not and is mirrored live into the dialog.
const svgEl = target.closest(`.${SVG_BLOCK_CLASS}`);
if (svgEl) {
const source = svgEl.getAttribute(SVG_SOURCE_ATTR);
if (source !== null) {
mermaidPreviewSvgHtml = sanitizeSvg(source);
svgPreviewLive = false;
} else {
svgPreviewLive = true;
}
mermaidPreviewOpen = true;
return;
}
// Otherwise, open preview when clicking on the mermaid diagram itself
const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
if (!mermaidEl) return;
const svg = mermaidEl.querySelector('svg');
if (!svg) return;
mermaidPreviewSvgHtml = svg.outerHTML;
@@ -648,6 +674,7 @@
*/
function handleMermaidPreviewOpenChange(open: boolean) {
mermaidPreviewOpen = open;
if (!open) {
mermaidPreviewSvgHtml = '';
svgPreviewLive = false;
@@ -666,6 +693,7 @@
const nodes = containerRef.querySelectorAll(
`pre.${MERMAID_BLOCK_CLASS}:not([${MERMAID_RENDERED_ATTR}])`
);
if (nodes.length === 0) return;
// Mark nodes immediately to prevent duplicate renders if called again during streaming.
@@ -674,24 +702,23 @@
// Read mode before await so Svelte tracks it reactively.
const isDark = mode.current === ColorMode.DARK;
// lazy load the mermaid dependecy only when needed to reduce bundle size.
const { default: mermaid } = await import('mermaid');
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
securityLevel: 'strict',
flowchart: {
useMaxWidth: false,
htmlLabels: true
},
sequence: {
htmlLabels: true,
useMaxWidth: false
},
gantt: {
useMaxWidth: false
}
},
securityLevel: 'strict',
sequence: {
useMaxWidth: false
},
startOnLoad: false,
theme: isDark ? 'dark' : 'default'
});
try {
@@ -714,6 +741,7 @@
const nodes = containerRef.querySelectorAll<HTMLElement>(
`pre.${SVG_BLOCK_CLASS}:not([${SVG_RENDERED_ATTR}])`
);
if (nodes.length === 0) return;
nodes.forEach((node) => {
@@ -725,6 +753,7 @@
if (clean) {
node.textContent = '';
const host = document.createElement('div');
node.appendChild(host);
mountSvgShadow(host, clean, SVG_INLINE_SHADOW_STYLE);
}
@@ -737,6 +766,7 @@
*/
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
if (!img || !img.src) return;
// Don't handle data URLs or already-handled images
@@ -745,11 +775,13 @@
img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING
)
return;
img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING;
const src = img.src;
// Create fallback element
const fallback = document.createElement('div');
fallback.className = 'image-load-error';
fallback.innerHTML = getImageErrorFallbackHtml(src);
@@ -775,6 +807,7 @@
try {
while (pendingMarkdown !== null) {
const nextMarkdown = pendingMarkdown;
pendingMarkdown = null;
await processMarkdown(nextMarkdown);
@@ -3,8 +3,8 @@
* Uses dependency injection pattern to avoid direct component state access.
*/
import { MERMAID_BLOCK_CLASS, MERMAID_SYNTAX_ATTR, MERMAID_WRAPPER_CLASS } from '$lib/constants';
import { copyCodeToClipboard, copyToClipboard } from '$lib/utils';
import { MERMAID_WRAPPER_CLASS, MERMAID_BLOCK_CLASS, MERMAID_SYNTAX_ATTR } from '$lib/constants';
export interface PreviewState {
previewDialogOpen: boolean;
@@ -37,12 +37,15 @@ export function createHandleCopyClick() {
event.stopPropagation();
const target = event.currentTarget as HTMLButtonElement | null;
if (!target) return;
const wrapper = target.closest('.code-block-wrapper');
if (!wrapper) return;
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
if (!codeElement) return;
const rawCode = codeElement.textContent ?? '';
@@ -80,12 +83,15 @@ export function createHandlePreviewClick(previewState: PreviewState) {
event.stopPropagation();
const target = event.currentTarget as HTMLButtonElement | null;
if (!target) return;
const wrapper = target.closest('.code-block-wrapper');
if (!wrapper) return;
const codeElement = wrapper.querySelector<HTMLElement>('code[data-code-id]');
if (!codeElement) return;
const rawCode = codeElement.textContent ?? '';
@@ -105,18 +111,19 @@ export function createHandlePreviewClick(previewState: PreviewState) {
export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
return async function handleMermaidClick(event: MouseEvent) {
const target = event.target as HTMLElement;
// Check if clicking on copy or preview button in mermaid block
const copyBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .copy-code-btn`);
const previewBtn = target.closest(`.${MERMAID_WRAPPER_CLASS} .preview-code-btn`);
if (copyBtn || previewBtn) {
const wrapper = target.closest(`.${MERMAID_WRAPPER_CLASS}`);
if (!wrapper) return;
const preElement = wrapper.querySelector<HTMLElement>(
`pre.${MERMAID_BLOCK_CLASS}[${MERMAID_SYNTAX_ATTR}]`
);
if (!preElement) return;
const mermaidSyntax = preElement.getAttribute(MERMAID_SYNTAX_ATTR) ?? '';
@@ -129,6 +136,7 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
} catch (error) {
console.error('Failed to copy mermaid syntax:', error);
}
return;
}
@@ -136,18 +144,23 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
event.preventDefault();
event.stopPropagation();
const svg = preElement.querySelector('svg');
if (!svg) return;
mermaidState.setMermaidPreviewSvgHtml(svg.outerHTML);
mermaidState.setMermaidPreviewOpen(true);
return;
}
}
// Otherwise, open preview when clicking on the mermaid diagram itself
const mermaidEl = target.closest(`.${MERMAID_BLOCK_CLASS}`);
if (!mermaidEl) return;
const svg = mermaidEl.querySelector('svg');
if (!svg) return;
mermaidState.setMermaidPreviewSvgHtml(svg.outerHTML);
@@ -162,6 +175,7 @@ export function createHandleMermaidClick(mermaidState: MermaidPreviewState) {
export function createHandleMermaidPreviewOpenChange(mermaidState: MermaidPreviewState) {
return function handleMermaidPreviewOpenChange(open: boolean) {
mermaidState.setMermaidPreviewOpen(open);
if (!open) {
mermaidState.setMermaidPreviewSvgHtml('');
}
@@ -180,16 +194,20 @@ export function createHandleImageError(
) {
return async function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
if (!img) return;
const blockId = img.closest('[data-block-id]')?.getAttribute('data-block-id');
if (!blockId) return;
const block = renderedBlocksState.renderedBlocks.find((b) => b.id === blockId);
if (!block) return;
// Skip if already handled
if (img.dataset[DATA_ERROR_BOUND_ATTR] === BOOL_TRUE_STRING) return;
img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING;
// Get the fallback HTML and replace the image
@@ -197,19 +215,19 @@ export function createHandleImageError(
<span class="image-error-icon">⚠️</span>
<span class="image-error-text">Failed to load image</span>
</div>`;
// Replace the img element with fallback in the block's HTML
const newHtml = block.html.replace(/img[^>]*src=["']([^"']*)[^>]*>/g, (match, src) => {
if (src === img.src) {
return fallbackHtml.replace('data-original-src=""', `data-original-src="${src}"`);
}
return match;
});
// Update the block
const newBlocks = renderedBlocksState.renderedBlocks.map((b) =>
b.id === blockId ? { ...b, html: newHtml } : b
);
renderedBlocksState.setRenderedBlocks(newBlocks);
};
}
@@ -65,6 +65,7 @@ export function getCodeInfoFromTarget(target: HTMLElement): CodeInfo | null {
if (!wrapper) {
console.error('No wrapper found');
return null;
}
@@ -72,13 +73,13 @@ export function getCodeInfoFromTarget(target: HTMLElement): CodeInfo | null {
if (!codeElement) {
console.error('No code element found in wrapper');
return null;
}
const rawCode = codeElement.textContent ?? '';
const languageLabel = wrapper.querySelector<HTMLElement>('.code-language');
const language = languageLabel?.textContent?.trim() || 'text';
return { rawCode, language };
return { language, rawCode };
}
@@ -3,21 +3,21 @@
* Contains common HAST element creation functions to avoid code duplication.
*/
import type { Element, ElementContent } from 'hast';
import {
CODE_BLOCK_HEADER_CLASS,
CODE_BLOCK_ACTIONS_CLASS,
CODE_BLOCK_HEADER_CLASS,
CODE_BLOCK_SCROLL_CONTAINER_CLASS,
CODE_ICON_SVG,
CODE_LANGUAGE_CLASS,
COPY_CODE_BTN_CLASS,
PREVIEW_CODE_BTN_CLASS,
TOGGLE_SOURCE_BTN_CLASS,
DIAGRAM_SOURCE_CLASS,
RELATIVE_CLASS,
COPY_ICON_SVG,
DIAGRAM_SOURCE_CLASS,
PREVIEW_CODE_BTN_CLASS,
PREVIEW_ICON_SVG,
CODE_ICON_SVG
RELATIVE_CLASS,
TOGGLE_SOURCE_BTN_CLASS
} from '$lib/constants';
import type { Element, ElementContent } from 'hast';
export interface BlockIdGenerator {
(id: number): string;
@@ -28,10 +28,10 @@ export interface BlockIdGenerator {
*/
export function createIconElement(svg: string): Element {
return {
type: 'element',
tagName: 'span',
children: [{ type: 'raw', value: svg } as unknown as ElementContent],
properties: {},
children: [{ type: 'raw', value: svg } as unknown as ElementContent]
tagName: 'span',
type: 'element'
};
}
@@ -48,8 +48,7 @@ export function createButton(
extraProperties: Record<string, string> = {}
): Element {
return {
type: 'element',
tagName: 'button',
children: [createIconElement(iconSvg)],
properties: {
className: [className],
[idAttribute]: id,
@@ -57,7 +56,8 @@ export function createButton(
type: 'button',
...extraProperties
},
children: [createIconElement(iconSvg)]
tagName: 'button',
type: 'element'
};
}
@@ -105,23 +105,24 @@ export function createSourceView(
language: string
): Element {
const code: Element = codeElement ?? {
type: 'element',
tagName: 'code',
children: [{ type: 'text', value: source }],
properties: { className: ['hljs', `language-${language}`] },
children: [{ type: 'text', value: source }]
tagName: 'code',
type: 'element'
};
return {
type: 'element',
tagName: 'div',
properties: { className: [DIAGRAM_SOURCE_CLASS, CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
children: [
{
type: 'element',
tagName: 'pre',
children: [code],
properties: {},
children: [code]
tagName: 'pre',
type: 'element'
}
]
],
properties: { className: [DIAGRAM_SOURCE_CLASS, CODE_BLOCK_SCROLL_CONTAINER_CLASS] },
tagName: 'div',
type: 'element'
};
}
@@ -136,23 +137,23 @@ export function createBlockHeader(
languageClassName: string = CODE_LANGUAGE_CLASS
): Element {
return {
type: 'element',
tagName: 'div',
properties: { className: [CODE_BLOCK_HEADER_CLASS] },
children: [
{
type: 'element',
tagName: 'span',
children: [{ type: 'text', value: language }],
properties: { className: [languageClassName] },
children: [{ type: 'text', value: language }]
tagName: 'span',
type: 'element'
},
{
type: 'element',
tagName: 'div',
children: actions,
properties: { className: [CODE_BLOCK_ACTIONS_CLASS] },
children: actions
tagName: 'div',
type: 'element'
}
]
],
properties: { className: [CODE_BLOCK_HEADER_CLASS] },
tagName: 'div',
type: 'element'
};
}
@@ -161,10 +162,10 @@ export function createBlockHeader(
*/
export function createScrollContainer(preElement: Element, scrollContainerClass: string): Element {
return {
type: 'element',
tagName: 'div',
children: [preElement],
properties: { className: [scrollContainerClass] },
children: [preElement]
tagName: 'div',
type: 'element'
};
}
@@ -182,13 +183,13 @@ export function createWrapper(
extraChildren: Element[] = []
): Element {
return {
type: 'element',
tagName: 'div',
children: [header, createScrollContainer(preElement, scrollContainerClass), ...extraChildren],
properties: {
className: [wrapperClass, RELATIVE_CLASS],
...additionalAttributes
} as Element['properties'],
children: [header, createScrollContainer(preElement, scrollContainerClass), ...extraChildren]
tagName: 'div',
type: 'element'
};
}
@@ -199,9 +200,12 @@ export function generateBlockId(prefix: string, windowKey: keyof Window): string
if (typeof window !== 'undefined') {
const idx = window[windowKey] as number | undefined;
const next = (idx ?? 0) + 1;
(window as unknown as Record<string, number>)[windowKey] = next;
return `${prefix}-${next}`;
}
// Fallback for SSR - use timestamp + random
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
@@ -10,10 +10,6 @@
* avoiding the need to stringify and re-parse HTML.
*/
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent } from 'hast';
import { visit } from 'unist-util-visit';
import { CODE_BLOCK_SCROLL_CONTAINER_CLASS, CODE_BLOCK_WRAPPER_CLASS } from '$lib/constants';
import {
createBlockHeader,
createCopyButton,
@@ -21,6 +17,10 @@ import {
createWrapper,
generateBlockId
} from './code-block-utils';
import { CODE_BLOCK_SCROLL_CONTAINER_CLASS, CODE_BLOCK_WRAPPER_CLASS } from '$lib/constants';
import type { Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
declare global {
interface Window {
@@ -30,6 +30,7 @@ declare global {
function extractLanguage(codeElement: Element): string {
const className = codeElement.properties?.className;
if (!Array.isArray(className)) return 'text';
for (const cls of className) {
@@ -5,8 +5,8 @@
* ensuring external links open in new tabs safely.
*/
import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
/**
@@ -10,29 +10,29 @@
* avoiding the need to stringify and re-parse HTML.
*/
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent } from 'hast';
import { visit } from 'unist-util-visit';
import {
MERMAID_WRAPPER_CLASS,
MERMAID_SCROLL_CONTAINER_CLASS,
MERMAID_BLOCK_CLASS,
MERMAID_LANGUAGE,
MERMAID_SYNTAX_ATTR,
MERMAID_ID_ATTR,
DIAGRAM_VIEW_MODE_ATTR,
DIAGRAM_VIEW_RENDERED
} from '$lib/constants';
import type { DiagramPreData } from './pre-transform';
import {
createBlockHeader,
createCopyButton,
createPreviewButton,
createToggleSourceButton,
createSourceView,
createToggleSourceButton,
createWrapper,
generateBlockId
} from './code-block-utils';
import type { DiagramPreData } from './pre-transform';
import {
DIAGRAM_VIEW_MODE_ATTR,
DIAGRAM_VIEW_RENDERED,
MERMAID_BLOCK_CLASS,
MERMAID_ID_ATTR,
MERMAID_LANGUAGE,
MERMAID_SCROLL_CONTAINER_CLASS,
MERMAID_SYNTAX_ATTR,
MERMAID_WRAPPER_CLASS
} from '$lib/constants';
import type { Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
declare global {
interface Window {
@@ -53,6 +53,7 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
if (node.tagName !== 'pre' || !parent || index === undefined) return;
const className = node.properties?.className;
if (!Array.isArray(className)) return;
const isMermaid = className.some(
@@ -62,11 +63,11 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
if (!isMermaid) return;
const mermaidId = generateBlockId(MERMAID_LANGUAGE, 'idxMermaidBlock');
// Extract the mermaid syntax (text content of the pre element)
const diagramText = node.children
.map((child) => {
if (child.type === 'text') return child.value;
return '';
})
.join('');
@@ -74,8 +75,8 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
// Store the mermaid syntax in data attribute for copy functionality
node.properties = {
...node.properties,
[MERMAID_SYNTAX_ATTR]: diagramText,
[MERMAID_ID_ATTR]: mermaidId
[MERMAID_ID_ATTR]: mermaidId,
[MERMAID_SYNTAX_ATTR]: diagramText
};
const actions = [
@@ -83,7 +84,6 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
createToggleSourceButton(mermaidId, MERMAID_ID_ATTR, 'Toggle mermaid source'),
createPreviewButton(mermaidId, MERMAID_ID_ATTR, 'Preview diagram')
];
const header = createBlockHeader(MERMAID_LANGUAGE, mermaidId, MERMAID_ID_ATTR, actions);
const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode;
const sourceView = createSourceView(preservedCode, diagramText, MERMAID_LANGUAGE);
@@ -93,8 +93,8 @@ export const rehypeEnhanceMermaidBlocks: Plugin<[], Root> = () => {
MERMAID_WRAPPER_CLASS,
MERMAID_SCROLL_CONTAINER_CLASS,
{
[MERMAID_ID_ATTR]: mermaidId,
[DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED
[DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED,
[MERMAID_ID_ATTR]: mermaidId
},
[sourceView]
);
@@ -9,29 +9,29 @@
* Operates directly on the HAST tree and reuses the shared code-block builders.
*/
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent } from 'hast';
import { visit } from 'unist-util-visit';
import {
SVG_WRAPPER_CLASS,
SVG_SCROLL_CONTAINER_CLASS,
SVG_BLOCK_CLASS,
SVG_LANGUAGE,
SVG_SOURCE_ATTR,
SVG_ID_ATTR,
DIAGRAM_VIEW_MODE_ATTR,
DIAGRAM_VIEW_RENDERED
} from '$lib/constants';
import type { DiagramPreData } from './pre-transform';
import {
createBlockHeader,
createCopyButton,
createPreviewButton,
createToggleSourceButton,
createSourceView,
createToggleSourceButton,
createWrapper,
generateBlockId
} from './code-block-utils';
import type { DiagramPreData } from './pre-transform';
import {
DIAGRAM_VIEW_MODE_ATTR,
DIAGRAM_VIEW_RENDERED,
SVG_BLOCK_CLASS,
SVG_ID_ATTR,
SVG_LANGUAGE,
SVG_SCROLL_CONTAINER_CLASS,
SVG_SOURCE_ATTR,
SVG_WRAPPER_CLASS
} from '$lib/constants';
import type { Element, ElementContent, Root } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
declare global {
interface Window {
@@ -45,6 +45,7 @@ export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
if (node.tagName !== 'pre' || !parent || index === undefined) return;
const className = node.properties?.className;
if (!Array.isArray(className)) return;
const isSvg = className.some((cls) => typeof cls === 'string' && cls === SVG_BLOCK_CLASS);
@@ -52,11 +53,11 @@ export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
if (!isSvg) return;
const svgId = generateBlockId(SVG_LANGUAGE, 'idxSvgBlock');
// Extract the svg source (text content of the pre element)
const svgSource = node.children
.map((child) => {
if (child.type === 'text') return child.value;
return '';
})
.join('');
@@ -64,8 +65,8 @@ export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
// Store the svg source in data attribute for copy and render
node.properties = {
...node.properties,
[SVG_SOURCE_ATTR]: svgSource,
[SVG_ID_ATTR]: svgId
[SVG_ID_ATTR]: svgId,
[SVG_SOURCE_ATTR]: svgSource
};
const actions = [
@@ -73,7 +74,6 @@ export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
createToggleSourceButton(svgId, SVG_ID_ATTR, 'Toggle svg source'),
createPreviewButton(svgId, SVG_ID_ATTR, 'Preview svg')
];
const header = createBlockHeader(SVG_LANGUAGE, svgId, SVG_ID_ATTR, actions);
const preservedCode = (node.data as DiagramPreData | undefined)?.sourceCode;
const sourceView = createSourceView(preservedCode, svgSource, SVG_LANGUAGE);
@@ -83,8 +83,8 @@ export const rehypeEnhanceSvgBlocks: Plugin<[], Root> = () => {
SVG_WRAPPER_CLASS,
SVG_SCROLL_CONTAINER_CLASS,
{
[SVG_ID_ATTR]: svgId,
[DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED
[DIAGRAM_VIEW_MODE_ATTR]: DIAGRAM_VIEW_RENDERED,
[SVG_ID_ATTR]: svgId
},
[sourceView]
);
@@ -8,7 +8,6 @@
* no tab stop); the full path stays available on `title`.
*/
import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
import {
FILE_URI_PREFIX,
MENTION_BADGE_CLASSNAME,
@@ -19,8 +18,9 @@ import {
} from '$lib/constants';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
// Trailing path separators mark a directory and are kept out of the label.
@@ -28,6 +28,7 @@ const TRAILING_SEPARATOR_REGEX = /\/+$/;
function decodeHrefPath(href: string): string {
const stripped = href.startsWith(FILE_URI_PREFIX) ? href.slice(FILE_URI_PREFIX.length) : href;
return decodeFileLinkPath(stripped);
}
@@ -35,6 +36,7 @@ function labelFromFileUrl(href: string): string {
const decoded = decodeHrefPath(href);
const trimmed = decoded.replace(TRAILING_SEPARATOR_REGEX, '');
const slash = trimmed.lastIndexOf(PATH_SEPARATOR);
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
}
@@ -42,18 +44,18 @@ function labelFromFileUrl(href: string): string {
// icon, matching the convention the mention picker inserts with.
function iconElement(href: string): Element {
return {
type: 'element',
tagName: 'svg',
children: getMentionBadgeIconPaths(href).map((d) => ({
children: [],
properties: { d },
tagName: 'path',
type: 'element'
})),
properties: {
...MENTION_BADGE_SVG_ATTRIBUTES,
className: MENTION_BADGE_ICON_CLASSNAME.split(' ').filter(Boolean)
},
children: getMentionBadgeIconPaths(href).map((d) => ({
type: 'element',
tagName: 'path',
properties: { d },
children: []
}))
tagName: 'svg',
type: 'element'
};
}
@@ -79,9 +81,6 @@ export const rehypeFileBadge: Plugin<[], Root> = () => {
node.children = [
iconElement(href),
{
type: 'element',
tagName: 'span',
properties: { className: ['shrink-0', 'truncate'] },
children: [
{
type: 'text',
@@ -92,7 +91,10 @@ export const rehypeFileBadge: Plugin<[], Root> = () => {
toolsStore.serverHome
)
}
]
],
properties: { className: ['shrink-0', 'truncate'] },
tagName: 'span',
type: 'element'
}
];
});
@@ -1,5 +1,5 @@
import type { Element, ElementContent, Root, Text } from 'hast';
import type { Plugin } from 'unified';
import type { Root, Element, ElementContent, Text } from 'hast';
import { visit } from 'unist-util-visit';
/**
@@ -17,9 +17,11 @@ export interface DiagramPreData {
*/
function extractText(node: ElementContent): string {
if (node.type === 'text') return node.value;
if (node.type === 'element') {
return (node.children ?? []).map(extractText).join('');
}
return '';
}
@@ -57,6 +59,7 @@ export function createPreTransform(
if (!codeElement) return;
const className = codeElement.properties?.className;
if (!Array.isArray(className)) return;
const matches = className.some(
@@ -73,15 +76,15 @@ export function createPreTransform(
if (contentGuard && !contentGuard(text)) return;
const pre: Element = {
type: 'element',
tagName: 'pre',
properties: {
className: [targetClass]
},
children: [{ type: 'text', value: text } as Text],
// Keep the highlighted code element so the block can offer a source
// view that matches the app code blocks without re highlighting.
data: { sourceCode: codeElement } satisfies DiagramPreData
data: { sourceCode: codeElement } satisfies DiagramPreData,
properties: {
className: [targetClass]
},
tagName: 'pre',
type: 'element'
};
(parent.children as ElementContent[])[index] = pre;
@@ -6,8 +6,8 @@
* (including those not in a predefined list) receive the attribute.
*/
import type { Element, Root } from 'hast';
import type { Plugin } from 'unified';
import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
/**
@@ -1,7 +1,7 @@
import { AttachmentType, UrlProtocol } from '$lib/enums';
import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
import type { Root as HastRoot } from 'hast';
import { visit } from 'unist-util-visit';
import type { DatabaseMessageExtra, DatabaseMessageExtraImageFile } from '$lib/types/database';
import { AttachmentType, UrlProtocol } from '$lib/enums';
/**
* Rehype plugin to resolve attachment image sources.
@@ -1,5 +1,5 @@
import { createPreTransform } from './pre-transform';
import { SVG_BLOCK_CLASS, SVG_LANGUAGE, XML_LANGUAGE, SVG_TAG_PREFIX } from '$lib/constants';
import { SVG_BLOCK_CLASS, SVG_LANGUAGE, SVG_TAG_PREFIX, XML_LANGUAGE } from '$lib/constants';
/**
* Converts svg code blocks to <pre class="svg-block"> for client-side rendering.
@@ -64,27 +64,30 @@
* // With this plugin: <br> becomes line break, <ul> becomes actual list
*/
import type { Plugin } from 'unified';
import { BR_PATTERN, LI_PATTERN, LIST_PATTERN } from '$lib/constants';
import type { Element, ElementContent, Root, Text } from 'hast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import { visitParents } from 'unist-util-visit-parents';
import { BR_PATTERN, LIST_PATTERN, LI_PATTERN } from '$lib/constants';
/**
* Expands text containing `<br>` tags into an array of text nodes and br elements.
*/
function expandBrTags(value: string): ElementContent[] {
const matches = [...value.matchAll(BR_PATTERN)];
if (!matches.length) return [{ type: 'text', value } as Text];
const result: ElementContent[] = [];
let cursor = 0;
for (const m of matches) {
if (m.index! > cursor) {
result.push({ type: 'text', value: value.slice(cursor, m.index) } as Text);
}
result.push({ type: 'element', tagName: 'br', properties: {}, children: [] } as Element);
result.push({ children: [], properties: {}, tagName: 'br', type: 'element' } as Element);
cursor = m.index! + m[0].length;
}
@@ -101,10 +104,12 @@ function expandBrTags(value: string): ElementContent[] {
*/
function parseList(value: string): Element | null {
const match = value.trim().match(LIST_PATTERN);
if (!match) return null;
const body = match[1];
const items: ElementContent[] = [];
let cursor = 0;
for (const liMatch of body.matchAll(LI_PATTERN)) {
@@ -112,10 +117,10 @@ function parseList(value: string): Element | null {
if (body.slice(cursor, liMatch.index!).trim()) return null;
items.push({
type: 'element',
tagName: 'li',
children: expandBrTags(liMatch[1] ?? ''),
properties: {},
children: expandBrTags(liMatch[1] ?? '')
tagName: 'li',
type: 'element'
} as Element);
cursor = liMatch.index! + liMatch[0].length;
@@ -124,7 +129,7 @@ function parseList(value: string): Element | null {
// Reject if no items found or trailing garbage exists
if (!items.length || body.slice(cursor).trim()) return null;
return { type: 'element', tagName: 'ul', properties: {}, children: items } as Element;
return { children: items, properties: {}, tagName: 'ul', type: 'element' } as Element;
}
/**
@@ -133,11 +138,13 @@ function parseList(value: string): Element | null {
function processCell(cell: Element) {
visitParents(cell, 'text', (textNode: Text, ancestors) => {
const parent = ancestors[ancestors.length - 1];
if (!parent || parent.type !== 'element') return;
const parentEl = parent as Element;
const siblings = parentEl.children as ElementContent[];
const startIndex = siblings.indexOf(textNode as ElementContent);
if (startIndex === -1) return;
// Combine consecutive text nodes and <br> elements into one string
@@ -146,6 +153,7 @@ function processCell(cell: Element) {
for (let i = startIndex; i < siblings.length; i++) {
const sib = siblings[i];
if (sib.type === 'text') {
combined += (sib as Text).value;
endIndex = i;
@@ -159,13 +167,16 @@ function processCell(cell: Element) {
// Try parsing as list first (replaces entire combined range)
const list = parseList(combined);
if (list) {
siblings.splice(startIndex, endIndex - startIndex + 1, list);
return;
}
// Otherwise, just expand <br> tags in this text node
const expanded = expandBrTags(textNode.value);
if (expanded.length !== 1 || expanded[0] !== textNode) {
siblings.splice(startIndex, 1, ...expanded);
}
@@ -1,7 +1,7 @@
import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants';
import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
import type { Break, Content, Paragraph, PhrasingContent, Root, Text } from 'mdast';
import { LINE_BREAK, NBSP, PHRASE_PARENTS, TAB_AS_SPACES } from '$lib/constants';
/**
* remark plugin that rewrites raw HTML nodes into plain-text equivalents.
@@ -23,12 +23,14 @@ function preserveIndent(line: string): string {
if (char === ' ') {
output += NBSP;
index += 1;
continue;
}
if (char === '\t') {
output += TAB_AS_SPACES;
index += 1;
continue;
}
@@ -71,12 +73,12 @@ export const remarkLiteralHtml: Plugin<[], Root> = () => {
if (!PHRASE_PARENTS.has(parent.type as string)) {
const paragraph: Paragraph = {
type: 'paragraph',
children: replacement as Paragraph['children'],
data: { literalHtml: true }
data: { literalHtml: true },
type: 'paragraph'
};
const siblings = parent.children as unknown as Content[];
siblings.splice(index, 1, paragraph as unknown as Content);
if (index > 0) {
@@ -1,7 +1,7 @@
<script lang="ts">
import MermaidPreviewControls from './MermaidPreviewControls.svelte';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import { SVG_DIALOG_SHADOW_STYLE } from '$lib/constants';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
interface Props {
svgHtml: string;
@@ -51,6 +51,7 @@
event.preventDefault();
const delta = event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
scale = Math.min(Math.max(scale + delta, MIN_SCALE), MAX_SCALE);
}
@@ -58,6 +59,7 @@
// (Svelte 5 wheel listeners are passive by default, making preventDefault() a no-op)
$effect(() => {
const el = containerRef.current;
if (!el) return;
function onWheel(e: WheelEvent) {
@@ -65,6 +67,7 @@
}
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
});
@@ -1,9 +1,9 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { Download } from '@lucide/svelte';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import ZoomInIcon from '@lucide/svelte/icons/zoom-in';
import ZoomOutIcon from '@lucide/svelte/icons/zoom-out';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
interface Props {
scale: number;
@@ -13,13 +13,15 @@
onResetView: () => void;
}
let { scale, svgHtml, onZoomIn, onZoomOut, onResetView }: Props = $props();
let { onResetView, onZoomIn, onZoomOut, scale, svgHtml }: Props = $props();
function downloadSvg() {
if (!svgHtml) return;
const blob = new Blob([svgHtml], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'diagram.svg';
a.click();
@@ -1,12 +1,11 @@
<script lang="ts">
import { browser } from '$app/environment';
import { mode } from 'mode-watcher';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { ColorMode } from '$lib/enums';
import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
import { ColorMode } from '$lib/enums';
import { highlightCode } from '$lib/utils';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import { mode } from 'mode-watcher';
interface Props {
code: string;
@@ -20,9 +19,9 @@
}
let {
class: className = '',
code,
language = 'text',
class: className = '',
maxHeight = '60vh',
maxWidth = '',
streaming = false
@@ -40,9 +39,11 @@
if (!browser) return;
const existingThemes = document.querySelectorAll('style[data-highlight-theme-preview]');
existingThemes.forEach((style) => style.remove());
const style = document.createElement('style');
style.setAttribute('data-highlight-theme-preview', 'true');
style.textContent = isDark ? githubDarkCss : githubLightCss;
@@ -51,6 +52,7 @@
function isAtBottom(): boolean {
if (!scrollEl) return false;
return (
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
SCROLL_BOTTOM_THRESHOLD_PX
@@ -59,8 +61,10 @@
function scrollToBottomOnFrame() {
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null;
// User may scroll between scheduling and paint.
if (scrollEl && !userScrolledUp) {
scrollEl.scrollTop = scrollEl.scrollHeight;
@@ -70,12 +74,15 @@
function handleScrollEvent() {
if (!scrollEl) return;
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
if (isScrollingUp && !isAtBottom()) {
userScrolledUp = true;
} else if (isAtBottom()) {
userScrolledUp = false;
}
lastScrollTop = scrollEl.scrollTop;
}
@@ -96,7 +103,9 @@
$effect(() => {
void code;
if (!streaming || userScrolledUp) return;
scrollToBottomOnFrame();
});
@@ -105,10 +114,11 @@
if (!streaming || !scrollEl) return;
const observer = new MutationObserver(() => scrollToBottomOnFrame());
observer.observe(scrollEl, {
characterData: true,
childList: true,
subtree: true,
characterData: true
subtree: true
});
return () => observer.disconnect();