From 1a023000269d44c3eba8e22f5627575d68bed93e Mon Sep 17 00:00:00 2001
From: tianyaxiang <tianyaxiang@qq.com>
Date: Sun, 2 Feb 2025 16:26:45 +0800
Subject: [PATCH] =?UTF-8?q?add=20LaTeX=20=E5=85=AC=E5=BC=8F=E7=9A=84?=
 =?UTF-8?q?=E6=94=AF=E6=8C=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 package.json                                  |  2 +
 pnpm-lock.yaml                                | 25 ++++++
 src/app/layout.tsx                            |  1 +
 .../editor/components/MarkdownCheatSheet.tsx  |  9 ++-
 src/lib/markdown/index.ts                     |  8 ++
 src/lib/markdown/renderer.ts                  | 77 ++++++++++++++++++-
 src/lib/markdown/types.ts                     |  2 +
 7 files changed, 121 insertions(+), 3 deletions(-)

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 `<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
   }
-} 
\ 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