diff --git a/frontend/src/components/MarkdownPreview.tsx b/frontend/src/components/MarkdownPreview.tsx index 27bce67..b340280 100644 --- a/frontend/src/components/MarkdownPreview.tsx +++ b/frontend/src/components/MarkdownPreview.tsx @@ -1,7 +1,40 @@ -import { useMemo } from 'react'; -import { marked } from 'marked'; +import { useCallback, useMemo } from 'react'; +import { marked, Renderer } from 'marked'; import DOMPurify from 'dompurify'; +function esc(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +const renderer = new Renderer(); +(renderer as unknown as Record).code = function ({ + text, + lang, +}: { + text: string; + lang?: string; +}) { + const language = (lang ?? '').split(/\s+/)[0]; + const lines = text.replace(/\n$/, '').split('\n'); + const linesHtml = lines + .map((line) => `${esc(line) || '​'}`) + .join('\n'); + const langLabel = language + ? `${esc(language)}` + : ''; + return ( + `
` + + `
${langLabel}` + + `
` + + `
${linesHtml}
` + + `
` + ); +}; + interface MarkdownPreviewProps { content: string; isMarkdown: boolean; @@ -9,15 +42,34 @@ interface MarkdownPreviewProps { export default function MarkdownPreview({ content, isMarkdown }: MarkdownPreviewProps) { const html = useMemo(() => { - const rawHtml = isMarkdown ? (marked.parse(content, { async: false }) as string) : content; - return DOMPurify.sanitize(rawHtml); + const rawHtml = isMarkdown + ? (marked.parse(content, { async: false, renderer }) as string) + : content; + return DOMPurify.sanitize(rawHtml, { ADD_TAGS: ['button'] }); }, [content, isMarkdown]); + const handleClick = useCallback((e: React.MouseEvent) => { + const btn = (e.target as HTMLElement).closest('.copy-btn'); + if (!btn) return; + const code = btn.closest('.code-block')?.querySelector('code'); + if (!code) return; + navigator.clipboard.writeText(code.textContent ?? '').then(() => { + const prev = btn.textContent; + btn.textContent = '已复制'; + btn.classList.add('copied'); + setTimeout(() => { + btn.textContent = prev; + btn.classList.remove('copied'); + }, 1500); + }); + }, []); + return (
); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8ba3ad8..70d4076 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -571,6 +571,85 @@ body { padding: 0; } +/* ---------- Code block with header ---------- */ +.code-block { + margin: 0.8em 0; + border: 1px solid var(--rule); + border-radius: var(--radius); + overflow: hidden; +} + +.code-header { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--paper-alt); + border-bottom: 1px solid var(--rule); + padding: 5px 12px; +} + +.code-lang { + font-family: 'IBM Plex Mono', monospace; + font-size: 11px; + color: var(--pine); + background: var(--pine-soft); + border: 1px solid rgba(47, 93, 80, 0.18); + border-radius: 4px; + padding: 1px 7px; + letter-spacing: 0.03em; +} + +.copy-btn { + font-family: 'IBM Plex Mono', monospace; + font-size: 11px; + color: var(--ash); + background: none; + border: 1px solid var(--rule); + border-radius: 4px; + padding: 2px 9px; + cursor: pointer; + transition: color 0.1s, border-color 0.1s; +} + +.copy-btn:hover { color: var(--ink); border-color: var(--ash); } +.copy-btn.copied { color: var(--pine); border-color: var(--pine); } + +.code-block pre { + margin: 0; + background: var(--paper-alt); + border: none; + border-radius: 0; + padding: 14px 0; + overflow-x: auto; + counter-reset: line; +} + +.code-block pre code { + display: block; + padding: 0; + background: none; +} + +.code-line { + display: block; + padding: 0 16px 0 0; + counter-increment: line; + line-height: 1.6; +} + +.code-line::before { + content: counter(line); + display: inline-block; + width: 36px; + padding-right: 16px; + text-align: right; + color: var(--ash-faint); + font-size: 12px; + user-select: none; + border-right: 1px solid var(--rule); + margin-right: 16px; +} + .markdown-preview ul, .markdown-preview ol { padding-left: 1.6em;