feat: équations mathématiques (KaTeX) — bloc, inline, barre visuelle, IA
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m28s
CI / Deploy production (on server) (push) Has been skipped

- Bloc équation avec barre visuelle 20 symboles (fractions, intégrales, grec, etc.)
- Math en ligne : tape $x^2$ → rendu KaTeX inline automatique
- Math en bloc : tape $$E=mc^2$$ → converti en bloc
- Génération IA : décrit l'équation en langage naturel → LaTeX
- Service math-from-text + endpoint /api/ai/math-from-text
- CSS KaTeX importé
- i18n FR/EN complet
This commit is contained in:
Antigravity
2026-06-14 18:21:46 +00:00
parent 83110200d5
commit d5b409c1ac
7 changed files with 485 additions and 1 deletions

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { mathFromTextService } from '@/lib/ai/services/math-from-text.service'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { description } = await request.json()
if (!description || typeof description !== 'string' || !description.trim()) {
return NextResponse.json({ error: 'Description is required' }, { status: 400 })
}
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
return NextResponse.json(
{ error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded', upgradeTier: err.upgradeTier, quotaExceeded: true },
{ status: 402 },
)
}
throw err
}
const latex = await mathFromTextService.generate(description)
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json({ latex })
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Failed to generate LaTeX' }, { status: 500 })
}
}

View File

@@ -2,6 +2,7 @@
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@import "tw-animate-css"; @import "tw-animate-css";
@import "vazirmatn/Vazirmatn-font-face.css"; @import "vazirmatn/Vazirmatn-font-face.css";
@import "katex/dist/katex.min.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));

View File

