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