perf(code): replace react-syntax-highlighter and prismjs with the Shiki worker
Route all non-markdown code highlighting through the off-main-thread Shiki worker, removing react-syntax-highlighter and prismjs entirely. - Extend the worker with highlightLines: tokenize a whole block once and return per-line inner HTML, so per-line layouts (diffs, gutters, virtualization) make one worker call instead of one highlighter per line. - Add shared WorkerHighlightedCode (whole-block) and useWorkerHighlightedLines (per-line) primitives. Colors resolve via the --md-syntax-* CSS variables, so theme changes never re-highlight. - Migrate all 12 react-syntax-highlighter call sites: PermissionCard, ToolPart, ContextSidebarTab, ToolOutputDialog (whole block) and DiffPreview/WritePreview (per line). - Migrate VirtualizedCodeBlock off prismjs to the worker, keeping virtua virtualization; whole-block tokenization also restores cross-line syntax context that per-line highlighting lost. - Drop react-syntax-highlighter (+types) from ui and web, prismjs (+types) from ui, and the orphaned create-element type shim.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -550,9 +550,8 @@ DialogUnifiedDiff.displayName = 'DialogUnifiedDiff';
|
||||
|
||||
const DialogReadContent: React.FC<{
|
||||
popup: ToolPopupContent;
|
||||
syntaxTheme: Record<string, React.CSSProperties>;
|
||||
pierreThemeConfig: PierreThemeConfig;
|
||||
}> = React.memo(({ popup, syntaxTheme, pierreThemeConfig }) => {
|
||||
}> = React.memo(({ popup, pierreThemeConfig }) => {
|
||||
const parsedReadOutput = React.useMemo(() => parseReadToolOutput(popup.content), [popup.content]);
|
||||
|
||||
const inputMeta = popup.metadata?.input;
|
||||
@@ -627,7 +626,6 @@ const DialogReadContent: React.FC<{
|
||||
<VirtualizedCodeBlock
|
||||
lines={codeLines}
|
||||
language={detectedLanguage}
|
||||
syntaxTheme={syntaxTheme}
|
||||
maxHeight="70vh"
|
||||
/>
|
||||
);
|
||||
@@ -1055,16 +1053,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
</div>
|
||||
{meta.tool === 'bash' && getInputValue('command') ? (
|
||||
<div className="tool-input-surface bg-transparent rounded-xl border border-border/20 mx-3">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language="bash"
|
||||
PreTag="div"
|
||||
customStyle={toolDisplayStyles.getPopupStyles()}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
wrapLongLines
|
||||
>
|
||||
{getInputValue('command')!}
|
||||
</SyntaxHighlighter>
|
||||
code={getInputValue('command')!}
|
||||
style={toolDisplayStyles.getPopupStyles()}
|
||||
codeStyle={DIALOG_CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
) : meta.tool === 'task' && getInputValue('prompt') ? (
|
||||
<div
|
||||
@@ -1124,16 +1119,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
completed: t('chat.todo.completed'),
|
||||
cancelled: t('chat.todo.cancelled'),
|
||||
}) || (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language="json"
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
code={popup.content}
|
||||
style={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeStyle={DIALOG_CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1179,22 +1171,19 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
if (tool === 'web-search' || tool === 'websearch' || tool === 'search_web') {
|
||||
return (
|
||||
renderWebSearchOutput(popup.content, syntaxTheme) || (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language="text"
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
code={popup.content}
|
||||
style={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeStyle={DIALOG_CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (tool === 'read') {
|
||||
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} pierreThemeConfig={pierreThemeConfig} />;
|
||||
return <DialogReadContent popup={popup} pierreThemeConfig={pierreThemeConfig} />;
|
||||
}
|
||||
|
||||
// JSON tree viewer for generic JSON outputs
|
||||
@@ -1210,16 +1199,13 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
|
||||
}
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language={popup.language || 'text'}
|
||||
PreTag="div"
|
||||
wrapLongLines
|
||||
customStyle={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeTagProps={DIALOG_CODE_TAG_PROPS}
|
||||
>
|
||||
{popup.content}
|
||||
</SyntaxHighlighter>
|
||||
code={popup.content}
|
||||
style={toolDisplayStyles.getPopupContainerStyles()}
|
||||
codeStyle={DIALOG_CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||
import { getToolMetadata } from '@/lib/toolHelpers';
|
||||
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2';
|
||||
import { toolDisplayStyles } from '@/lib/typography';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
@@ -844,7 +844,7 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
input: Record<string, unknown> | undefined;
|
||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||
}> = ({ output, part, metadata, input, syntaxTheme }) => {
|
||||
}> = ({ output, part, metadata, input }) => {
|
||||
const { t } = useI18n();
|
||||
const renderedOutput = getToolOutputText(output, part, metadata);
|
||||
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
|
||||
@@ -910,16 +910,13 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
/>
|
||||
) : (
|
||||
<div className="typography-code pr-12 text-muted-foreground/90">
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language="json"
|
||||
PreTag="div"
|
||||
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeTagProps={CODE_TAG_PROPS}
|
||||
wrapLongLines
|
||||
>
|
||||
{renderedOutput}
|
||||
</SyntaxHighlighter>
|
||||
code={renderedOutput}
|
||||
style={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeStyle={CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -928,16 +925,13 @@ const ToolScrollableTextOutput: React.FC<{
|
||||
|
||||
return (
|
||||
<div className={part.tool === 'bash' ? 'typography-code text-muted-foreground/90' : undefined}>
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
<WorkerHighlightedCode
|
||||
language={outputLanguage}
|
||||
PreTag="div"
|
||||
customStyle={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeTagProps={CODE_TAG_PROPS}
|
||||
wrapLongLines
|
||||
>
|
||||
{renderedOutput}
|
||||
</SyntaxHighlighter>
|
||||
code={renderedOutput}
|
||||
style={TOOL_COLLAPSED_CUSTOM_STYLE}
|
||||
codeStyle={CODE_TAG_PROPS.style}
|
||||
wrap
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,48 +1,20 @@
|
||||
/**
|
||||
* VirtualizedCodeBlock — PERF-007
|
||||
*
|
||||
* Replaces per-line <SyntaxHighlighter> with:
|
||||
* 1. ONE Prism.highlight() call to tokenize all code at once
|
||||
* Renders large code/read outputs without mounting one highlighter per line:
|
||||
* 1. ONE worker tokenization of the whole block (off the main thread)
|
||||
* 2. virtua to only render visible rows
|
||||
*
|
||||
* This drops mount cost from O(N * Prism) to O(1 * Prism) + O(visible_rows).
|
||||
* For a 2000-line file, ~2000 SyntaxHighlighter instances → ~30 plain <div>s.
|
||||
* Tokenizing the whole block at once also preserves cross-line syntax context
|
||||
* (multi-line strings/comments) that per-line highlighting loses. Colors resolve
|
||||
* through the `--md-syntax-*` CSS variables on the container.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Virtualizer } from 'virtua';
|
||||
import Prism from 'prismjs';
|
||||
|
||||
// Ensure common languages are loaded (react-syntax-highlighter lazy-loads them,
|
||||
// but we call Prism directly so we need them registered).
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-markup-templating';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import 'prismjs/components/prism-tsx';
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-csharp';
|
||||
import 'prismjs/components/prism-ruby';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-toml';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-diff';
|
||||
import 'prismjs/components/prism-docker';
|
||||
import 'prismjs/components/prism-swift';
|
||||
import 'prismjs/components/prism-kotlin';
|
||||
import 'prismjs/components/prism-lua';
|
||||
import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-scss';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||
|
||||
// ── Threshold: files smaller than this render without virtualization ──
|
||||
const VIRTUALIZE_THRESHOLD = 80;
|
||||
@@ -60,7 +32,6 @@ export interface CodeLine {
|
||||
interface VirtualizedCodeBlockProps {
|
||||
lines: CodeLine[];
|
||||
language: string;
|
||||
syntaxTheme: Record<string, React.CSSProperties>;
|
||||
/** Max visible height in CSS (default: 60vh) */
|
||||
maxHeight?: string;
|
||||
/** Show line numbers (default: true) */
|
||||
@@ -69,123 +40,21 @@ interface VirtualizedCodeBlockProps {
|
||||
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
|
||||
}
|
||||
|
||||
const toKebabCase = (value: string): string => value.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
||||
|
||||
const styleObjectToCss = (style: React.CSSProperties): string => {
|
||||
return Object.entries(style)
|
||||
.filter(([, v]) => v !== undefined && v !== null)
|
||||
.map(([k, v]) => `${toKebabCase(k)}:${String(v)};`)
|
||||
.join('');
|
||||
};
|
||||
|
||||
const buildSelectorList = (rawKey: string): string[] => {
|
||||
return rawKey
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((selector) => {
|
||||
if (selector.startsWith('.token')) {
|
||||
return [`.oc-virtualized-prism ${selector}`];
|
||||
}
|
||||
if (selector.startsWith('token.')) {
|
||||
return [`.oc-virtualized-prism .${selector}`];
|
||||
}
|
||||
if (/^[a-z0-9_-]+$/i.test(selector)) {
|
||||
return [`.oc-virtualized-prism .token.${selector}`];
|
||||
}
|
||||
if (selector.includes('token')) {
|
||||
return [`.oc-virtualized-prism ${selector}`];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
const buildPrismThemeCss = (theme: Record<string, React.CSSProperties>): string => {
|
||||
const rules: string[] = [];
|
||||
Object.entries(theme).forEach(([rawKey, style]) => {
|
||||
const selectors = buildSelectorList(rawKey);
|
||||
if (selectors.length === 0) {
|
||||
return;
|
||||
}
|
||||
const css = styleObjectToCss(style);
|
||||
if (!css) {
|
||||
return;
|
||||
}
|
||||
rules.push(`${selectors.join(',')}{${css}}`);
|
||||
});
|
||||
return rules.join('\n');
|
||||
};
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
text: 'plain',
|
||||
plaintext: 'plain',
|
||||
shell: 'bash',
|
||||
sh: 'bash',
|
||||
zsh: 'bash',
|
||||
patch: 'diff',
|
||||
dockerfile: 'docker',
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
};
|
||||
|
||||
const normalizeLanguage = (language: string): string => {
|
||||
const lower = language.toLowerCase();
|
||||
return LANGUAGE_ALIASES[lower] ?? lower;
|
||||
};
|
||||
|
||||
const HIGHLIGHT_CACHE_MAX = 5000;
|
||||
const highlightCache = new Map<string, string>();
|
||||
|
||||
const highlightLine = (text: string, language: string): string => {
|
||||
const normalizedLanguage = normalizeLanguage(language);
|
||||
const cacheKey = `${normalizedLanguage}\n${text}`;
|
||||
const cached = highlightCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const grammar = Prism.languages[normalizedLanguage] ?? Prism.languages.text;
|
||||
if (!grammar) {
|
||||
const escaped = escapeHtml(text);
|
||||
highlightCache.set(cacheKey, escaped);
|
||||
return escaped;
|
||||
}
|
||||
|
||||
try {
|
||||
const highlighted = Prism.highlight(text, grammar, normalizedLanguage);
|
||||
if (highlightCache.size >= HIGHLIGHT_CACHE_MAX) {
|
||||
const oldestKey = highlightCache.keys().next().value;
|
||||
if (typeof oldestKey === 'string') {
|
||||
highlightCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
highlightCache.set(cacheKey, highlighted);
|
||||
return highlighted;
|
||||
} catch {
|
||||
const escaped = escapeHtml(text);
|
||||
highlightCache.set(cacheKey, escaped);
|
||||
return escaped;
|
||||
}
|
||||
};
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.memo((props) => {
|
||||
const {
|
||||
lines,
|
||||
language,
|
||||
syntaxTheme,
|
||||
maxHeight = '60vh',
|
||||
showLineNumbers = true,
|
||||
lineStyles,
|
||||
} = props;
|
||||
const prismThemeCss = React.useMemo(() => buildPrismThemeCss(syntaxTheme), [syntaxTheme]);
|
||||
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
||||
// Tokenize the whole block in one worker call; rows index into the result.
|
||||
const fullText = React.useMemo(() => lines.map((line) => line.text).join('\n'), [lines]);
|
||||
const highlighted = useWorkerHighlightedLines(fullText, language);
|
||||
|
||||
const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
|
||||
|
||||
@@ -193,15 +62,14 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
|
||||
if (!shouldVirtualize) {
|
||||
return (
|
||||
<div
|
||||
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
|
||||
style={{ maxHeight, overflow: 'auto' }}
|
||||
className="typography-code font-mono w-full min-w-0"
|
||||
style={{ ...(syntaxVars as React.CSSProperties), maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
|
||||
{lines.map((line, idx) => (
|
||||
<Row
|
||||
key={idx}
|
||||
line={line}
|
||||
language={language}
|
||||
html={highlighted?.[idx]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
@@ -214,8 +82,8 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
|
||||
return (
|
||||
<VirtualizedRows
|
||||
lines={lines}
|
||||
language={language}
|
||||
prismThemeCss={prismThemeCss}
|
||||
highlighted={highlighted}
|
||||
syntaxVars={syntaxVars}
|
||||
maxHeight={maxHeight}
|
||||
showLineNumbers={showLineNumbers}
|
||||
lineStyles={lineStyles}
|
||||
@@ -228,8 +96,8 @@ VirtualizedCodeBlock.displayName = 'VirtualizedCodeBlock';
|
||||
// ── Virtualised container (extracted so the hook is top-level) ────────
|
||||
interface VirtualizedRowsProps {
|
||||
lines: CodeLine[];
|
||||
language: string;
|
||||
prismThemeCss: string;
|
||||
highlighted: string[] | null;
|
||||
syntaxVars: Record<string, string>;
|
||||
maxHeight: string;
|
||||
showLineNumbers: boolean;
|
||||
lineStyles?: (line: CodeLine) => React.CSSProperties | undefined;
|
||||
@@ -237,8 +105,8 @@ interface VirtualizedRowsProps {
|
||||
|
||||
const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
||||
lines,
|
||||
language,
|
||||
prismThemeCss,
|
||||
highlighted,
|
||||
syntaxVars,
|
||||
maxHeight,
|
||||
showLineNumbers,
|
||||
lineStyles,
|
||||
@@ -249,10 +117,9 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="typography-code font-mono w-full min-w-0 oc-virtualized-prism"
|
||||
style={{ height: viewportHeight, maxHeight, overflow: 'auto' }}
|
||||
className="typography-code font-mono w-full min-w-0"
|
||||
style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
{prismThemeCss ? <style>{prismThemeCss}</style> : null}
|
||||
<Virtualizer
|
||||
data={lines}
|
||||
itemSize={ROW_HEIGHT}
|
||||
@@ -263,7 +130,7 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
||||
<Row
|
||||
key={index}
|
||||
line={line}
|
||||
language={language}
|
||||
html={highlighted?.[index]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
@@ -278,14 +145,12 @@ VirtualizedRows.displayName = 'VirtualizedRows';
|
||||
// ── Single row ───────────────────────────────────────────────────────
|
||||
interface RowProps {
|
||||
line: CodeLine;
|
||||
language: string;
|
||||
html: string | undefined;
|
||||
showLineNumbers: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const Row: React.FC<RowProps> = React.memo(({ line, language, showLineNumbers, style }) => {
|
||||
const html = React.useMemo(() => highlightLine(line.text, language), [line.text, language]);
|
||||
|
||||
const Row: React.FC<RowProps> = React.memo(({ line, html, showLineNumbers, style }) => {
|
||||
return (
|
||||
<div
|
||||
className="typography-code font-mono flex w-full min-w-0"
|
||||
@@ -304,11 +169,10 @@ const Row: React.FC<RowProps> = React.memo(({ line, language, showLineNumbers, s
|
||||
<div className="whitespace-pre-wrap break-words text-muted-foreground/70 italic">
|
||||
{line.text}
|
||||
</div>
|
||||
) : html !== undefined ? (
|
||||
<div className="whitespace-pre" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<div
|
||||
className="whitespace-pre"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<div className="whitespace-pre">{line.text}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user