Files
Momento/memento-note/components/rich-text-editor.tsx

774 lines
39 KiB
TypeScript

'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<string>
}
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: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, 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<string> {
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<Editor | null>(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<RichTextEditorHandle, RichTextEditorProps>(
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<string>(content || '')
useEffect(() => {
if (editor && content !== undefined && content !== lastEmittedContent.current) {
editor.commands.setContent(content || '')
lastEmittedContent.current = content || ''
}
}, [content, editor])
useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor])
return (
<div className={cn('notion-editor-wrapper', className)}>
{editor && (
<BubbleMenu
editor={editor}
className="notion-bubble-menu"
{...({
tippyOptions: {
appendTo: () => 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
}}
>
<BubbleToolbar editor={editor} />
</BubbleMenu>
)}
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} />}
<EditorContent editor={editor} />
{imageInsert.open && (
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
)}
</div>
)
}
)
function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void; onCancel: () => void }) {
const { t } = useLanguage()
const [url, setUrl] = useState('')
const [preview, setPreview] = useState<string | null>(null)
const [error, setError] = useState('')
const inputRef = useRef<HTMLInputElement>(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 (
<div className="notion-overlay" onClick={onCancel}>
<div className="notion-image-modal" onClick={(e) => e.stopPropagation()}>
<div className="text-sm font-medium mb-3">{t('richTextEditor.imageModalTitle')}</div>
<input
ref={inputRef}
type="url"
value={url}
onChange={(e) => { 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 && (
<button onClick={() => setPreview(url.trim())} className="text-xs text-muted-foreground hover:text-foreground transition-colors underline mt-2">
{t('richTextEditor.imageModalPreview')}
</button>
)}
{preview && (
<div className="mt-2 rounded-lg overflow-hidden border border-border max-h-40">
<img src={preview} alt="" className="max-h-40 object-contain w-full" onError={() => { setPreview(null); setError(t('richTextEditor.imageModalLoadFailed')) }} />
</div>
)}
{error && <div className="mt-1.5 text-xs text-destructive">{error}</div>}
<div className="flex justify-end gap-2 mt-4">
<button onClick={onCancel} className="notion-modal-btn">{t('richTextEditor.imageModalCancel')}</button>
<button onClick={handleConfirm} className="notion-modal-btn notion-modal-btn-primary">{t('richTextEditor.imageModalInsert')}</button>
</div>
</div>
</div>
)
}
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<HTMLInputElement>(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(/^<p>([\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 (
<div className="flex items-center gap-1.5 px-2 py-1.5">
<LinkIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<input
ref={linkInputRef}
type="url"
value={linkUrl}
onChange={(e) => 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"
/>
<button onClick={applyLink} className="notion-bubble-btn notion-bubble-btn-active"><Check className="w-3.5 h-3.5" /></button>
{editor.isActive('link') && (
<button onClick={() => { editor.chain().focus().extendMarkRange('link').unsetLink().run(); setLinkOpen(false) }} className="notion-bubble-btn">
<ExternalLink className="w-3.5 h-3.5" />
</button>
)}
<button onClick={() => { setLinkOpen(false); editor.chain().focus().run() }} className="notion-bubble-btn"><X className="w-3.5 h-3.5" /></button>
</div>
)
}
return (
<div className="flex items-center gap-0.5 px-1 py-0.5 relative">
{marks.map((m, i) => (
<button key={i} onClick={m.action} title={m.title} className={cn('notion-bubble-btn rounded-md', m.active && 'notion-bubble-btn-active')}>
<m.icon className="w-3.5 h-3.5" />
</button>
))}
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={openLinkEditor} className={cn('notion-bubble-btn rounded-md', editor.isActive('link') && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
<button onClick={() => setAiOpen(!aiOpen)} className={cn('notion-bubble-btn rounded-md', aiLoading && 'animate-pulse')}><Sparkles className="w-3.5 h-3.5" /></button>
{editor.isActive('image') && (
<>
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">25%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">50%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">100%</button>
</>
)}
{aiOpen && (
<div className="notion-ai-submenu">
<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-primary" /><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={() => setTranslateOpen(v => !v)}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>
{translateOpen && (
<div className="notion-ai-lang-picker">
{AI_LANGS.map(l => (
<button key={l} className="notion-ai-lang-item" onClick={() => handleAI('translate', l)}>{l}</button>
))}
<div className="flex items-center gap-1 px-1 pt-1 w-full border-t border-border/40 mt-1">
<input
className="notion-inline-input text-xs flex-1 min-w-0"
placeholder={t('ai.action.customLang') || 'Autre...'}
value={customLang}
onChange={e => setCustomLang(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && customLang.trim()) handleAI('translate', customLang.trim()) }}
/>
</div>
</div>
)}
<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>
)}
{aiModal && (
<div className="notion-ai-result-overlay" onClick={() => setAiModal(null)}>
<div className="notion-ai-result-modal" onClick={e => e.stopPropagation()}>
<div className="notion-ai-result-header">
<span className="text-sm font-semibold">
{aiModal.type === 'explain' ? t('ai.action.explain') : (t('ai.result.preview') || 'Apercu IA')}
</span>
<button onClick={() => setAiModal(null)} className="notion-bubble-btn ml-auto"><X className="w-3.5 h-3.5" /></button>
</div>
{aiModal.type === 'preview' && (
<div className="notion-ai-result-section">
<span className="notion-ai-result-label">{t('ai.result.original') || 'Original'}</span>
<p className="text-sm text-muted-foreground line-through opacity-60 mt-1">{aiModal.origText}</p>
</div>
)}
<div className="notion-ai-result-section">
{aiModal.type === 'preview' && <span className="notion-ai-result-label">{t('ai.result.suggestion') || 'Suggestion'}</span>}
<div className="prose prose-sm max-w-none text-sm mt-1" dangerouslySetInnerHTML={{ __html: aiModal.html }} />
</div>
<div className="flex justify-end gap-2 mt-3 pt-3 border-t border-border/40">
<button onClick={() => setAiModal(null)} className="notion-modal-btn">{t('common.cancel') || 'Annuler'}</button>
{aiModal.type === 'preview' && (
<button onClick={applyAiResult} className="notion-modal-btn notion-modal-btn-primary">{t('ai.result.apply') || 'Appliquer'}</button>
)}
</div>
</div>
</div>
)}
</div>
)
}
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<string | null>(null)
const [coords, setCoords] = useState({ top: 0, left: 0 })
const [aiLoading, setAiLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const selectedItemRef = useRef<HTMLButtonElement>(null)
const menuInteracting = useRef(false)
const CAT_LABELS: Record<string, string> = {
'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<string, SlashItem[]>)
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<string, SlashItem[]>)
// 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(
<div
ref={menuRef}
className="notion-slash-menu"
style={{ top: coords.top, left: coords.left }}
onClick={(e) => e.stopPropagation()}
>
{/* Category tabs */}
{!query && totalVisible > 1 && (
<div className="notion-slash-tabs" onMouseDown={(e) => e.preventDefault()}>
<button
className={cn('notion-slash-tab', !activeCategory && 'notion-slash-tab-active')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => { setActiveCategory(null); setSelectedIndex(0) }}
>
{t('richTextEditor.slashTabAll')}
</button>
{allCategories.filter(cat => availableCategoriesInSearch[cat]).map(cat => (
<button
key={cat}
className={cn('notion-slash-tab', activeCategory === cat && 'notion-slash-tab-active', cat === 'IA Note' && 'notion-slash-tab-ai')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => { setActiveCategory(activeCategory === cat ? null : cat); setSelectedIndex(0) }}
>
{cat === 'IA Note' && <Sparkles className="w-2.5 h-2.5" />}
{cat}
<span className="notion-slash-tab-count">{availableCategoriesInSearch[cat]?.length ?? 0}</span>
</button>
))}
</div>
)}
{/* Header hint */}
<div className="notion-slash-header">
<span>{t('richTextEditor.slashHint')}</span>
</div>
{aiLoading && (
<div className="notion-slash-loading">
<Sparkles className="w-3.5 h-3.5 animate-spin" />
<span>{t('richTextEditor.slashLoading')}</span>
</div>
)}
{!aiLoading && Object.entries(categories).map(([cat, items]) => (
<div key={cat} className="notion-slash-section">
<div className={cn('notion-slash-label', cat === 'IA Note' && 'notion-slash-label-ai')}>
{cat === 'IA Note' && <Sparkles className="w-3 h-3 inline mr-1" />}
{CAT_LABELS[cat] || cat}
</div>
{items.map((item) => {
flatIndex++
const idx = flatIndex
const isSelected = idx === selectedIndex
return (
<button
key={item.title}
ref={isSelected ? selectedItemRef : null}
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
<item.icon className="w-4 h-4" />
</div>
<div className="notion-slash-content">
<span className="notion-slash-title">{item.title}</span>
<span className="notion-slash-desc">{item.description}</span>
</div>
{item.shortcut && (
<span className="notion-slash-shortcut">{item.shortcut}</span>
)}
</button>
)
})}
</div>
))}
</div>,
document.body
)
}