From 9dc0dbacad57e02a7ca208893cdd6690534af568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Mon, 6 Apr 2026 22:21:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20rewrite=20MarkdownRenderer=20code=20blo?= =?UTF-8?q?cks=20with=20React=20CodeViewer=20=E2=80=94=20no=20more=20HTML?= =?UTF-8?q?=20string=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/markdown/MarkdownRenderer.tsx | 158 ++++++++++-------- 1 file changed, 90 insertions(+), 68 deletions(-) diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index bd10e9f7..a0d2eb1d 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -1,54 +1,25 @@ -import { useMemo } from 'react' +import { useMemo, useCallback } from 'react' import { marked, type Tokens } from 'marked' -import { escapeHtml, highlightCodeLines, isHighlightable } from '../chat/highlightCode' +import { CodeViewer } from '../chat/CodeViewer' type Props = { content: string } +type CodeBlock = { + id: string + code: string + language: string | undefined +} + const renderer = new marked.Renderer() +let pendingCodeBlocks: CodeBlock[] = [] + renderer.code = function ({ text, lang }: Tokens.Code) { - const languageLabel = escapeHtml(lang || 'code') - const lines = text.split('\n') - const hasLanguage = isHighlightable(lang) - const highlightedLines = highlightCodeLines(text, lang) - - // Show line numbers only when language is known (actual code). - // Plain text, file trees, command output etc. look better without them. - const body = hasLanguage - ? highlightedLines - .map((line, index) => ` -
- ${index + 1} - ${line || ' '} -
- `) - .join('') - : highlightedLines - .map((line) => ` -
- ${line || ' '} -
- `) - .join('') - - return ` -
-
-
- ${languageLabel} - ${lines.length} ${lines.length === 1 ? 'line' : 'lines'} -
- -
-
-
${body}
-
-
- ` + const id = `cb-${pendingCodeBlocks.length}` + pendingCodeBlocks.push({ id, code: text, language: lang || undefined }) + return `
` } marked.setOptions({ @@ -57,16 +28,46 @@ marked.setOptions({ }) marked.use({ renderer }) -export function MarkdownRenderer({ content }: Props) { - const html = useMemo(() => { - try { - return marked.parse(content) as string - } catch { - return content - } - }, [content]) +function parseMarkdown(content: string): { html: string; codeBlocks: CodeBlock[] } { + pendingCodeBlocks = [] + const html = marked.parse(content) as string + const codeBlocks = [...pendingCodeBlocks] + pendingCodeBlocks = [] + return { html, codeBlocks } +} - const handleClick = async (event: React.MouseEvent) => { +export function MarkdownRenderer({ content }: Props) { + const { html, codeBlocks } = useMemo(() => parseMarkdown(content), [content]) + + const parts = useMemo(() => { + if (codeBlocks.length === 0) { + return [{ type: 'html' as const, content: html }] + } + + const result: Array<{ type: 'html'; content: string } | { type: 'code'; block: CodeBlock }> = [] + let remaining = html + + for (const block of codeBlocks) { + const marker = `
` + const idx = remaining.indexOf(marker) + if (idx === -1) continue + + const before = remaining.slice(0, idx) + if (before) { + result.push({ type: 'html', content: before }) + } + result.push({ type: 'code', block }) + remaining = remaining.slice(idx + marker.length) + } + + if (remaining) { + result.push({ type: 'html', content: remaining }) + } + + return result + }, [html, codeBlocks]) + + const handleClick = useCallback(async (event: React.MouseEvent) => { const target = event.target as HTMLElement | null const button = target?.closest('[data-copy-code]') if (!button) return @@ -82,26 +83,47 @@ export function MarkdownRenderer({ content }: Props) { button.textContent = original }, 1500) } catch { - // Ignore clipboard errors and keep the original label. + // Ignore clipboard errors } + }, []) + + const proseClasses = `prose prose-sm max-w-none text-[var(--color-text-primary)] + prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold + prose-p:my-2 prose-p:leading-relaxed + prose-code:text-[13px] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-info)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded + prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none + prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline + prose-strong:text-[var(--color-text-primary)] + prose-ul:my-2 prose-ol:my-2 + prose-li:my-0.5 + prose-table:text-sm + prose-th:bg-[var(--color-surface-info)] prose-th:px-3 prose-th:py-2 + prose-td:px-3 prose-td:py-2 prose-td:border-[var(--color-border)]` + + if (codeBlocks.length === 0) { + return ( +
+ ) } return ( -
+
+ {parts.map((part, i) => + part.type === 'html' ? ( +
+ ) : ( +
+ +
+ ) + )} +
) }