Files
Momento/memento-note/components/tiptap-math-extension.tsx
Antigravity d5b409c1ac
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m28s
CI / Deploy production (on server) (push) Has been skipped
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
2026-06-14 18:21:46 +00:00

374 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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()
}