ui: Linting & Formatting scripts (#26819)
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
+43
-39
@@ -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)}`;
|
||||
}
|
||||
|
||||
+5
-4
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
+21
-21
@@ -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]
|
||||
);
|
||||
|
||||
+21
-21
@@ -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]
|
||||
);
|
||||
|
||||
+16
-14
@@ -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'
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
+10
-7
@@ -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;
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+18
-7
@@ -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);
|
||||
}
|
||||
|
||||
+7
-5
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user