add LaTeX 公式的支持

This commit is contained in:
tianyaxiang 2025-02-02 16:26:45 +08:00
parent 7ae14c5599
commit 1a02300026
7 changed files with 121 additions and 3 deletions

View File

@ -26,10 +26,12 @@
"@tiptap/pm": "^2.2.4",
"@tiptap/react": "^2.2.4",
"@tiptap/starter-kit": "^2.2.4",
"@types/katex": "^0.16.7",
"@types/marked": "^6.0.0",
"@types/prismjs": "^1.26.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"katex": "^0.16.21",
"lucide-react": "^0.474.0",
"marked": "^15.0.6",
"next": "14.1.0",

View File

@ -59,6 +59,9 @@ importers:
'@tiptap/starter-kit':
specifier: ^2.2.4
version: 2.11.3
'@types/katex':
specifier: ^0.16.7
version: 0.16.7
'@types/marked':
specifier: ^6.0.0
version: 6.0.0
@ -71,6 +74,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
katex:
specifier: ^0.16.21
version: 0.16.21
lucide-react:
specifier: ^0.474.0
version: 0.474.0(react@18.3.1)
@ -819,6 +825,9 @@ packages:
'@tiptap/starter-kit@2.11.3':
resolution: {integrity: sha512-UGKS6+TA/7yMGqHBK5S/Kxis6iy3Tw0gvVg1EkYHUmkApLJypE87wUMkIeLeD9dd5+2WkxWcYMhC9R3ByjulBg==}
'@types/katex@0.16.7':
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
@ -976,6 +985,10 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
commander@8.3.0:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
@ -1165,6 +1178,10 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
katex@0.16.21:
resolution: {integrity: sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==}
hasBin: true
kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
@ -2437,6 +2454,8 @@ snapshots:
'@tiptap/extension-text-style': 2.11.3(@tiptap/core@2.11.3(@tiptap/pm@2.11.3))
'@tiptap/pm': 2.11.3
'@types/katex@0.16.7': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
@ -2584,6 +2603,8 @@ snapshots:
commander@4.1.1: {}
commander@8.3.0: {}
crelt@1.0.6: {}
cross-spawn@7.0.6:
@ -2754,6 +2775,10 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
katex@0.16.21:
dependencies:
commander: 8.3.0
kleur@3.0.3: {}
lilconfig@3.1.3: {}

View File

@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import '@/styles/code-themes.css'
import 'katex/dist/katex.min.css'
import { ThemeProvider } from '@/components/theme/ThemeProvider'
import { cn } from '@/lib/utils'
import { Toaster } from '@/components/ui/toaster'

View File

@ -58,11 +58,18 @@ const cheatSheet = [
},
]
},
{
title: '数学公式',
items: [
{ label: '行内公式', syntax: '$E = mc^2$' },
{ label: '行间公式', syntax: '$$\\frac{n!}{k!(n-k)!} = \\binom{n}{k}$$' },
{ label: '带反引号的行间公式', syntax: '$$`\\sum_{i=1}^n a_i = \\int_0^{\\pi} \\sin(x) dx`$$' },
]
},
{
title: '其他',
items: [
{ label: '水平分割线', syntax: '---' },
{ label: '数学公式', syntax: '$E = mc^2$' },
{ label: '注脚', syntax: '这里是文字[^1]\n\n[^1]: 这里是注脚' },
]
},

View File

@ -49,6 +49,11 @@ export const defaultOptions: RendererOptions = {
borderTop: '1px solid #eee',
fontSize: '0.9em',
color: '#666'
},
latex: {
margin: '1em 0',
fontSize: '1.1em',
textAlign: 'center'
}
},
inline: {
@ -59,6 +64,9 @@ export const defaultOptions: RendererOptions = {
checkbox: {
marginRight: '0.5em',
verticalAlign: 'middle'
},
latex: {
fontSize: '1.1em'
}
}
}

View File

@ -1,8 +1,16 @@
import { marked } from 'marked'
import type { Tokens } from 'marked'
import type { Tokens, TokenizerAndRendererExtension } from 'marked'
import type { RendererOptions } from './types'
import { cssPropertiesToString } from './styles'
import { highlightCode } from './code-highlight'
import katex from 'katex'
// 自定义 LaTeX 块的 Token 类型
interface LatexBlockToken extends Tokens.Generic {
type: 'latexBlock'
raw: string
text: string
}
export class MarkdownRenderer {
private renderer: typeof marked.Renderer.prototype
@ -12,9 +20,74 @@ export class MarkdownRenderer {
this.options = options
this.renderer = new marked.Renderer()
this.initializeRenderer()
this.initializeLatexExtension()
}
private initializeLatexExtension() {
// 添加 LaTeX 块的 tokenizer
const latexBlockTokenizer: TokenizerAndRendererExtension = {
name: 'latexBlock',
level: 'block',
start(src: string) {
return src.match(/^\$\$\n/)?.index
},
tokenizer(src: string) {
const rule = /^\$\$\n([\s\S]*?)\n\$\$/
const match = rule.exec(src)
if (match) {
return {
type: 'latexBlock',
raw: match[0],
tokens: [],
text: match[1].trim()
}
}
},
renderer: (token) => {
try {
const latexStyle = (this.options.block?.latex || {})
const style = {
...latexStyle,
display: 'block',
margin: '1em 0',
textAlign: 'center'
}
const styleStr = cssPropertiesToString(style)
const rendered = katex.renderToString(token.text, {
displayMode: true,
throwOnError: false
})
return `<div${styleStr ? ` style="${styleStr}"` : ''}>${rendered}</div>`
} catch (error) {
console.error('LaTeX rendering error:', error)
return token.raw
}
}
}
// 注册扩展
marked.use({ extensions: [latexBlockTokenizer] })
}
private initializeRenderer() {
// 重写 text 方法来处理行内 LaTeX 公式
this.renderer.text = (token: Tokens.Text | Tokens.Escape) => {
// 处理行内公式 $...$ 和行间公式 $$`...`$$
return token.text.replace(/\$\$`([^`]+)`\$\$|\$([^$\n]+?)\$/g, (match, backtick, inline) => {
try {
const formula = backtick || inline
const isDisplayMode = !!backtick
return katex.renderToString(formula.trim(), {
displayMode: isDisplayMode,
throwOnError: false
})
} catch (error) {
console.error('LaTeX rendering error:', error)
return match
}
})
}
// 重写 heading 方法
this.renderer.heading = ({ text, depth }: Tokens.Heading) => {
const headingKey = `h${depth}` as keyof RendererOptions['block']
@ -208,4 +281,4 @@ export class MarkdownRenderer {
public getRenderer(): typeof marked.Renderer.prototype {
return this.renderer
}
}
}

View File

@ -110,6 +110,7 @@ export interface RendererOptions {
td?: StyleOptions
thead?: StyleOptions
footnotes?: StyleOptions
latex?: StyleOptions
}
inline?: {
strong?: StyleOptions
@ -120,6 +121,7 @@ export interface RendererOptions {
checkbox?: StyleOptions
del?: StyleOptions
footnote?: StyleOptions
latex?: StyleOptions
}
codeTheme?: CodeThemeId
}