@@ -31,6 +31,7 @@ import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension
import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension' import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension'
import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar' import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar'
import { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-extension' import { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-extension'
import { MathEquationExtension, InlineMathExtension, insertMathEquation } from './tiptap-math-extension'
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension' import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
import { ClipArticleExtension } from './tiptap-clip-article-extension' import { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker' import { BlockPicker, type BlockSuggestion } from './block-picker'
@@ -67,7 +68,7 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus, Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database, SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -226,6 +227,10 @@ const slashCommands: SlashItem[] = [
window.dispatchEvent(new CustomEvent('memento-open-link-preview')) window.dispatchEvent(new CustomEvent('memento-open-link-preview'))
}, },
}, },
{
title: 'Math', description: 'LaTeX equation block', icon: FunctionSquare, category: 'Basic blocks', shortcut: '$$',
command: (e) => { insertMathEquation(e) },
},
] ]
async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> { async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
@@ -473,6 +478,8 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
OutlineExtension, OutlineExtension,
FindReplaceExtension, FindReplaceExtension,
LinkPreviewExtension, LinkPreviewExtension,
MathEquationExtension,
InlineMathExtension,
ClipArticleExtension, ClipArticleExtension,
RtlPreserveExtension, RtlPreserveExtension,
Placeholder.configure({ Placeholder.configure({
@@ -1695,6 +1702,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] }, { ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] }, { ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
{ ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] }, { ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
{ ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
{ {
title: t('richTextEditor.slashNoteLink'), title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'), description: t('richTextEditor.slashNoteLinkDesc'),

View File

@@ -0,0 +1,373 @@
'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from '@tiptap/react'
import { Trash2, X, FunctionSquare, Sparkles, Loader2 } from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import katex from 'katex'
function renderKatex(latex: string, display: boolean): { html: string } {
try {
const html = katex.renderToString(latex, {
displayMode: display,
throwOnError: false,
errorColor: '#ef4444',
})
return { html }
} catch {
return { html: '' }
}
}
const MATH_BUTTONS: Array<{ label: string; insert: string; title: string }> = [
{ label: '½', insert: '\\frac{}{}', title: 'Fraction' },
{ label: '√', insert: '\\sqrt{}', title: 'Racine' },
{ label: '∑', insert: '\\sum_{}^{}', title: 'Somme' },
{ label: '∫', insert: '\\int_{}^{}', title: 'Intégrale' },
{ label: 'x²', insert: '^{}', title: 'Puissance' },
{ label: 'x₂', insert: '_{}', title: 'Indice' },
{ label: '×', insert: '\\times', title: 'Multiplication' },
{ label: '÷', insert: '\\div', title: 'Division' },
{ label: '±', insert: '\\pm', title: 'Plus ou moins' },
{ label: '≤', insert: '\\leq', title: 'Inférieur ou égal' },
{ label: '≥', insert: '\\geq', title: 'Supérieur ou égal' },
{ label: '≠', insert: '\\neq', title: 'Différent' },
{ label: '→', insert: '\\rightarrow', title: 'Flèche droite' },
{ label: '∞', insert: '\\infty', title: 'Infini' },
{ label: 'α', insert: '\\alpha', title: 'Alpha' },
{ label: 'β', insert: '\\beta', title: 'Beta' },
{ label: 'γ', insert: '\\gamma', title: 'Gamma' },
{ label: 'π', insert: '\\pi', title: 'Pi' },
{ label: 'θ', insert: '\\theta', title: 'Theta' },
{ label: 'Δ', insert: '\\Delta', title: 'Delta' },
]
const MathEquationView = ({ node, updateAttributes, deleteNode, selected }: any) => {
const { t } = useLanguage()
const latex = node.attrs.latex || ''
const [editing, setEditing] = useState(!latex)
const [input, setInput] = useState(latex)
const [aiLoading, setAiLoading] = useState(false)
const [aiInput, setAiInput] = useState('')
const [showAi, setShowAi] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const renderRef = useRef<HTMLDivElement>(null)
const { html } = renderKatex(latex, true)
useEffect(() => {
if (editing && inputRef.current) inputRef.current.focus()
}, [editing])
useEffect(() => {
if (renderRef.current && html) renderRef.current.innerHTML = html
}, [html])
const commit = () => {
updateAttributes({ latex: input })
if (input.trim()) setEditing(false)
}
const insertSymbol = (symbol: string) => {
const el = inputRef.current
if (!el) { setInput(prev => prev + symbol); return }
const start = el.selectionStart
const end = el.selectionEnd
const newVal = input.slice(0, start) + symbol + input.slice(end)
setInput(newVal)
setTimeout(() => {
el.focus()
const cursorPos = start + symbol.length
if (symbol.includes('{}')) {
const braceIdx = symbol.indexOf('{')
el.setSelectionRange(start + braceIdx + 1, start + braceIdx + 1)
} else {
el.setSelectionRange(cursorPos, cursorPos)
}
}, 0)
}
const handleAiGenerate = async () => {
if (!aiInput.trim()) return
setAiLoading(true)
try {
const res = await fetch('/api/ai/math-from-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: aiInput }),
})
const data = await res.json()
if (res.ok && data.latex) {
setInput(data.latex)
setShowAi(false)
setAiInput('')
}
} catch (e) {
console.error(e)
} finally {
setAiLoading(false)
}
}
const livePreview = renderKatex(input, true).html
return (
<NodeViewWrapper className="math-block my-3" dir="auto" data-selected={selected}>
{editing ? (
<div className="rounded-xl border border-border bg-card p-3 space-y-2">
{/* Toolbar */}
<div className="flex flex-wrap gap-1 pb-2 border-b border-border/30">
{MATH_BUTTONS.map(btn => (
<button
key={btn.label}
type="button"
onClick={() => insertSymbol(btn.insert)}
className="w-7 h-7 flex items-center justify-center rounded-md text-sm hover:bg-muted transition-colors border border-border/30 font-serif"
title={btn.title}
>
{btn.label}
</button>
))}
</div>
{/* AI generation */}
{showAi ? (
<div className="flex items-center gap-1.5 rounded-lg bg-brand-accent/5 border border-brand-accent/20 p-2">
<Sparkles size={14} className="text-brand-accent flex-shrink-0" />
<input
type="text"
value={aiInput}
onChange={(e) => setAiInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAiGenerate() } }}
placeholder={t('richTextEditor.mathAiPlaceholder')}
className="flex-1 min-w-0 bg-transparent text-sm outline-none"
autoFocus
/>
{aiLoading ? (
<Loader2 size={14} className="animate-spin text-brand-accent" />
) : (
<button
type="button"
onClick={handleAiGenerate}
disabled={!aiInput.trim()}
className="text-xs font-medium text-brand-accent hover:underline disabled:opacity-40"
>
{t('richTextEditor.mathConfirm')}
</button>
)}
<button type="button" onClick={() => setShowAi(false)} className="p-0.5 text-muted-foreground hover:text-foreground">
<X size={14} />
</button>
</div>
) : (
<button
type="button"
onClick={() => setShowAi(true)}
className="flex items-center gap-1.5 text-[11px] text-brand-accent hover:underline font-medium"
>
<Sparkles size={12} />
{t('richTextEditor.mathAi')}
</button>
)}
{/* LaTeX input */}
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); commit() }
if (e.key === 'Escape' && latex) { setInput(latex); setEditing(false) }
}}
placeholder={t('richTextEditor.mathPlaceholder')}
className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm resize-none"
rows={2}
/>
{/* Live preview */}
{input.trim() && (
<div className="rounded-lg bg-muted/30 px-4 py-3 overflow-x-auto text-center" ref={(el) => { if (el) el.innerHTML = livePreview }} />
)}
{/* Actions */}
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-0.5">
{latex && (
<button onClick={() => { setInput(latex); setEditing(false) }} className="p-1 rounded hover:bg-muted text-muted-foreground" title={t('richTextEditor.mathCancel')}>
<X className="h-3.5 w-3.5" />
</button>
)}
<button onClick={deleteNode} className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive" title={t('richTextEditor.mathDelete')}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<button
onClick={commit}
disabled={!input.trim()}
className="px-3 py-1 text-xs rounded-md bg-brand-accent text-white hover:bg-brand-accent/90 disabled:opacity-40 transition-colors font-medium"
>
{t('richTextEditor.mathConfirm')}
</button>
</div>
</div>
) : (
<div className={cn('group relative rounded-xl border bg-card overflow-hidden', selected ? 'border-primary/40 ring-2 ring-primary/20' : 'border-border')}>
<div ref={renderRef} className="px-6 py-4 overflow-x-auto text-center" />
<div className="absolute top-1.5 right-1.5 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => setEditing(true)} className="px-2 py-0.5 text-[10px] rounded-md bg-background/80 backdrop-blur hover:bg-muted text-muted-foreground">
{t('richTextEditor.mathEdit')}
</button>
<button onClick={deleteNode} className="p-1 rounded-md bg-background/80 backdrop-blur hover:bg-destructive/10 text-muted-foreground hover:text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
</NodeViewWrapper>
)
}
// Inline math node — renders $x^2$ inline within text
const InlineMathView = ({ node }: any) => {
const ref = useRef<HTMLSpanElement>(null)
const latex = node.attrs.latex || ''
useEffect(() => {
if (ref.current) {
const { html } = renderKatex(latex, false)
ref.current.innerHTML = html
}
}, [latex])
return <NodeViewWrapper as="span" ref={ref} className="inline-math" />
}
export const MathEquationExtension = Node.create({
name: 'mathEquationBlock',
group: 'block',
atom: true,
defining: true,
addAttributes() {
return {
latex: {
default: '',
parseHTML: (el) => el.getAttribute('data-latex') || el.textContent || '',
renderHTML: (attrs) => ({ 'data-latex': attrs.latex }),
},
}
},
parseHTML() {
return [
{ tag: 'div[data-type="math-equation"]' },
]
},
renderHTML({ node, HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-type': 'math-equation',
'data-latex': node.attrs.latex,
class: 'math-equation-block',
}),
node.attrs.latex || '',
]
},
addNodeView() {
return ReactNodeViewRenderer(MathEquationView)
},
addInputRules() {
return [
{
find: /\$\$([^$]+)\$\$$/,
handler: ({ state, range, match }) => {
const latex = match[1]
const tr = state.tr
tr.deleteRange(range.from, range.to)
tr.insert(range.from, state.schema.nodes.mathEquationBlock.create({ latex }))
},
},
]
},
addKeyboardShortcuts() {
return {
'Mod-Shift-M': () => this.editor.commands.insertContent({
type: this.name,
attrs: { latex: '' },
}),
}
},
})
// Inline math mark — detects $...$ in text
export const InlineMathExtension = Node.create({
name: 'inlineMath',
group: 'inline',
inline: true,
atom: true,
addAttributes() {
return {
latex: {
default: '',
parseHTML: (el) => el.getAttribute('data-latex') || el.textContent || '',
renderHTML: (attrs) => ({ 'data-latex': attrs.latex }),
},
}
},
parseHTML() {
return [{ tag: 'span[data-type="inline-math"]' }]
},
renderHTML({ node, HTMLAttributes }) {
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-type': 'inline-math',
'data-latex': node.attrs.latex,
class: 'inline-math',
}),
node.attrs.latex || '',
]
},
addNodeView() {
return ReactNodeViewRenderer(InlineMathView)
},
addInputRules() {
return [
{
find: /(?:^|\s)\$([^$\n]+)\$$/,
handler: ({ state, range, match }) => {
const fullMatch = match[0]
const latex = match[1]
const leadingSpace = fullMatch.startsWith(' ') ? ' ' : ''
const start = range.from + (leadingSpace ? 1 : 0)
const tr = state.tr
tr.deleteRange(range.from, range.to)
tr.insert(range.from, [
...(leadingSpace ? [state.schema.text(' ')] : []),
state.schema.nodes.inlineMath.create({ latex }),
])
},
},
]
},
})
export function insertMathEquation(editor: any) {
editor.chain().focus().insertContent({
type: 'mathEquationBlock',
attrs: { latex: '' },
}).run()
}

