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

@@ -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>

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>
)
}

View File

@@ -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>
)
}

View File

@@ -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({

View File

@@ -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>
)
}

View File

@@ -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
)
}