diff --git a/package.json b/package.json index ea0ddde..37c90b7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61d6233..ec19471 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dd890b8..7c6540b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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' diff --git a/src/components/editor/components/MarkdownCheatSheet.tsx b/src/components/editor/components/MarkdownCheatSheet.tsx index 67ce302..72e3282 100644 --- a/src/components/editor/components/MarkdownCheatSheet.tsx +++ b/src/components/editor/components/MarkdownCheatSheet.tsx @@ -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]: 这里是注脚' }, ] }, diff --git a/src/lib/markdown/index.ts b/src/lib/markdown/index.ts index 5fe2a8f..b194ad7 100644 --- a/src/lib/markdown/index.ts +++ b/src/lib/markdown/index.ts @@ -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' } } } diff --git a/src/lib/markdown/renderer.ts b/src/lib/markdown/renderer.ts index dc61cbf..cb14f81 100644 --- a/src/lib/markdown/renderer.ts +++ b/src/lib/markdown/renderer.ts @@ -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 `${rendered}` + } 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 } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/lib/markdown/types.ts b/src/lib/markdown/types.ts index a8d8526..01e291a 100644 --- a/src/lib/markdown/types.ts +++ b/src/lib/markdown/types.ts @@ -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 } \ No newline at end of file