feat: code blocks with language label, line numbers, and copy button
This commit is contained in:
parent
3d1ad7c4e4
commit
d414ae01ba
@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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 {
|
||||
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<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 (
|
||||
<div
|
||||
className="markdown-preview"
|
||||
data-testid="markdown-preview"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user