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

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