fix(editor): custom image width parsing, fix image paste, add AI submenu features

This commit is contained in:
2026-05-03 00:08:28 +02:00
parent d0387cd9a0
commit 54b7b4fcf1
19 changed files with 3890 additions and 3490 deletions

View File

@@ -12,7 +12,7 @@ import {
Lightbulb, Minimize2, AlignLeft, Wand2,
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
GitMerge, PlusCircle, Eye, Code,
GitMerge, PlusCircle, Eye, Code, Languages,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
@@ -64,6 +64,7 @@ const ACTION_IDS = [
{ id: 'clarify', icon: Lightbulb, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'clarify' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.clarify' },
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'shorten' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'improve' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
{ id: 'translate', icon: Languages, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string) => ({ text: content, option: 'translate', language: lang || 'fr' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.translate' },
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
{ id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
]
@@ -250,18 +251,18 @@ export function ContextualAIChat({
setResourceScraping(true)
try {
const result = await scrapePageText(resourceUrl.trim())
if (!result) { toast.error('Impossible de charger cette URL'); return }
if (!result) { toast.error(t('ai.resource.failedToLoadUrl')); return }
setResourceText(result.text)
toast.success(`Page chargée : ${result.title.slice(0, 40)}`)
toast.success(t('ai.resource.pageLoaded', { title: result.title.slice(0, 40) }))
} catch {
toast.error('Erreur lors du chargement de la page')
toast.error(t('ai.resource.pageLoadError'))
} finally {
setResourceScraping(false)
}
}
const handleResourcePreview = async () => {
if (!resourceText.trim()) { toast.error('Collez du texte ou chargez une URL d\'abord'); return }
if (!resourceText.trim()) { toast.error(t('ai.resource.pasteOrUrlFirst')); return }
if (resourceMode === 'replace') {
setResourcePreview({ text: resourceText, source: 'paste' })
return
@@ -279,10 +280,10 @@ export function ContextualAIChat({
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Erreur IA')
if (!res.ok) throw new Error(data.error || t('ai.resource.enrichError'))
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
} catch (e: any) {
toast.error(e.message || 'Erreur lors de l\'enrichissement')
toast.error(e.message || t('ai.resource.enrichError'))
} finally {
setResourceEnriching(false)
}
@@ -294,7 +295,7 @@ export function ContextualAIChat({
setResourcePreview(null)
setResourceText('')
setResourceUrl('')
toast.success('Contenu appliqué à la note ✓')
toast.success(t('ai.resource.contentApplied'))
}
/** Called from chat hover-actions: inject a chat message into the note */
@@ -326,7 +327,7 @@ export function ContextualAIChat({
if (!res.ok) throw new Error(data.error || 'Erreur IA')
setResourcePreview({ text: data.enrichedContent, source: mode })
} catch (e: any) {
toast.error(e.message || 'Erreur enrichissement')
toast.error(e.message || t('ai.resource.enrichErrorShort'))
} finally {
setResourceEnriching(false)
}
@@ -351,7 +352,7 @@ export function ContextualAIChat({
<div className="min-w-0">
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0" />
IA Note
{t('ai.aiNoteTitle')}
</h2>
<p className="text-xs text-muted-foreground truncate">
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
@@ -390,7 +391,7 @@ export function ContextualAIChat({
{([
{ id: 'chat', icon: Bot, label: t('ai.chatTab') },
{ id: 'actions', icon: Wand2, label: t('ai.noteActions') },
{ id: 'resource', icon: ArrowDownToLine, label: 'Ressource' },
{ id: 'resource', icon: ArrowDownToLine, label: t('ai.resourceTab') },
] as const).map(tab => (
<button
key={tab.id}
@@ -466,23 +467,23 @@ export function ContextualAIChat({
<button
onClick={() => handleInjectFromChat(content, 'replace')}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-primary/10 hover:text-primary border border-border/40 transition-colors"
title="Remplacer le contenu de la note par ce message"
title={t('ai.injectReplaceTitle')}
>
<Download className="h-2.5 w-2.5" /> Remplacer
<Download className="h-2.5 w-2.5" /> {t('ai.injectReplace')}
</button>
<button
onClick={() => handleInjectFromChat(content, 'complete')}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-950/30 border border-border/40 transition-colors"
title="Compléter la note avec ce message (IA)"
title={t('ai.injectCompleteTitle')}
>
<PlusCircle className="h-2.5 w-2.5" /> Compléter
<PlusCircle className="h-2.5 w-2.5" /> {t('ai.injectComplete')}
</button>
<button
onClick={() => handleInjectFromChat(content, 'merge')}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-violet-50 hover:text-violet-600 dark:hover:bg-violet-950/30 border border-border/40 transition-colors"
title="Fusionner avec la note (IA)"
title={t('ai.injectMergeTitle')}
>
<GitMerge className="h-2.5 w-2.5" /> Fusionner
<GitMerge className="h-2.5 w-2.5" /> {t('ai.injectMerge')}
</button>
</div>
)}
@@ -695,7 +696,7 @@ export function ContextualAIChat({
<div className="flex flex-col">
<span>{t(action.i18nKey)}</span>
{noteImages.length > 1 && (
<span className="text-[10px] text-muted-foreground">{noteImages.length} images</span>
<span className="text-[10px] text-muted-foreground">{t('ai.imagesCount', { count: noteImages.length })}</span>
)}
</div>
{loading && <span className="ml-auto text-[10px] text-muted-foreground">{t('ai.processingAction')}</span>}
@@ -760,10 +761,10 @@ export function ContextualAIChat({
<div className="px-4 py-2.5 border-b border-border/40 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<p className="text-xs font-semibold text-foreground">
{resourcePreview.source === 'chat' ? '💬 Depuis le chat'
: resourcePreview.source === 'replace' ? '↓ Remplacement'
: resourcePreview.source === 'complete' ? '✦ Complété par IA'
: '⟳ Fusionné par IA'}
{resourcePreview.source === 'chat' ? t('ai.resource.fromChat')
: resourcePreview.source === 'replace' ? t('ai.resource.replacement')
: resourcePreview.source === 'complete' ? t('ai.resource.completedByAI')
: t('ai.resource.mergedByAI')}
</p>
{/* Format toggle */}
<div className="flex rounded-md border border-border/40 overflow-hidden">
@@ -776,7 +777,7 @@ export function ContextualAIChat({
: 'bg-card text-muted-foreground hover:bg-muted',
)}
>
<Eye className="h-2.5 w-2.5" /> Rendu
<Eye className="h-2.5 w-2.5" /> {t('ai.resource.rendered')}
</button>
<button
onClick={() => setResourcePreviewFormat('markdown')}
@@ -814,7 +815,7 @@ export function ContextualAIChat({
className="flex-1 text-xs gap-1.5"
onClick={() => setResourcePreview(null)}
>
<X className="h-3.5 w-3.5" /> Annuler
<X className="h-3.5 w-3.5" /> {t('ai.resource.cancel')}
</Button>
<Button
size="sm"
@@ -822,7 +823,7 @@ export function ContextualAIChat({
onClick={handleApplyResourcePreview}
disabled={!onApplyToNote}
>
<Check className="h-3.5 w-3.5" /> Appliquer à la note
<Check className="h-3.5 w-3.5" /> {t('ai.resource.applyToNote')}
</Button>
</div>
</div>
@@ -832,7 +833,7 @@ export function ContextualAIChat({
{/* URL loader */}
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
<Link2 className="h-3 w-3 inline mr-1" />URL (optionnel)
<Link2 className="h-3 w-3 inline mr-1" />{t('ai.resource.urlLabel')}
</label>
<div className="flex gap-1.5">
<input
@@ -860,18 +861,18 @@ export function ContextualAIChat({
{/* Text area */}
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
<FileText className="h-3 w-3 inline mr-1" />Texte de la ressource
<FileText className="h-3 w-3 inline mr-1" />{t('ai.resource.resourceText')}
</label>
<textarea
value={resourceText}
onChange={e => setResourceText(e.target.value)}
placeholder="Collez votre texte ici (markdown, HTML, texte brut…)"
placeholder={t('ai.resource.resourcePlaceholder')}
rows={8}
className="w-full px-2.5 py-2 text-xs bg-card border border-border/60 rounded-lg focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 resize-none transition-all font-mono leading-relaxed"
/>
{resourceText && (
<p className="text-[9px] text-muted-foreground mt-0.5 text-right">
{resourceText.split(/\s+/).filter(Boolean).length} mots
{resourceText.split(/\s+/).filter(Boolean).length} {t('ai.resource.words')}
</p>
)}
</div>
@@ -879,13 +880,13 @@ export function ContextualAIChat({
{/* Mode selector */}
<div>
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
Mode d'intégration
{t('ai.resource.integrationMode')}
</label>
<div className="grid grid-cols-3 gap-1.5">
{([
{ id: 'replace', label: 'Remplacer', desc: 'Direct, sans IA', icon: Download, color: 'primary' },
{ id: 'complete', label: 'Compléter', desc: 'Ajoute sans réécrire', icon: PlusCircle, color: 'emerald' },
{ id: 'merge', label: 'Fusionner', desc: 'Réécrit et intègre', icon: GitMerge, color: 'violet' },
{ id: 'replace', label: t('ai.resource.modeReplace'), desc: t('ai.resource.modeReplaceDesc'), icon: Download, color: 'primary' },
{ id: 'complete', label: t('ai.resource.modeComplete'), desc: t('ai.resource.modeCompleteDesc'), icon: PlusCircle, color: 'emerald' },
{ id: 'merge', label: t('ai.resource.modeMerge'), desc: t('ai.resource.modeMergeDesc'), icon: GitMerge, color: 'violet' },
] as const).map(m => {
const Icon = m.icon
const sel = resourceMode === m.id
@@ -920,17 +921,17 @@ export function ContextualAIChat({
disabled={!resourceText.trim() || resourceEnriching}
>
{resourceEnriching
? <><Loader2 className="h-4 w-4 animate-spin" /> IA en cours…</>
? <><Loader2 className="h-4 w-4 animate-spin" /> {t('ai.resource.aiProcessing')}</>
: resourceMode === 'replace'
? <><Eye className="h-4 w-4" /> Aperçu</>
: <><Sparkles className="h-4 w-4" /> Générer l'aperçu</>
? <><Eye className="h-4 w-4" /> {t('ai.resource.preview')}</>
: <><Sparkles className="h-4 w-4" /> {t('ai.resource.generatePreview')}</>
}
</Button>
{/* Hint */}
{!noteContent && resourceMode !== 'replace' && (
<p className="text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
💡 La note est vide le contenu de la ressource sera intégré directement.
{t('ai.resource.emptyNoteHint')}
</p>
)}
</div>
@@ -941,3 +942,6 @@ export function ContextualAIChat({
</aside>
)
}