View File

@@ -0,0 +1,44 @@
import { getChatProvider } from '../factory'
import { getSystemConfig } from '@/lib/config'
export class MathFromTextService {
async generate(description: string): Promise<string> {
if (!description?.trim()) {
throw new Error('Description is required')
}
const systemPrompt = `You are a mathematician and LaTeX expert.
Convert the user's natural language description into a single valid LaTeX equation.
CRITICAL RULES:
- Output ONLY the raw LaTeX code — no preamble, no explanation, no markdown fence, no \\[ or \\( delimiters.
- Use standard amsmath/amssymb syntax.
- Never output \\documentclass, \\begin{document}, or $$ delimiters.
Examples:
- "the quadratic formula" -> x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
- "Pythagorean theorem" -> a^2 + b^2 = c^2
- "Euler's identity" -> e^{i\\pi} + 1 = 0
- "la dérivée de x au carré" -> \\frac{d}{dx} x^2 = 2x`
const userPrompt = `Convert this to LaTeX:\n\n${description}`
const config = await getSystemConfig()
const provider = getChatProvider(config)
const fullPrompt = `${systemPrompt}\n\n${userPrompt}`
const raw = await provider.generateText(fullPrompt)
return this.extractLatex(raw)
}
private extractLatex(response: string): string {
const codeBlock = response.match(/```(?:latex|tex|math)?\n([\s\S]+?)\n```/)
if (codeBlock) return codeBlock[1].trim()
return response
.replace(/^\\\[|\\\]$/g, '')
.replace(/^\\\(|\\\)$/g, '')
.replace(/^\$\$|\$\$$/g, '')
.trim()
}
}
export const mathFromTextService = new MathFromTextService()

