feat(editor): AI modal preview/apply, translate lang picker, fix p-tag injection, explain modal - no UTF-8 corruption

This commit is contained in:
2026-05-03 00:39:36 +02:00
parent 54b7b4fcf1
commit bd4034777c
3 changed files with 166 additions and 7 deletions

View File

@@ -276,6 +276,8 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
)
}
const AI_LANGS = ['Francais','English','Espanol','Deutsch','Arabe','Portugais','Italiano','Chinois','Japonais']
function BubbleToolbar({ editor }: { editor: Editor | null }) {
const { t, language } = useLanguage()
const [, setTick] = useState(0)
@@ -284,6 +286,9 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
const [linkOpen, setLinkOpen] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const linkInputRef = useRef<HTMLInputElement>(null)
const [translateOpen, setTranslateOpen] = useState(false)
const [customLang, setCustomLang] = useState('')
const [aiModal, setAiModal] = useState<{ type: 'explain' | 'preview'; origText: string; html: string; from: number; to: number } | null>(null)
useEffect(() => {
if (!editor) return
@@ -310,18 +315,20 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
{ icon: SubscriptIcon, active: editor.isActive('subscript'), action: () => editor.chain().focus().toggleSubscript().run(), title: t('richTextEditor.subscript') },
]
const handleAI = async (option: 'clarify' | 'shorten' | 'improve' | 'fix_grammar' | 'translate' | 'explain') => {
const handleAI = async (option: 'clarify' | 'shorten' | 'improve' | 'fix_grammar' | 'translate' | 'explain', targetLang?: string) => {
const { from, to } = editor.state.selection
const text = editor.state.doc.textBetween(from, to, ' ')
if (!text || text.split(/\s+/).length < 2) return
setAiLoading(true)
setAiOpen(false)
setTranslateOpen(false)
try {
const result = await aiReformulate(text, option, language)
const lang = option === 'translate' ? (targetLang || language) : language
const result = await aiReformulate(text, option, lang)
if (option === 'explain') {
toast.message(t('ai.action.explain'), { description: result, duration: 10000 })
setAiModal({ type: 'explain', origText: text, html: result, from, to })
} else {
editor.chain().focus().insertContentAt({ from, to }, result).run()
setAiModal({ type: 'preview', origText: text, html: result, from, to })
}
} catch (err) {
console.error('AI error:', err)
@@ -330,6 +337,13 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
}
}
const applyAiResult = () => {
if (!aiModal || !editor) { setAiModal(null); return }
const clean = aiModal.html.replace(/^<p>([\s\S]*)<\/p>$/, '$1').trim()
editor.chain().focus().insertContentAt({ from: aiModal.from, to: aiModal.to }, clean).run()
setAiModal(null)
}
const openLinkEditor = () => {
const existing = editor.getAttributes('link').href || ''
setLinkUrl(existing)
@@ -394,10 +408,54 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-blue-500" /><span>{t('richTextEditor.slashShorten')}</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('improve')}><Wand2 className="w-3.5 h-3.5 text-purple-500" /><span>{t('richTextEditor.slashImprove')}</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('fix_grammar')}><SpellCheck className="w-3.5 h-3.5 text-green-500" /><span>{t('ai.action.fixGrammar')}</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('translate')}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>
<button className="notion-ai-subitem" onClick={() => setTranslateOpen(v => !v)}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>
{translateOpen && (
<div className="notion-ai-lang-picker">
{AI_LANGS.map(l => (
<button key={l} className="notion-ai-lang-item" onClick={() => handleAI('translate', l)}>{l}</button>
))}
<div className="flex items-center gap-1 px-1 pt-1 w-full border-t border-border/40 mt-1">
<input
className="notion-inline-input text-xs flex-1 min-w-0"
placeholder={t('ai.action.customLang') || 'Autre...'}
value={customLang}
onChange={e => setCustomLang(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && customLang.trim()) handleAI('translate', customLang.trim()) }}
/>
</div>
</div>
)}
<button className="notion-ai-subitem" onClick={() => handleAI('explain')}><BookOpen className="w-3.5 h-3.5 text-orange-500" /><span>{t('ai.action.explain')}</span></button>
</div>
)}
{aiModal && (
<div className="notion-ai-result-overlay" onClick={() => setAiModal(null)}>
<div className="notion-ai-result-modal" onClick={e => e.stopPropagation()}>
<div className="notion-ai-result-header">
<span className="text-sm font-semibold">
{aiModal.type === 'explain' ? t('ai.action.explain') : (t('ai.result.preview') || 'Apercu IA')}
</span>
<button onClick={() => setAiModal(null)} className="notion-bubble-btn ml-auto"><X className="w-3.5 h-3.5" /></button>
</div>
{aiModal.type === 'preview' && (
<div className="notion-ai-result-section">
<span className="notion-ai-result-label">{t('ai.result.original') || 'Original'}</span>
<p className="text-sm text-muted-foreground line-through opacity-60 mt-1">{aiModal.origText}</p>
</div>
)}
<div className="notion-ai-result-section">
{aiModal.type === 'preview' && <span className="notion-ai-result-label">{t('ai.result.suggestion') || 'Suggestion'}</span>}
<div className="prose prose-sm max-w-none text-sm mt-1" dangerouslySetInnerHTML={{ __html: aiModal.html }} />
</div>
<div className="flex justify-end gap-2 mt-3 pt-3 border-t border-border/40">
<button onClick={() => setAiModal(null)} className="notion-modal-btn">{t('common.cancel') || 'Annuler'}</button>
{aiModal.type === 'preview' && (
<button onClick={applyAiResult} className="notion-modal-btn notion-modal-btn-primary">{t('ai.result.apply') || 'Appliquer'}</button>
)}
</div>
</div>
</div>
)}
</div>
)
}