chore: render katex in markdown
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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{');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user