fix(editor): custom image width parsing, fix image paste, add AI submenu features
This commit is contained in:
@@ -124,8 +124,8 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
|
||||
|
||||
<FeatureToggle
|
||||
name="IA Note"
|
||||
description="Active le bouton de chat IA et les outils d'amélioration du texte"
|
||||
name={t('aiSettings.aiNote')}
|
||||
description={t('aiSettings.aiNoteDesc')}
|
||||
checked={settings.paragraphRefactor}
|
||||
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
||||
/>
|
||||
@@ -167,37 +167,37 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
|
||||
{/* Language Detection Toggle */}
|
||||
<FeatureToggle
|
||||
name="Détection de langue"
|
||||
description="Détecte automatiquement la langue de vos notes"
|
||||
name={t('aiSettings.languageDetection')}
|
||||
description={t('aiSettings.languageDetectionDesc')}
|
||||
checked={settings.languageDetection ?? true}
|
||||
onChange={(checked) => handleToggle('languageDetection', checked)}
|
||||
/>
|
||||
|
||||
{/* Auto Labeling Toggle */}
|
||||
<FeatureToggle
|
||||
name="Suggestion des labels"
|
||||
description="Suggère et applique des étiquettes automatiquement à vos notes"
|
||||
name={t('aiSettings.autoLabeling')}
|
||||
description={t('aiSettings.autoLabelingDesc')}
|
||||
checked={settings.autoLabeling ?? true}
|
||||
onChange={(checked) => handleToggle('autoLabeling', checked)}
|
||||
/>
|
||||
|
||||
<FeatureToggle
|
||||
name="Historique des notes"
|
||||
description="Active les snapshots de versions et la restauration depuis History"
|
||||
name={t('aiSettings.noteHistory')}
|
||||
description={t('aiSettings.noteHistoryDesc')}
|
||||
checked={settings.noteHistory ?? false}
|
||||
onChange={(checked) => handleToggle('noteHistory', checked)}
|
||||
/>
|
||||
|
||||
{settings.noteHistory && (
|
||||
<div className="space-y-2 rounded-lg border border-border/50 bg-muted/30 p-3">
|
||||
<p className="text-sm font-medium">{t('notes.historyMode') || 'Mode d\'historique'}</p>
|
||||
<p className="text-sm font-medium">{t('notes.historyMode')}</p>
|
||||
<RadioGroup
|
||||
value={settings.noteHistoryMode ?? 'manual'}
|
||||
onValueChange={(value) => {
|
||||
const mode = value as 'manual' | 'auto'
|
||||
setSettings((s) => ({ ...s, noteHistoryMode: mode }))
|
||||
updateAISettings({ noteHistoryMode: mode }).then(() => {
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
toast.success(t('settings.settingsSaved'))
|
||||
})
|
||||
}}
|
||||
className="space-y-2"
|
||||
@@ -206,10 +206,10 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
<RadioGroupItem value="manual" id="history-manual" />
|
||||
<div className="grid gap-0.5 leading-none">
|
||||
<Label htmlFor="history-manual" className="text-sm font-medium">
|
||||
{t('notes.historyModeManual') || 'Manuel'}
|
||||
{t('notes.historyModeManual')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('notes.historyModeManualDesc') || 'Créer des snapshots avec le bouton commit'}
|
||||
{t('notes.historyModeManualDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,10 +217,10 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
<RadioGroupItem value="auto" id="history-auto" />
|
||||
<div className="grid gap-0.5 leading-none">
|
||||
<Label htmlFor="history-auto" className="text-sm font-medium">
|
||||
{t('notes.historyModeAuto') || 'Automatique'}
|
||||
{t('notes.historyModeAuto')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('notes.historyModeAutoDesc') || 'Snapshots automatiques avec détection intelligente'}
|
||||
{t('notes.historyModeAutoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
// Paste handler: upload clipboard images
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -237,8 +238,8 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('paste', handlePaste)
|
||||
return () => document.removeEventListener('paste', handlePaste)
|
||||
document.addEventListener('paste', handlePaste, { capture: true })
|
||||
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||
}, [t])
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
@@ -276,6 +277,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
setLinks(links.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const allImages = useMemo(() => {
|
||||
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
|
||||
return Array.from(new Set([...images, ...extracted]));
|
||||
}, [images, content, noteType]);
|
||||
|
||||
const handleGenerateTitles = async () => {
|
||||
// Combine content and link metadata for AI
|
||||
const fullContentForAI = [
|
||||
@@ -304,7 +310,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
const response = await fetch('/api/ai/title-suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: fullContent }),
|
||||
body: JSON.stringify({ content: fullContentForAI }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -733,6 +739,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
className="min-h-[200px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
/>
|
||||
) : noteType === 'text' || noteType === 'markdown' ? (
|
||||
<div className="space-y-2">
|
||||
@@ -1158,3 +1165,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -394,6 +394,7 @@ export function NoteInlineEditor({
|
||||
// Paste handler: upload clipboard images
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -412,8 +413,8 @@ export function NoteInlineEditor({
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('paste', handlePaste)
|
||||
return () => document.removeEventListener('paste', handlePaste)
|
||||
document.addEventListener('paste', handlePaste, { capture: true })
|
||||
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||
}, [note.id, note.images, onChange, t])
|
||||
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
@@ -938,3 +939,5 @@ export function NoteInlineEditor({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -452,6 +452,7 @@ export function NoteInput({
|
||||
// Paste handler: upload clipboard images
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
if (type === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
@@ -468,8 +469,8 @@ export function NoteInput({
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('paste', handlePaste)
|
||||
return () => document.removeEventListener('paste', handlePaste)
|
||||
document.addEventListener('paste', handlePaste, { capture: true })
|
||||
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||
}, [t])
|
||||
|
||||
// AI title from images
|
||||
@@ -774,6 +775,7 @@ export function NoteInput({
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
className="min-h-[120px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
/>
|
||||
) : type === 'text' || type === 'markdown' ? (
|
||||
<>
|
||||
@@ -1101,3 +1103,4 @@ export function NoteInput({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,9 @@ import {
|
||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
} from 'lucide-react'
|
||||
SpellCheck, Languages, BookOpen } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface RichTextEditorHandle {
|
||||
getEditor: () => Editor | null
|
||||
@@ -38,6 +39,7 @@ interface RichTextEditorProps {
|
||||
onChange?: (content: string) => void
|
||||
className?: string
|
||||
placeholder?: string
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
}
|
||||
|
||||
type SlashItem = {
|
||||
@@ -58,6 +60,7 @@ const CustomImage = Image.extend({
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: '100%',
|
||||
parseHTML: element => element.style.width || element.getAttribute('width') || '100%',
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.width) return {}
|
||||
return { style: `width: ${attributes.width}; max-width: 100%; height: auto;` }
|
||||
@@ -92,11 +95,11 @@ const slashCommands: SlashItem[] = [
|
||||
{ title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => {} },
|
||||
]
|
||||
|
||||
async function aiReformulate(text: string, option: 'clarify' | 'shorten' | 'improve'): Promise<string> {
|
||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||
const res = await fetch('/api/ai/reformulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, option, format: 'html' }),
|
||||
body: JSON.stringify({ text, option, format: 'html', language }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'AI failed')
|
||||
@@ -129,7 +132,7 @@ function useImageInsert() {
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, className, placeholder }, ref) {
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const imageInsert = useImageInsert()
|
||||
|
||||
@@ -152,6 +155,27 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: { class: 'notion-editor' },
|
||||
handlePaste: (view, event, slice) => {
|
||||
if (!onImageUpload) return false;
|
||||
const items = Array.from(event.clipboardData?.items || []);
|
||||
const hasImage = items.some(item => item.type.startsWith('image/'));
|
||||
if (!hasImage) return false;
|
||||
event.preventDefault();
|
||||
const images = items.filter(item => item.type.startsWith('image/')).map(item => item.getAsFile()).filter(f => f !== null) as File[];
|
||||
images.forEach(async (file) => {
|
||||
try {
|
||||
toast.info(t('notes.uploading'));
|
||||
const url = await onImageUpload(file);
|
||||
const { schema } = view.state;
|
||||
const node = schema.nodes.image.create({ src: url });
|
||||
const tr = view.state.tr.replaceSelectionWith(node);
|
||||
view.dispatch(tr);
|
||||
} catch (err) {
|
||||
toast.error(t('notes.uploadFailed'));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
const html = e.getHTML()
|
||||
@@ -253,7 +277,7 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
|
||||
}
|
||||
|
||||
function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const [, setTick] = useState(0)
|
||||
const [aiOpen, setAiOpen] = useState(false)
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
@@ -286,15 +310,19 @@ 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') => {
|
||||
const handleAI = async (option: 'clarify' | 'shorten' | 'improve' | 'fix_grammar' | 'translate' | 'explain') => {
|
||||
const { from, to } = editor.state.selection
|
||||
const text = editor.state.doc.textBetween(from, to, ' ')
|
||||
if (!text || text.split(/\s+/).length < 5) return
|
||||
if (!text || text.split(/\s+/).length < 2) return
|
||||
setAiLoading(true)
|
||||
setAiOpen(false)
|
||||
try {
|
||||
const result = await aiReformulate(text, option)
|
||||
editor.chain().focus().insertContentAt({ from, to }, result).run()
|
||||
const result = await aiReformulate(text, option, language)
|
||||
if (option === 'explain') {
|
||||
toast.message(t('ai.action.explain'), { description: result, duration: 10000 })
|
||||
} else {
|
||||
editor.chain().focus().insertContentAt({ from, to }, result).run()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI error:', err)
|
||||
} finally {
|
||||
@@ -365,6 +393,9 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
<button className="notion-ai-subitem" onClick={() => handleAI('clarify')}><Lightbulb className="w-3.5 h-3.5 text-amber-500" /><span>{t('richTextEditor.slashClarify')}</span></button>
|
||||
<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={() => handleAI('explain')}><BookOpen className="w-3.5 h-3.5 text-orange-500" /><span>{t('ai.action.explain')}</span></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -631,3 +662,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user