From d5b409c1aca4e68daa781a971bbc38e35cc8f4b1 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sun, 14 Jun 2026 18:21:46 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=C3=A9quations=20math=C3=A9matiques=20(?= =?UTF-8?q?KaTeX)=20=E2=80=94=20bloc,=20inline,=20barre=20visuelle,=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../app/api/ai/math-from-text/route.ts | 38 ++ memento-note/app/globals.css | 1 + memento-note/components/rich-text-editor.tsx | 10 +- .../components/tiptap-math-extension.tsx | 373 ++++++++++++++++++ .../lib/ai/services/math-from-text.service.ts | 44 +++ memento-note/locales/en.json | 10 + memento-note/locales/fr.json | 10 + 7 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 memento-note/app/api/ai/math-from-text/route.ts create mode 100644 memento-note/components/tiptap-math-extension.tsx create mode 100644 memento-note/lib/ai/services/math-from-text.service.ts diff --git a/memento-note/app/api/ai/math-from-text/route.ts b/memento-note/app/api/ai/math-from-text/route.ts new file mode 100644 index 0000000..1b28683 --- /dev/null +++ b/memento-note/app/api/ai/math-from-text/route.ts @@ -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 }) + } +} diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index 0ea8005..cfe4540 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -2,6 +2,7 @@ @plugin "@tailwindcss/typography"; @import "tw-animate-css"; @import "vazirmatn/Vazirmatn-font-face.css"; +@import "katex/dist/katex.min.css"; @custom-variant dark (&:is(.dark *)); diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index d32a542..34fb9fa 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -31,6 +31,7 @@ import { CalloutExtension, insertCalloutBlock } from './tiptap-callout-extension import { OutlineExtension, insertOutlineBlock } from './tiptap-outline-extension' import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar' 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 { ClipArticleExtension } from './tiptap-clip-article-extension' import { BlockPicker, type BlockSuggestion } from './block-picker' @@ -67,7 +68,7 @@ import { FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus, SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database, - ChevronsRightLeft, MessageSquareWarning, ListTree + ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare } from 'lucide-react' import { cn } from '@/lib/utils' import { toast } from 'sonner' @@ -226,6 +227,10 @@ const slashCommands: SlashItem[] = [ 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 { @@ -473,6 +478,8 @@ export const RichTextEditor = forwardRef = [ + { 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(null) + const renderRef = useRef(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 ( + + {editing ? ( +
+ {/* Toolbar */} +
+ {MATH_BUTTONS.map(btn => ( + + ))} +
+ + {/* AI generation */} + {showAi ? ( +
+ + 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 ? ( + + ) : ( + + )} + +
+ ) : ( + + )} + + {/* LaTeX input */} +