chore: render katex in markdown

This commit is contained in:
jialin
2025-02-18 19:51:22 +08:00
parent ea34431cf7
commit 4c77151877
26 changed files with 693 additions and 208 deletions
+89 -32
View File
@@ -1,6 +1,7 @@
import classNames from 'classnames';
import hljs from 'highlight.js';
import { memo, useMemo } from 'react';
import styled from 'styled-components';
import CopyButton from '../copy-button';
import { escapeHtml } from './utils';
@@ -15,9 +16,64 @@ interface CodeViewerProps {
theme?: 'light' | 'dark';
style?: React.CSSProperties;
}
interface CodeHeaderProps {
copyValue: string;
copyable: boolean;
lang: string;
theme: 'light' | 'dark';
}
const CodeHeaderWrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
height: 32px;
padding: 0 12px;
font-size: 12px;
color: var(--ant-color-text-tertiary);
background-color: #fafafa;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
&.dark {
background-color: #383838;
color: rgba(255, 255, 255, 0.65);
}
`;
const Wrapper = styled.div`
border-radius: var(--border-radius-mini);
`;
const CodeHeader: React.FC<CodeHeaderProps> = ({
copyValue,
lang,
theme,
copyable
}) => {
if (!copyable) {
return null;
}
return (
<CodeHeaderWrapper
className={classNames({
dark: theme === 'dark',
light: theme === 'light'
})}
>
<span>{lang}</span>
<CopyButton
text={copyValue}
size="small"
style={{ color: '#abb2bf' }}
></CopyButton>
</CodeHeaderWrapper>
);
};
const CodeViewer: React.FC<CodeViewerProps> = (props) => {
const {
code,
code = '',
copyValue,
lang,
autodetect = true,
@@ -63,38 +119,39 @@ const CodeViewer: React.FC<CodeViewerProps> = (props) => {
}, [code, lang, autodetect, ignoreIllegals]);
return (
<pre
className={classNames(
'code-pre custome-scrollbar custom-scrollbar-horizontal ',
{
dark: props.theme === 'dark',
light: props.theme === 'light',
copyable: copyable
}
)}
style={{
height: height,
...style
}}
>
<code
style={{ minHeight: height }}
className={classNames(highlightedCode.className, {
dark: props.theme === 'dark',
light: props.theme === 'light'
})}
dangerouslySetInnerHTML={{
__html: highlightedCode.value
<Wrapper>
<CodeHeader
copyValue={copyValue || code}
lang={lang}
copyable={copyable}
theme={props.theme || 'light'}
></CodeHeader>
<pre
className={classNames(
'code-pre custome-scrollbar custom-scrollbar-horizontal ',
{
dark: props.theme === 'dark',
light: props.theme === 'light'
}
)}
style={{
marginBottom: 0,
height: height,
...style
}}
></code>
{copyable && (
<CopyButton
text={copyValue || code}
size="small"
style={{ color: '#abb2bf' }}
></CopyButton>
)}
</pre>
>
<code
style={{ minHeight: height }}
className={classNames(highlightedCode.className, {
dark: props.theme === 'dark',
light: props.theme === 'light'
})}
dangerouslySetInnerHTML={{
__html: highlightedCode.value
}}
></code>
</pre>
</Wrapper>
);
};
@@ -41,8 +41,8 @@
.code-pre {
padding-inline: 12px 12px;
border-radius: 0 0 var(--border-radius-mini) var(--border-radius-mini);
position: relative;
border-radius: var(--border-radius-mini);
code {
line-height: 1.5;
@@ -0,0 +1,71 @@
import { sanitizeUrl } from '@braintree/sanitize-url';
import 'katex/dist/katex.min.css';
import React, { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import styled from 'styled-components';
import HighlightCode from '../highlight-code';
import './index.less';
import { escapeBrackets, escapeDollarNumber, escapeMhchem } from './utils';
interface FullMarkdownProps {
content: string;
theme?: 'light' | 'dark';
}
const Wrapper = styled.div.attrs(() => ({
className: 'markdown-viewer'
}))`
line-height: 2;
`;
const CodeViewer = (props: any) => {
const { children, className, node, theme, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
return match ? (
<HighlightCode code={children} lang={match[1]} theme={theme} />
) : (
<code {...rest} className={className}>
{children}
</code>
);
};
const FullMarkdown: React.FC<FullMarkdownProps> = ({
content,
theme = 'light'
}) => {
const escapedContent = useMemo(() => {
return escapeMhchem(escapeBrackets(escapeDollarNumber(content)));
}, [content]);
return (
<Wrapper>
<ReactMarkdown
remarkPlugins={[[remarkMath], remarkGfm]}
rehypePlugins={[rehypeKatex]}
components={{
code(props) {
return <CodeViewer {...props} theme={theme}></CodeViewer>;
},
a: ({ href, children, ...props }) => (
<a
href={sanitizeUrl(href)}
{...props}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
)
}}
>
{escapedContent}
</ReactMarkdown>
</Wrapper>
);
};
export default FullMarkdown;
@@ -22,6 +22,7 @@
font-weight: 700;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-small);
margin-top: 1em;
}
.hj-wrapper {
+8 -1
View File
@@ -1,4 +1,5 @@
import { EyeOutlined } from '@ant-design/icons';
import { sanitizeUrl } from '@braintree/sanitize-url';
import { Checkbox, Image, Typography } from 'antd';
import { unescape } from 'lodash';
import { TokensList, marked } from 'marked';
@@ -52,7 +53,8 @@ const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
}, []);
const generateImgSrc = useCallback(
(src: string | null) => {
(url: string | null) => {
const src = sanitizeUrl(url || '');
if (!src) {
return '';
}
@@ -186,6 +188,11 @@ const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
return;
}
const imgs = document.querySelectorAll('.markdown-viewer img');
const links = document.querySelectorAll('.markdown-viewer a');
links.forEach((link) => {
// set target blank for all links
link.setAttribute('target', '_blank');
});
imgs.forEach((img) => {
const src = img.getAttribute('src');
img.setAttribute('src', generateImgSrc(src));
+39
View File
@@ -14,3 +14,42 @@ export const unescape = (str = '') => {
? str.replace(reEscapedHtml, (entity) => htmlUnescapes[entity] || "'")
: str;
};
export function escapeDollarNumber(text: string) {
let escapedText = '';
for (let i = 0; i < text.length; i += 1) {
let char = text[i];
const nextChar = text[i + 1] || ' ';
if (char === '$' && nextChar >= '0' && nextChar <= '9') {
char = '\\$';
}
escapedText += char;
}
return escapedText;
}
export function escapeBrackets(text: string) {
const pattern =
/(```[\S\s]*?```|`.*?`)|\\\[([\S\s]*?[^\\])\\]|\\\((.*?)\\\)/g;
return text.replaceAll(
pattern,
(match, codeBlock, squareBracket, roundBracket) => {
if (codeBlock) {
return codeBlock;
} else if (squareBracket) {
return `$$${squareBracket}$$`;
} else if (roundBracket) {
return `$${roundBracket}$`;
}
return match;
}
);
}
export function escapeMhchem(text: string) {
return text.replaceAll('$\\ce{', '$\\\\ce{').replaceAll('$\\pu{', '$\\\\pu{');
}