Files
Momento/memento-note/components/tiptap-math-extension.tsx
Antigravity 018db001b4
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
feat: sélection texte → Toggle/Callout dans le BubbleMenu
Quand l'utilisateur sélectionne du texte (1 ou plusieurs paragraphes),
la barre flottante affiche deux boutons:
- ChevronsRightLeft → enveloppe dans un Toggle
- MessageSquareWarning → enveloppe dans un Callout
Le contenu sélectionné devient le contenu du bloc.
2026-06-20 16:14:07 +00:00

365 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 }))
},
},
]
},
})
// 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()
}