fix(editor): custom image width parsing, fix image paste, add AI submenu features
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user