From 4d506fba4eb8589e19208220523bc2eda60763a5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 15 Jun 2026 18:17:38 +0300 Subject: [PATCH] perf(markdown): rewrite rendering on marked + Shiki + morphdom Replace the react-markdown/Prism component tree with an HTML-string pipeline (marked -> KaTeX -> Shiki -> DOMPurify -> DOM decorators) patched into the DOM via morphdom, with per-block reconciliation and a paced streaming reveal. Cuts streaming CPU versus the previous renderer while preserving file-reference links, mermaid diagrams, table export, and agent/skill/favicon link handling. Public MarkdownRenderer/ SimpleMarkdownRenderer props are unchanged (drop-in). --- .../src/components/chat/MarkdownRenderer.tsx | 6 +- .../components/chat/MarkdownRendererImpl.tsx | 1459 ++++------------- .../src/components/chat/markdown/decorate.ts | 448 +++++ .../components/chat/markdown/markdownCore.ts | 362 ++++ .../components/chat/markdown/markdownTheme.ts | 133 ++ 5 files changed, 1237 insertions(+), 1171 deletions(-) create mode 100644 packages/ui/src/components/chat/markdown/decorate.ts create mode 100644 packages/ui/src/components/chat/markdown/markdownCore.ts create mode 100644 packages/ui/src/components/chat/markdown/markdownTheme.ts diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 51afc5e4..162e4e1d 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; -// Thin lazy wrapper around the heavy MarkdownRenderer implementation. -// The full implementation (marked, react-markdown, beautiful-mermaid, -// react-syntax-highlighter, etc.) is loaded on demand, keeping the +// Thin lazy wrapper around the MarkdownRenderer implementation. +// The full implementation (marked + Shiki highlighting + KaTeX + morphdom +// DOM morphing, plus beautiful-mermaid) is loaded on demand, keeping the // initial bundle lean. export type { MarkdownVariant } from './MarkdownRendererImpl'; diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 2209caa9..1b91a5db 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -1,41 +1,33 @@ import React from 'react'; import 'katex/dist/katex.min.css'; +import morphdom from 'morphdom'; import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid'; -import ReactMarkdown from 'react-markdown'; -import type { Components, Options as ReactMarkdownOptions } from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; -import rehypeKatex from 'rehype-katex'; -import { marked, type Tokens } from 'marked'; -import remend from 'remend'; -import { FadeInOnReveal } from './message/FadeInOnReveal'; import type { Part } from '@opencode-ai/sdk/v2'; import { cn } from '@/lib/utils'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { toast } from '@/components/ui'; -import { Icon } from "@/components/icon/Icon"; -import { copyTextToClipboard } from '@/lib/clipboard'; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; - -import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url'; -import { - buildAgentMentionUrl, - parseAgentHref, - parseSkillHref, -} from '@/lib/messages/inlineMessageLinks'; +import { isExternalHttpUrl, openExternalUrl } from '@/lib/url'; import { useOptionalThemeSystem } from '@/contexts/useThemeSystem'; import { getDefaultTheme } from '@/lib/theme/themes'; -import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; +import type { Theme } from '@/types/theme'; import type { ToolPopupContent } from './message/types'; +import { FadeInOnReveal } from './message/FadeInOnReveal'; import { useUIStore } from '@/stores/useUIStore'; -import { useDeviceInfo } from '@/lib/device'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import type { EditorAPI } from '@/lib/api/types'; import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants'; import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils'; +import { fallbackHtml, renderMarkdownBlocks } from './markdown/markdownCore'; +import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme'; +import { + attachMarkdownInteractions, + decorateMarkdown, + type DecorateContext, + type DecorateLabels, + type MermaidRender, +} from './markdown/decorate'; const useCurrentMermaidTheme = () => { const themeSystem = useOptionalThemeSystem(); @@ -101,512 +93,6 @@ const useExternalLinkInteractions = ({ }, [containerRef, enabled]); }; -const ExternalLinkFavicon: React.FC<{ href: string }> = ({ href }) => { - const [failed, setFailed] = React.useState(false); - const faviconUrl = React.useMemo(() => getExternalFaviconUrl(href), [href]); - - if (!faviconUrl || failed) { - return null; - } - - return ( - - setFailed(true)} - /> - - ); -}; - -// Table utility functions -const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => { - const headers: string[] = []; - const rows: string[][] = []; - - const thead = tableEl.querySelector('thead'); - if (thead) { - const headerCells = thead.querySelectorAll('th'); - headerCells.forEach(cell => headers.push(cell.innerText.trim())); - } - - const tbody = tableEl.querySelector('tbody'); - if (tbody) { - const rowEls = tbody.querySelectorAll('tr'); - rowEls.forEach(row => { - const cells = row.querySelectorAll('td'); - const rowData: string[] = []; - cells.forEach(cell => rowData.push(cell.innerText.trim())); - rows.push(rowData); - }); - } - - return { headers, rows }; -}; - -const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => { - const escapeCell = (cell: string): string => { - if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) { - return `"${cell.replace(/"/g, '""')}"`; - } - return cell; - }; - - const lines: string[] = []; - if (headers.length > 0) { - lines.push(headers.map(escapeCell).join(',')); - } - rows.forEach(row => lines.push(row.map(escapeCell).join(','))); - return lines.join('\n'); -}; - -const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => { - const escapeCell = (cell: string): string => { - return cell.replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r'); - }; - - const lines: string[] = []; - if (headers.length > 0) { - lines.push(headers.map(escapeCell).join('\t')); - } - rows.forEach(row => lines.push(row.map(escapeCell).join('\t'))); - return lines.join('\n'); -}; - -const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => { - if (headers.length === 0) return ''; - - const escapeCell = (cell: string): string => { - return cell.replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); - }; - - const lines: string[] = []; - lines.push(`| ${headers.map(escapeCell).join(' | ')} |`); - lines.push(`| ${headers.map(() => '---').join(' | ')} |`); - rows.forEach(row => { - const paddedRow = headers.map((_, i) => escapeCell(row[i] || '')); - lines.push(`| ${paddedRow.join(' | ')} |`); - }); - return lines.join('\n'); -}; - -const downloadFile = (filename: string, content: string, mimeType: string) => { - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -}; - - -// Table copy button with dropdown -const TableCopyButton: React.FC<{ tableRef: React.RefObject }> = ({ tableRef }) => { - const { t } = useI18n(); - const [copied, setCopied] = React.useState(false); - const [showMenu, setShowMenu] = React.useState(false); - const menuRef = React.useRef(null); - - React.useEffect(() => { - if (!showMenu) { - return; - } - - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setShowMenu(false); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [showMenu]); - - const handleCopy = async (format: 'csv' | 'tsv' | 'markdown') => { - const tableEl = tableRef.current?.querySelector('table'); - if (!tableEl) return; - - const data = extractTableData(tableEl); - let content: string; - if (format === 'csv') { - content = tableToCSV(data); - } else if (format === 'tsv') { - content = tableToTSV(data); - } else { - content = tableToMarkdown(data); - } - - - try { - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/plain': new Blob([content], { type: 'text/plain' }), - 'text/html': new Blob([tableEl.outerHTML], { type: 'text/html' }), - }), - ]); - setCopied(true); - setShowMenu(false); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - const fallbackResult = await copyTextToClipboard(content); - if (fallbackResult.ok) { - setCopied(true); - setShowMenu(false); - setTimeout(() => setCopied(false), 2000); - return; - } - console.error('Failed to copy table:', err); - } - }; - - return ( -
- - {showMenu && ( -
- - - -
- )} -
- ); -}; - -// Table download button with dropdown -const TableDownloadButton: React.FC<{ tableRef: React.RefObject }> = ({ tableRef }) => { - const { t } = useI18n(); - const [showMenu, setShowMenu] = React.useState(false); - const menuRef = React.useRef(null); - - React.useEffect(() => { - if (!showMenu) { - return; - } - - const handleClickOutside = (e: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setShowMenu(false); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [showMenu]); - - const handleDownload = (format: 'csv' | 'markdown') => { - const tableEl = tableRef.current?.querySelector('table'); - if (!tableEl) return; - - const data = extractTableData(tableEl); - const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data); - const filename = format === 'csv' ? 'table.csv' : 'table.md'; - const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown'; - downloadFile(filename, content, mimeType); - setShowMenu(false); - toast.success(t('markdownRenderer.table.toast.downloadedAsFormat', { format: format.toUpperCase() })); - }; - - return ( -
- - {showMenu && ( -
- - -
- )} -
- ); -}; - -// Table wrapper with custom controls -const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => { - const tableRef = React.useRef(null); - const { isMobile, isTablet } = useDeviceInfo(); - const alwaysShowActions = isMobile || isTablet; - const [showActions, setShowActions] = React.useState(alwaysShowActions); - - React.useEffect(() => { - if (alwaysShowActions) { - setShowActions(true); - } - }, [alwaysShowActions]); - - return ( -
setShowActions(true)} - onMouseLeave={() => { - if (!alwaysShowActions) { - setShowActions(false); - } - }} - onFocusCapture={() => setShowActions(true)} - > - {showActions ? ( -
- - -
- ) : ( - - ); -}; - -const MERMAID_RENDER_DELAY_MS = 80; - -const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => { - const { t } = useI18n(); - const currentTheme = useCurrentMermaidTheme(); - const { isMobile, isTablet } = useDeviceInfo(); - const [copied, setCopied] = React.useState(false); - const [downloaded, setDownloaded] = React.useState(false); - const [svg, setSvg] = React.useState(''); - const [ascii, setAscii] = React.useState(''); - - React.useEffect(() => { - if (typeof window === 'undefined') { - try { - setSvg(mode === 'svg' ? renderMermaidSVG(source, { - bg: currentTheme.colors.surface.elevated, - fg: currentTheme.colors.surface.foreground, - line: currentTheme.colors.interactive.border, - accent: currentTheme.colors.primary.base, - muted: currentTheme.colors.surface.mutedForeground, - surface: currentTheme.colors.surface.muted, - border: currentTheme.colors.interactive.border, - transparent: true, - font: 'IBM Plex Sans, sans-serif', - }) : ''); - setAscii(mode === 'ascii' ? renderMermaidASCII(source) : ''); - } catch { - setSvg(''); - setAscii(''); - } - return; - } - - let cancelled = false; - let frame: number | null = null; - setSvg(''); - setAscii(''); - - const timer = window.setTimeout(() => { - frame = window.requestAnimationFrame(() => { - frame = null; - if (cancelled) { - return; - } - - try { - if (mode === 'svg') { - setSvg(renderMermaidSVG(source, { - bg: currentTheme.colors.surface.elevated, - fg: currentTheme.colors.surface.foreground, - line: currentTheme.colors.interactive.border, - accent: currentTheme.colors.primary.base, - muted: currentTheme.colors.surface.mutedForeground, - surface: currentTheme.colors.surface.muted, - border: currentTheme.colors.interactive.border, - transparent: true, - font: 'IBM Plex Sans, sans-serif', - })); - return; - } - - setAscii(renderMermaidASCII(source)); - } catch { - setSvg(''); - setAscii(''); - } - }); - }, MERMAID_RENDER_DELAY_MS); - - return () => { - cancelled = true; - window.clearTimeout(timer); - if (frame !== null) { - window.cancelAnimationFrame(frame); - } - }; - }, [currentTheme, mode, source]); - - const copyVisibilityClass = isMobile || isTablet ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'; - - const handleCopyAscii = async (asciiText: string) => { - if (!asciiText) return; - const result = await copyTextToClipboard(asciiText); - if (result.ok) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - const handleCopyMermaidSource = async () => { - if (!source) return; - const result = await copyTextToClipboard(source); - if (result.ok) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - const handleDownloadSvg = () => { - if (!svg) return; - try { - const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `diagram-${Date.now()}.svg`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - setDownloaded(true); - setTimeout(() => setDownloaded(false), 2000); - } catch { - toast.error(t('markdownRenderer.mermaid.toast.downloadFailed')); - } - }; - - if (mode === 'ascii') { - const asciiText = ascii || source; - - return ( -
-
-
{asciiText}
-
-
- -
-
- ); - } - - if (!svg) { - return ( -
-
-
{source}
-
-
- -
-
- ); - } - - return ( -
-
-
-
-
- - -
-
- ); -}; - type MermaidControlOptions = { download: boolean; copy: boolean; @@ -643,600 +129,6 @@ const stripLeadingFrontmatter = (markdown: string): string => { export type MarkdownVariant = 'assistant' | 'tool' | 'reasoning'; -const MARKDOWN_REMARK_PLUGINS: ReactMarkdownOptions['remarkPlugins'] = [remarkGfm, remarkMath]; -const MARKDOWN_REHYPE_PLUGINS: ReactMarkdownOptions['rehypePlugins'] = [[rehypeKatex, { throwOnError: false, errorColor: 'var(--destructive)' }]]; -const MARKDOWN_BLOCK_CACHE_MAX_ENTRIES = 240; - -type MarkdownStreamBlock = { - key: string; - raw: string; - src: string; - mode: 'full' | 'live'; -}; - -const hasReferenceDefinitions = (text: string): boolean => { - return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text); -}; - -const hasOpenFence = (raw: string): boolean => { - const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/); - if (!match) return false; - const marker = match[1]; - if (!marker) return false; - const char = marker[0]; - const size = marker.length; - const last = raw.trimEnd().split('\n').at(-1)?.trim() ?? ''; - return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last); -}; - -const healMarkdown = (text: string): string => { - return remend(text, { linkMode: 'text-only' }); -}; - -const fnv1a32 = (input: string): string => { - let hash = 0x811c9dc5; - for (let i = 0; i < input.length; i += 1) { - hash ^= input.charCodeAt(i); - hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; - } - return hash.toString(16); -}; - -const buildMarkdownCacheKey = (baseKey: string, raw: string, index: number, mode: 'full' | 'live'): string => { - const sample = raw.length > 400 ? `${raw.slice(0, 200)}${raw.slice(-200)}` : raw; - return `${baseKey}:${index}:${mode}:${raw.length}:${fnv1a32(sample)}`; -}; - -const MARKDOWN_BLOCK_CACHE = new Map(); - -const getMarkdownBlockCacheEntry = (key: string): MarkdownStreamBlock[] | undefined => { - const cached = MARKDOWN_BLOCK_CACHE.get(key); - if (!cached) { - return undefined; - } - MARKDOWN_BLOCK_CACHE.delete(key); - MARKDOWN_BLOCK_CACHE.set(key, cached); - return cached; -}; - -const setMarkdownBlockCacheEntry = (key: string, blocks: MarkdownStreamBlock[]): void => { - while (MARKDOWN_BLOCK_CACHE.size >= MARKDOWN_BLOCK_CACHE_MAX_ENTRIES) { - const oldest = MARKDOWN_BLOCK_CACHE.keys().next().value; - if (typeof oldest !== 'string') { - break; - } - MARKDOWN_BLOCK_CACHE.delete(oldest); - } - MARKDOWN_BLOCK_CACHE.set(key, blocks); -}; - -const streamMarkdownBlocks = (text: string, live: boolean, baseKey: string): MarkdownStreamBlock[] => { - if (!live) { - const cacheKey = `${baseKey}:final:${text.length}:${fnv1a32(text.length > 800 ? `${text.slice(0, 400)}${text.slice(-400)}` : text)}`; - const cached = getMarkdownBlockCacheEntry(cacheKey); - if (cached) { - return cached; - } - - const blocks: MarkdownStreamBlock[] = [{ - key: buildMarkdownCacheKey(baseKey, text, 0, 'full'), - raw: text, - src: text, - mode: 'full', - }]; - setMarkdownBlockCacheEntry(cacheKey, blocks); - return blocks; - } - - const healed = healMarkdown(text); - if (hasReferenceDefinitions(text)) { - return [{ - key: buildMarkdownCacheKey(baseKey, text, 0, 'live'), - raw: text, - src: healed, - mode: 'live', - }]; - } - - const tokens = marked.lexer(text); - const blocks: MarkdownStreamBlock[] = []; - let blockIndex = 0; - for (let index = 0; index < tokens.length; index += 1) { - const token = tokens[index] as Tokens.Generic; - if (token.type === 'space') { - continue; - } - - const raw = token.raw ?? ''; - const isLast = index === tokens.length - 1 || tokens.slice(index + 1).every((nextToken) => nextToken.type === 'space'); - const mode: 'full' | 'live' = isLast ? 'live' : 'full'; - const src = isLast && token.type === 'code' && hasOpenFence(raw) - ? raw - : healMarkdown(raw); - - blocks.push({ - key: buildMarkdownCacheKey(baseKey, raw, blockIndex, mode), - raw, - src, - mode, - }); - blockIndex += 1; - } - - if (blocks.length === 0) { - return [{ - key: buildMarkdownCacheKey(baseKey, text, 0, 'live'), - raw: text, - src: healed, - mode: 'live', - }]; - } - - return blocks; -}; - -const useStableMarkdownBlocks = (text: string, live: boolean, baseKey: string): MarkdownStreamBlock[] => { - const previousRef = React.useRef([]); - - return React.useMemo(() => { - const nextBlocks = streamMarkdownBlocks(text, live, baseKey); - const previousBlocks = previousRef.current; - const stabilized = nextBlocks.map((block, index) => { - const previous = previousBlocks[index]; - if (previous && previous.key === block.key && previous.src === block.src) { - return previous; - } - return block; - }); - - const unchanged = stabilized.length === previousBlocks.length - && stabilized.every((block, index) => block === previousBlocks[index]); - - if (unchanged) { - return previousBlocks; - } - - previousRef.current = stabilized; - return stabilized; - }, [baseKey, live, text]); -}; - -const extractCodeText = (children: React.ReactNode): string => { - if (typeof children === 'string') return children; - if (Array.isArray(children)) { - return children.map((child) => extractCodeText(child)).join(''); - } - if (React.isValidElement(children)) { - return extractCodeText((children.props as { children?: React.ReactNode }).children); - } - return ''; -}; - -const getCodeLanguage = (className: string | undefined): string => { - const match = className?.match(/language-([\w-]+)/); - return match?.[1]?.toLowerCase() ?? 'text'; -}; - -const decodeHtmlEntities = (value: string): string => { - let decoded = value; - for (let i = 0; i < 3; i += 1) { - const next = decoded - .replace(/"/g, '"') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&'); - if (next === decoded) { - return decoded; - } - decoded = next; - } - return decoded; -}; - -const normalizeCodeBlockText = (code: string, language: string): string => { - if (!['json', 'jsonc', 'json5'].includes(language)) { - return code; - } - if (!/&(quot|#34|amp;quot|lt|gt|amp|apos|#39);/.test(code)) { - return code; - } - return decodeHtmlEntities(code); -}; - -const CODE_HIGHLIGHT_SETTLE_MS = 300; -const CODE_HIGHLIGHT_INITIAL_DELAY_MS = 80; -const CODE_HIGHLIGHT_LINE_LIMIT = 1200; -const VSCODE_CODE_HIGHLIGHT_LINE_LIMIT = 200; -const CODE_SHARED_STYLE: React.CSSProperties = { - margin: 0, - background: 'transparent', - padding: 0, - fontSize: 'var(--text-code)', - lineHeight: 'var(--markdown-code-block-line-height)', -}; - -const exceedsLineLimit = (value: string, limit: number): boolean => { - let lineCount = 1; - for (let index = 0; index < value.length; index += 1) { - if (value.charCodeAt(index) === 10) { - lineCount += 1; - if (lineCount > limit) { - return true; - } - } - } - return false; -}; - -const getCodeHighlightLineLimit = (): number => ( - isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT -); - -const downloadTextFile = (content: string, filename: string, mimeType: string) => { - if (typeof window === 'undefined') { - return; - } - - try { - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } catch { - // Best-effort; callers can optionally toast. - } -}; - -const MarkdownCodeBlock: React.FC<{ - code: string; - language: string; - syntaxTheme: { [key: string]: React.CSSProperties }; -}> = ({ code, language, syntaxTheme }) => { - const [copied, setCopied] = React.useState(false); - const [highlight, setHighlight] = React.useState(false); - const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code'); - const prevCodeRef = React.useRef(code); - const timerRef = React.useRef | null>(null); - const { isMobile, isTablet } = useDeviceInfo(); - const alwaysShowControls = isMobile || isTablet; - const [showControls, setShowControls] = React.useState(alwaysShowControls); - const skipHighlight = exceedsLineLimit(code, getCodeHighlightLineLimit()); - - const canPreview = language === 'html' || language === 'htm'; - - React.useEffect(() => { - if (alwaysShowControls) { - setShowControls(true); - } - }, [alwaysShowControls]); - - React.useEffect(() => { - if (!canPreview && viewMode !== 'code') { - setViewMode('code'); - } - }, [canPreview, viewMode]); - - React.useEffect(() => { - if (skipHighlight) { - setHighlight(false); - return; - } - - if (typeof window === 'undefined') { - setHighlight(true); - return; - } - - let frame: number | null = null; - const timer = window.setTimeout(() => { - frame = window.requestAnimationFrame(() => { - frame = null; - setHighlight(true); - }); - }, CODE_HIGHLIGHT_INITIAL_DELAY_MS); - - return () => { - window.clearTimeout(timer); - if (frame !== null) { - window.cancelAnimationFrame(frame); - } - }; - }, [skipHighlight]); - - // Defer Prism highlighting while code is actively streaming. - React.useEffect(() => { - if (prevCodeRef.current === code) return; - prevCodeRef.current = code; - - if (timerRef.current) clearTimeout(timerRef.current); - setHighlight(false); - timerRef.current = setTimeout(() => { - setHighlight(true); - timerRef.current = null; - }, CODE_HIGHLIGHT_SETTLE_MS); - - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }; - }, [code]); - - const handleCopy = React.useCallback(async () => { - const result = await copyTextToClipboard(code); - if (!result.ok) return; - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); - }, [code]); - - const handleDownload = React.useCallback(() => { - if (!canPreview) { - return; - } - - const safeSuffix = Date.now().toString(36); - downloadTextFile(code, `preview-${safeSuffix}.html`, 'text/html;charset=utf-8'); - }, [canPreview, code]); - - return ( -
setShowControls(true)} - onMouseLeave={() => { - if (!alwaysShowControls) { - setShowControls(false); - } - }} - onFocusCapture={() => setShowControls(true)} - > -
- {language} - {showControls ? ( -
- {canPreview ? ( - - ) : null} - {canPreview ? ( - - ) : null} - -
- ) : ( - - {canPreview && viewMode === 'preview' ? ( -
-