Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 34s
634 lines
30 KiB
TypeScript
634 lines
30 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 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,
|
|
} from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export interface RichTextEditorHandle {
|
|
getEditor: () => Editor | null
|
|
}
|
|
|
|
interface RichTextEditorProps {
|
|
content?: string
|
|
onChange?: (content: string) => void
|
|
className?: string
|
|
placeholder?: string
|
|
}
|
|
|
|
type SlashItem = {
|
|
title: string
|
|
description: string
|
|
icon: typeof Bold
|
|
category?: string
|
|
shortcut?: string
|
|
isImage?: boolean
|
|
isAi?: boolean
|
|
aiOption?: 'clarify' | 'shorten' | 'improve'
|
|
command: (editor: Editor) => void
|
|
}
|
|
|
|
const CustomImage = Image.extend({
|
|
addAttributes() {
|
|
return {
|
|
...this.parent?.(),
|
|
width: {
|
|
default: '100%',
|
|
renderHTML: attributes => {
|
|
if (!attributes.width) return {}
|
|
return { style: `width: ${attributes.width}; max-width: 100%; height: auto;` }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const slashCommands: SlashItem[] = [
|
|
// Basic blocks (indices 0-9)
|
|
{ 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: '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 (index 10)
|
|
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => {} },
|
|
// Formatting (indices 11-13) — super/subscript removed, use BubbleMenu
|
|
{ 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 (indices 14-17)
|
|
{ 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: () => {} },
|
|
]
|
|
|
|
async function aiReformulate(text: string, option: 'clarify' | 'shorten' | 'improve'): Promise<string> {
|
|
const res = await fetch('/api/ai/reformulate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text, option, format: 'html' }),
|
|
})
|
|
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 }, 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 }),
|
|
Superscript,
|
|
Subscript,
|
|
Typography,
|
|
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
|
],
|
|
content: content || '',
|
|
immediatelyRender: false,
|
|
editorProps: {
|
|
attributes: { class: 'notion-editor' },
|
|
},
|
|
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']
|
|
}}
|
|
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>
|
|
)
|
|
}
|
|
|
|
function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
|
const { t } = 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)
|
|
|
|
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') => {
|
|
const { from, to } = editor.state.selection
|
|
const text = editor.state.doc.textBetween(from, to, ' ')
|
|
if (!text || text.split(/\s+/).length < 5) return
|
|
setAiLoading(true)
|
|
setAiOpen(false)
|
|
try {
|
|
const result = await aiReformulate(text, option)
|
|
editor.chain().focus().insertContentAt({ from, to }, result).run()
|
|
} catch (err) {
|
|
console.error('AI error:', err)
|
|
} finally {
|
|
setAiLoading(false)
|
|
}
|
|
}
|
|
|
|
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', 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', 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', 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 text-xs font-medium px-1">25%</button>
|
|
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn text-xs font-medium px-1">50%</button>
|
|
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn 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-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>
|
|
</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)
|
|
// Flag: true while user is interacting with the menu (prevents selectionUpdate from closing it)
|
|
const menuInteracting = useRef(false)
|
|
|
|
// Translated category names (keys match slashCommands category field)
|
|
const CAT_LABELS: Record<string, string> = {
|
|
'Basic blocks': t('richTextEditor.slashCatBasic'),
|
|
'Media': t('richTextEditor.slashCatMedia'),
|
|
'Formatting': t('richTextEditor.slashCatFormatting'),
|
|
'IA Note': t('richTextEditor.slashCatAi'),
|
|
}
|
|
|
|
// Translated command list (keeps same order/icons/shortcuts as global slashCommands)
|
|
const localCommands: SlashItem[] = [
|
|
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: 'Basic blocks' },
|
|
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), category: 'Basic blocks' },
|
|
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), category: 'Basic blocks' },
|
|
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: 'Basic blocks' },
|
|
{ ...slashCommands[4], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: 'Basic blocks' },
|
|
{ ...slashCommands[5], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: 'Basic blocks' },
|
|
{ ...slashCommands[6], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: 'Basic blocks' },
|
|
{ ...slashCommands[7], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: 'Basic blocks' },
|
|
{ ...slashCommands[8], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: 'Basic blocks' },
|
|
{ ...slashCommands[9], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: 'Basic blocks' },
|
|
{ ...slashCommands[10], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: 'Media' },
|
|
{ ...slashCommands[11], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: 'Formatting' },
|
|
{ ...slashCommands[12], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: 'Formatting' },
|
|
{ ...slashCommands[13], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: 'Formatting' },
|
|
{ ...slashCommands[14], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: 'IA Note' },
|
|
{ ...slashCommands[15], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: 'IA Note' },
|
|
{ ...slashCommands[16], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: 'IA Note' },
|
|
{ ...slashCommands[17], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: 'IA Note' },
|
|
]
|
|
|
|
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])
|
|
|
|
// All category names in order
|
|
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
|
|
|
|
// Compute categories based on full search to keep tabs visible even when one is selected
|
|
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)
|
|
setCoords({ top: c.bottom + 8, left: c.left })
|
|
}, [isOpen, editor, query])
|
|
|
|
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
|
|
)
|
|
}
|
|
|