View File

@@ -2435,6 +2435,16 @@
"linkPreviewUnwrap": "Revert to simple link", "linkPreviewUnwrap": "Revert to simple link",
"linkPreviewDelete": "Delete preview", "linkPreviewDelete": "Delete preview",
"smartPasteUrlPreview": "Paste as preview card", "smartPasteUrlPreview": "Paste as preview card",
"slashMath": "Equation",
"slashMathDesc": "Mathematical formula in LaTeX notation",
"mathLatex": "LaTeX notation",
"mathPlaceholder": "\\frac{1}{2} + \\sum_{i=1}^{n} x_i",
"mathConfirm": "Confirm",
"mathEdit": "Edit",
"mathDelete": "Delete",
"mathCancel": "Cancel",
"mathAi": "Write with AI",
"mathAiPlaceholder": "Describe the equation in words... (e.g: quadratic formula)",
"calloutDelete": "Delete callout", "calloutDelete": "Delete callout",
"calloutUnwrap": "Disable callout", "calloutUnwrap": "Disable callout",
"calloutInfo": "Information", "calloutInfo": "Information",

View File

@@ -2439,6 +2439,16 @@
"linkPreviewUnwrap": "Revenir au lien simple", "linkPreviewUnwrap": "Revenir au lien simple",
"linkPreviewDelete": "Supprimer l'aperçu", "linkPreviewDelete": "Supprimer l'aperçu",
"smartPasteUrlPreview": "Coller comme carte aperçu", "smartPasteUrlPreview": "Coller comme carte aperçu",
"slashMath": "Équation",
"slashMathDesc": "Formule mathématique en notation LaTeX",
"mathLatex": "Notation LaTeX",
"mathPlaceholder": "\\frac{1}{2} + \\sum_{i=1}^{n} x_i",
"mathConfirm": "Valider",
"mathEdit": "Modifier",
"mathDelete": "Supprimer",
"mathCancel": "Annuler",
"mathAi": "Écrire avec l'IA",
"mathAiPlaceholder": "Décris l'équation en mots... (ex: formule quadratique)",
"calloutDelete": "Supprimer l'encadré", "calloutDelete": "Supprimer l'encadré",
"calloutUnwrap": "Désactiver l'encadré", "calloutUnwrap": "Désactiver l'encadré",
"calloutInfo": "Information", "calloutInfo": "Information",