feat: équations mathématiques (KaTeX) — bloc, inline, barre visuelle, IA
- 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:
38
memento-note/app/api/ai/math-from-text/route.ts
Normal file
38
memento-note/app/api/ai/math-from-text/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 *));
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
373
memento-note/components/tiptap-math-extension.tsx
Normal file
373
memento-note/components/tiptap-math-extension.tsx
Normal 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()
|
||||||
|
}
|
||||||
44
memento-note/lib/ai/services/math-from-text.service.ts
Normal file
44
memento-note/lib/ai/services/math-from-text.service.ts
Normal 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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user