'use client' import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react' import { createPortal } from 'react-dom' import { useLanguage } from '@/lib/i18n' import { useEditor, EditorContent } from '@tiptap/react' import { BubbleMenu } from '@tiptap/react/menus' import StarterKit from '@tiptap/starter-kit' import Underline from '@tiptap/extension-underline' import Placeholder from '@tiptap/extension-placeholder' import TiptapLink from '@tiptap/extension-link' import Highlight from '@tiptap/extension-highlight' import Image from '@tiptap/extension-image' import TextAlign from '@tiptap/extension-text-align' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { Table } from '@tiptap/extension-table' import { TableRow } from '@tiptap/extension-table-row' import { TableCell } from '@tiptap/extension-table-cell' import { TableHeader } from '@tiptap/extension-table-header' import Superscript from '@tiptap/extension-superscript' import Subscript from '@tiptap/extension-subscript' import Typography from '@tiptap/extension-typography' import type { Editor } from '@tiptap/core' import type { EditorState } from '@tiptap/pm/state' import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code, Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon, Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink, FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus, SpellCheck, Languages, BookOpen, Presentation } from 'lucide-react' import { cn } from '@/lib/utils' import { toast } from 'sonner' export interface RichTextEditorHandle { getEditor: () => Editor | null } interface RichTextEditorProps { content?: string onChange?: (content: string) => void className?: string placeholder?: string onImageUpload?: (file: File) => Promise } type SlashItem = { title: string description: string icon: any category?: string shortcut?: string isImage?: boolean isAi?: boolean aiOption?: 'clarify' | 'shorten' | 'improve' command: (editor: Editor, range?: any) => void } const CustomImage = Image.extend({ addAttributes() { return { ...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;` } } } } } }) const slashCommands: SlashItem[] = [ // Basic blocks { title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() }, { title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() }, { title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() }, { title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() }, { title: 'Table', description: 'Insert a simple table', icon: () => TBL, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, { title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() }, { title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() }, { title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() }, { title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() }, { title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() }, { title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() }, // Media { title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } }, // Formatting { title: 'Align Left', description: 'Align text left', icon: AlignLeft, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('left').run() }, { title: 'Align Center', description: 'Center text', icon: AlignCenter, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('center').run() }, { title: 'Align Right', description: 'Align text right', icon: AlignRight, category: 'Formatting', command: (e) => e.chain().focus().setTextAlign('right').run() }, // IA Note { title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } }, { title: 'Raccourcir', description: 'Condenser le texte', icon: Scissors, category: 'IA Note', isAi: true, aiOption: 'shorten', command: () => { } }, { title: 'Améliorer', description: 'Améliorer le style', icon: Wand2, category: 'IA Note', isAi: true, aiOption: 'improve', command: () => { } }, { title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } }, // Formatting extensions { title: 'Bold', description: 'Make text bold', icon: Bold, category: 'Formatting', command: (e) => e.chain().focus().toggleBold().run() }, { title: 'Italic', description: 'Make text italic', icon: Italic, category: 'Formatting', command: (e) => e.chain().focus().toggleItalic().run() }, { title: 'Underline', description: 'Underline text', icon: UnderlineIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleUnderline().run() }, { title: 'Strike', description: 'Strikethrough text', icon: Strikethrough, category: 'Formatting', command: (e) => e.chain().focus().toggleStrike().run() }, { title: 'Highlight', description: 'Highlight text', icon: Highlighter, category: 'Formatting', command: (e) => e.chain().focus().toggleHighlight().run() }, { title: 'Superscript', description: 'Text above the baseline', icon: SuperscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSuperscript().run() }, { title: 'Subscript', description: 'Text below the baseline', icon: SubscriptIcon, category: 'Formatting', command: (e) => e.chain().focus().toggleSubscript().run() }, // AI Tools { title: 'Diagramme', description: 'Générer un diagramme Excalidraw', icon: BookOpen, category: 'IA Note', command: (e) => { const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'diagram' } }) window.dispatchEvent(event) } }, { title: 'Présentation', description: 'Générer des slides HTML/PPTX', icon: Presentation, category: 'IA Note', command: (e) => { const event = new CustomEvent('memento-open-ai', { detail: { tab: 'actions', scroll: 'slides' } }) window.dispatchEvent(event) } }, ] async function aiReformulate(text: string, option: string, language?: string): Promise { const res = await fetch('/api/ai/reformulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, option, format: 'html', language }), }) const data = await res.json() if (!res.ok) throw new Error(data.error || 'AI failed') return data.reformulatedText || data.text || text } function useImageInsert() { const [open, setOpen] = useState(false) const editorRef = useRef(null) const requestInsert = useCallback((editor: Editor) => { editorRef.current = editor setOpen(true) }, []) const confirm = useCallback((url: string) => { if (url.trim() && editorRef.current) { editorRef.current.chain().focus().setImage({ src: url.trim() }).run() } setOpen(false) editorRef.current = null }, []) const cancel = useCallback(() => { setOpen(false) editorRef.current = null }, []) return { open, requestInsert, confirm, cancel } } export const RichTextEditor = forwardRef( function RichTextEditor({ content, onChange, className, placeholder, onImageUpload }, ref) { const { t } = useLanguage() const imageInsert = useImageInsert() const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: false, underline: false }), Underline, TiptapLink.configure({ openOnClick: false, autolink: true }), Highlight.configure({ multicolor: false }), CustomImage.configure({ inline: false, allowBase64: true }), TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }), TaskList, TaskItem.configure({ nested: true }), Table.configure({ resizable: true }), TableRow, TableHeader, TableCell, Superscript, Subscript, Typography, Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }), ], content: content || '', 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() lastEmittedContent.current = html onChange?.(html) }, }) const lastEmittedContent = useRef(content || '') useEffect(() => { if (editor && content !== undefined && content !== lastEmittedContent.current) { editor.commands.setContent(content || '') lastEmittedContent.current = content || '' } }, [content, editor]) useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor]) return (
{editor && ( document.body, zIndex: 99999, fallbackPlacements: ['bottom', 'top'] } } as any)} shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => { const { from, to } = state.selection const isImage = e.isActive('image') return (from !== to && !e.isActive('codeBlock')) || isImage }} > )} {editor && } {imageInsert.open && ( )}
) } ) function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void; onCancel: () => void }) { const { t } = useLanguage() const [url, setUrl] = useState('') const [preview, setPreview] = useState(null) const [error, setError] = useState('') const inputRef = useRef(null) useEffect(() => { inputRef.current?.focus() }, []) const handleConfirm = () => { if (!url.trim()) return if (!/^https?:\/\/.+/i.test(url.trim())) { setError(t('richTextEditor.imageModalInvalidUrl')); return } onConfirm(url.trim()) } return (
e.stopPropagation()}>
{t('richTextEditor.imageModalTitle')}
{ setUrl(e.target.value); setError(''); setPreview(null) }} onKeyDown={(e) => { if (e.key === 'Enter') handleConfirm(); if (e.key === 'Escape') onCancel() }} placeholder="https://example.com/image.png" className="notion-modal-input" /> {url.trim() && !preview && ( )} {preview && (
{ setPreview(null); setError(t('richTextEditor.imageModalLoadFailed')) }} />
)} {error &&
{error}
}
) } const AI_LANGS = ['Francais', 'English', 'Espanol', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais'] function BubbleToolbar({ editor }: { editor: Editor | null }) { const { t, language } = useLanguage() const [, setTick] = useState(0) const [aiOpen, setAiOpen] = useState(false) const [aiLoading, setAiLoading] = useState(false) const [linkOpen, setLinkOpen] = useState(false) const [linkUrl, setLinkUrl] = useState('') const linkInputRef = useRef(null) const [translateOpen, setTranslateOpen] = useState(false) const [customLang, setCustomLang] = useState('') const [aiModal, setAiModal] = useState<{ type: 'explain' | 'preview'; origText: string; html: string; from: number; to: number } | null>(null) useEffect(() => { if (!editor) return const h = () => setTick(t => t + 1) editor.on('transaction', h) editor.on('selectionUpdate', h) return () => { editor.off('transaction', h); editor.off('selectionUpdate', h) } }, [editor]) useEffect(() => { if (linkOpen && linkInputRef.current) linkInputRef.current.focus() }, [linkOpen]) if (!editor) return null const marks = [ { icon: Bold, active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run(), title: t('richTextEditor.bold') }, { icon: Italic, active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run(), title: t('richTextEditor.italic') }, { icon: UnderlineIcon, active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run(), title: t('richTextEditor.underline') }, { icon: Strikethrough, active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run(), title: t('richTextEditor.strike') }, { icon: Code, active: editor.isActive('code'), action: () => editor.chain().focus().toggleCode().run(), title: t('richTextEditor.code') }, { icon: Highlighter, active: editor.isActive('highlight'), action: () => editor.chain().focus().toggleHighlight().run(), title: t('richTextEditor.highlight') }, { icon: SuperscriptIcon, active: editor.isActive('superscript'), action: () => editor.chain().focus().toggleSuperscript().run(), title: t('richTextEditor.superscript') }, { icon: SubscriptIcon, active: editor.isActive('subscript'), action: () => editor.chain().focus().toggleSubscript().run(), title: t('richTextEditor.subscript') }, ] const handleAI = async (option: 'clarify' | 'shorten' | 'improve' | 'fix_grammar' | 'translate' | 'explain', targetLang?: string) => { const { from, to } = editor.state.selection const text = editor.state.doc.textBetween(from, to, ' ') if (!text || text.split(/\s+/).length < 2) return setAiLoading(true) setAiOpen(false) setTranslateOpen(false) try { const lang = option === 'translate' ? (targetLang || language) : language const result = await aiReformulate(text, option, lang) if (option === 'explain') { setAiModal({ type: 'explain', origText: text, html: result, from, to }) } else { setAiModal({ type: 'preview', origText: text, html: result, from, to }) } } catch (err) { console.error('AI error:', err) } finally { setAiLoading(false) } } const applyAiResult = () => { if (!aiModal || !editor) { setAiModal(null); return } const clean = aiModal.html.replace(/^

([\s\S]*)<\/p>$/, '$1').trim() editor.chain().focus().insertContentAt({ from: aiModal.from, to: aiModal.to }, clean).run() setAiModal(null) } const openLinkEditor = () => { const existing = editor.getAttributes('link').href || '' setLinkUrl(existing) setLinkOpen(true) setAiOpen(false) } const applyLink = () => { if (linkUrl.trim()) { editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl.trim() }).run() } else { editor.chain().focus().extendMarkRange('link').unsetLink().run() } setLinkOpen(false) } if (linkOpen) { return (

setLinkUrl(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyLink() }; if (e.key === 'Escape') { setLinkOpen(false); editor.chain().focus().run() } }} placeholder={t('richTextEditor.linkPlaceholder')} className="notion-inline-input" /> {editor.isActive('link') && ( )}
) } return (
{marks.map((m, i) => ( ))}
{editor.isActive('image') && ( <>
)} {aiOpen && (
{translateOpen && (
{AI_LANGS.map(l => ( ))}
setCustomLang(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && customLang.trim()) handleAI('translate', customLang.trim()) }} />
)}
)} {aiModal && (
setAiModal(null)}>
e.stopPropagation()}>
{aiModal.type === 'explain' ? t('ai.action.explain') : (t('ai.result.preview') || 'Apercu IA')}
{aiModal.type === 'preview' && (
{t('ai.result.original') || 'Original'}

{aiModal.origText}

)}
{aiModal.type === 'preview' && {t('ai.result.suggestion') || 'Suggestion'}}
{aiModal.type === 'preview' && ( )}
)}
) } function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertImage: (editor: Editor) => void }) { const { t } = useLanguage() const [isOpen, setIsOpen] = useState(false) const [query, setQuery] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const [activeCategory, setActiveCategory] = useState(null) const [coords, setCoords] = useState({ top: 0, left: 0 }) const [aiLoading, setAiLoading] = useState(false) const menuRef = useRef(null) const selectedItemRef = useRef(null) const menuInteracting = useRef(false) const CAT_LABELS: Record = { 'Basic blocks': t('richTextEditor.slashCatBasic'), 'Media': t('richTextEditor.slashCatMedia'), 'Formatting': t('richTextEditor.slashCatFormatting'), 'IA Note': t('richTextEditor.slashCatAi'), } const localCommands: SlashItem[] = [ { ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: t('richTextEditor.slashCatBasic') }, { ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: t('richTextEditor.slashCatMedia') }, { ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: t('richTextEditor.slashCatFormatting') }, { ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: t('richTextEditor.slashCatFormatting') }, { ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: t('richTextEditor.slashCatFormatting') }, { ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: t('richTextEditor.slashCatAi') }, { ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: t('richTextEditor.slashCatAi') }, { ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: t('richTextEditor.slashCatAi') }, { ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: t('richTextEditor.slashCatAi') }, { ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), category: t('richTextEditor.slashCatFormatting') }, { ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), category: t('richTextEditor.slashCatFormatting') }, { ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), category: t('richTextEditor.slashCatFormatting') }, { ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), category: t('richTextEditor.slashCatFormatting') }, { ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), category: t('richTextEditor.slashCatFormatting') }, { ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), category: t('richTextEditor.slashCatFormatting') }, { ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), category: t('richTextEditor.slashCatFormatting') }, { ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), category: t('richTextEditor.slashCatAi') }, { ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), category: t('richTextEditor.slashCatAi') }, ] const closeMenu = useCallback(() => { setIsOpen(false); setQuery(''); setSelectedIndex(0); setActiveCategory(null) }, []) const deleteSlashText = useCallback(() => { const { from, to } = editor.state.selection const textBefore = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n') const slashIdx = textBefore.lastIndexOf('/') if (slashIdx >= 0) { const deleteFrom = from - (textBefore.length - slashIdx) editor.chain().focus().deleteRange({ from: deleteFrom, to }).run() } }, [editor]) const handleSelect = useCallback(async (item: SlashItem) => { if (item.isImage) { deleteSlashText(); closeMenu(); onInsertImage(editor) } else if (item.isAi && item.aiOption) { deleteSlashText(); closeMenu(); setAiLoading(true) try { const allText = editor.state.doc.textContent if (!allText || allText.split(/\s+/).length < 5) return const result = await aiReformulate(allText, item.aiOption) editor.chain().focus().setContent(result).run() } catch (err) { console.error('AI slash error:', err) } finally { setAiLoading(false) } } else { deleteSlashText(); item.command(editor); closeMenu() } }, [editor, closeMenu, deleteSlashText, onInsertImage]) const allCategories = Array.from(new Set(localCommands.map(c => c.category || 'Basic blocks'))) const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase())) const filtered = activeCategory ? textFiltered.filter(c => (c.category || 'Basic blocks') === activeCategory) : textFiltered const availableCategoriesInSearch = textFiltered.reduce((acc, item) => { const cat = item.category || 'Basic blocks' if (!acc[cat]) acc[cat] = [] acc[cat].push(item) return acc }, {} as Record) const categories = filtered.reduce((acc, item) => { const cat = item.category || 'Basic blocks' if (!acc[cat]) acc[cat] = [] acc[cat].push(item) return acc }, {} as Record) // Auto-scroll selected item into view useEffect(() => { selectedItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) }, [selectedIndex]) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen) return if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length); return } if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return } if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault() const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])] const currentIndex = availableTabs.indexOf(activeCategory) const nextIndex = e.key === 'ArrowRight' ? (currentIndex + 1) % availableTabs.length : (currentIndex - 1 + availableTabs.length) % availableTabs.length setActiveCategory(availableTabs[nextIndex]) setSelectedIndex(0) return } if (e.key === 'Enter') { e.preventDefault() const item = filtered[selectedIndex] if (item) handleSelect(item) return } if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return } if (e.key === 'Tab') { e.preventDefault() const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])] const nextIndex = (availableTabs.indexOf(activeCategory) + 1) % availableTabs.length setActiveCategory(availableTabs[nextIndex]) setSelectedIndex(0) return } } document.addEventListener('keydown', handleKeyDown, true) return () => document.removeEventListener('keydown', handleKeyDown, true) }, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, categories, activeCategory]) useEffect(() => { if (!isOpen) return const { from } = editor.state.selection const c = editor.view.coordsAtPos(from) // Check if menu would overflow bottom const menuHeight = menuRef.current?.offsetHeight || 300 const wouldOverflow = c.bottom + menuHeight + 20 > window.innerHeight if (wouldOverflow) { setCoords({ top: c.top - menuHeight - 8, left: c.left }) } else { setCoords({ top: c.bottom + 8, left: c.left }) } }, [isOpen, editor, query, filtered.length]) useEffect(() => { const handleClick = (e: MouseEvent) => { if (isOpen && menuRef.current && !menuRef.current.contains(e.target as Node)) { closeMenu() } } document.addEventListener('click', handleClick) return () => document.removeEventListener('click', handleClick) }, [isOpen, closeMenu]) useEffect(() => { const handler = () => { // Ignore events fired while user clicks inside the menu if (menuInteracting.current) return const { from, empty } = editor.state.selection if (!empty) { if (isOpen) closeMenu(); return } const text = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n') const m = text.match(/\/([^\s/]*)$/) if (m) { setQuery(m[1]) setSelectedIndex(0) if (!isOpen) { setIsOpen(true); setActiveCategory(null) } // reset category filter on fresh open } else if (isOpen) closeMenu() } editor.on('update', handler) editor.on('selectionUpdate', handler) return () => { editor.off('update', handler); editor.off('selectionUpdate', handler) } }, [editor, isOpen, closeMenu]) if (!isOpen || filtered.length === 0) return null let flatIndex = -1 const totalVisible = Object.keys(categories).length return createPortal(
e.stopPropagation()} > {/* Category tabs */} {!query && totalVisible > 1 && (
e.preventDefault()}> {allCategories.filter(cat => availableCategoriesInSearch[cat]).map(cat => ( ))}
)} {/* Header hint */}
{t('richTextEditor.slashHint')}
{aiLoading && (
{t('richTextEditor.slashLoading')}
)} {!aiLoading && Object.entries(categories).map(([cat, items]) => (
{cat === 'IA Note' && } {CAT_LABELS[cat] || cat}
{items.map((item) => { flatIndex++ const idx = flatIndex const isSelected = idx === selectedIndex return ( ) })}
))}
, document.body ) }