feat: code blocks with language label, line numbers, and copy button

This commit is contained in:
Tianyang 2026-08-01 20:21:23 +08:00
parent 3d1ad7c4e4
commit d414ae01ba
2 changed files with 135 additions and 4 deletions

View File

@ -1,7 +1,40 @@
import { useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { marked } from 'marked'; import { marked, Renderer } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
function esc(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const renderer = new Renderer();
(renderer as unknown as Record<string, unknown>).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) => `<span class="code-line">${esc(line) || ''}</span>`)
.join('\n');
const langLabel = language
? `<span class="code-lang">${esc(language)}</span>`
: '';
return (
`<div class="code-block">` +
`<div class="code-header">${langLabel}` +
`<button class="copy-btn" type="button">复制</button></div>` +
`<pre><code class="${language ? `language-${esc(language)}` : ''}">${linesHtml}</code></pre>` +
`</div>`
);
};
interface MarkdownPreviewProps { interface MarkdownPreviewProps {
content: string; content: string;
isMarkdown: boolean; isMarkdown: boolean;
@ -9,15 +42,34 @@ interface MarkdownPreviewProps {
export default function MarkdownPreview({ content, isMarkdown }: MarkdownPreviewProps) { export default function MarkdownPreview({ content, isMarkdown }: MarkdownPreviewProps) {
const html = useMemo(() => { const html = useMemo(() => {
const rawHtml = isMarkdown ? (marked.parse(content, { async: false }) as string) : content; const rawHtml = isMarkdown
return DOMPurify.sanitize(rawHtml); ? (marked.parse(content, { async: false, renderer }) as string)
: content;
return DOMPurify.sanitize(rawHtml, { ADD_TAGS: ['button'] });
}, [content, isMarkdown]); }, [content, isMarkdown]);
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('.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 ( return (
<div <div
className="markdown-preview" className="markdown-preview"
data-testid="markdown-preview" data-testid="markdown-preview"
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
onClick={handleClick}
/> />
); );
} }

View File

@ -571,6 +571,85 @@ body {
padding: 0; 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 ul,
.markdown-preview ol { .markdown-preview ol {
padding-left: 1.6em; padding-left: 1.6em;