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 { 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, '<')
|
||||||
|
.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 {
|
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user