feat: slash menu - category tabs, Tab key navigation, auto-scroll, description search
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 45s

This commit is contained in:
2026-05-02 22:06:30 +02:00
parent 7b9534703c
commit 8e07594ba2
2 changed files with 134 additions and 25 deletions

View File

@@ -1260,6 +1260,76 @@ html.font-system * {
opacity: 0.7;
}
/* Category tab bar */
.notion-slash-tabs {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 6px 2px;
flex-wrap: wrap;
border-bottom: 1px solid var(--border);
margin-bottom: 2px;
}
.notion-slash-tab {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
border: 1px solid var(--border);
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition: all 0.12s ease;
white-space: nowrap;
}
.notion-slash-tab:hover {
background: var(--accent);
color: var(--foreground);
}
.notion-slash-tab-active {
background: var(--primary);
color: var(--primary-foreground);
border-color: var(--primary);
}
.notion-slash-tab-active:hover {
opacity: 0.9;
background: var(--primary);
color: var(--primary-foreground);
}
.notion-slash-tab-ai {
border-color: oklch(0.82 0.08 270);
color: oklch(0.52 0.18 270);
}
.dark .notion-slash-tab-ai {
border-color: oklch(0.38 0.09 270);
color: oklch(0.75 0.16 270);
}
.notion-slash-tab-ai.notion-slash-tab-active {
background: oklch(0.55 0.18 270);
border-color: oklch(0.55 0.18 270);
color: white;
}
.notion-slash-tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 10px;
font-size: 9px;
font-weight: 700;
background: rgba(0,0,0,0.1);
line-height: 1;
}
.notion-slash-tab-active .notion-slash-tab-count {
background: rgba(255,255,255,0.25);
}
.notion-slash-section + .notion-slash-section {
border-top: 1px solid var(--border);
margin-top: 4px;

View File

@@ -326,11 +326,15 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
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 closeMenu = useCallback(() => { setIsOpen(false); setQuery(''); setSelectedIndex(0) }, [])
const closeMenu = useCallback(() => {
setIsOpen(false); setQuery(''); setSelectedIndex(0); setActiveCategory(null)
}, [])
const deleteSlashText = useCallback(() => {
const { from, to } = editor.state.selection
@@ -344,31 +348,27 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
const handleSelect = useCallback(async (item: SlashItem) => {
if (item.isImage) {
deleteSlashText()
closeMenu()
onInsertImage(editor)
deleteSlashText(); closeMenu(); onInsertImage(editor)
} else if (item.isAi && item.aiOption) {
deleteSlashText()
closeMenu()
setAiLoading(true)
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)
}
} catch (err) { console.error('AI slash error:', err) }
finally { setAiLoading(false) }
} else {
deleteSlashText()
item.command(editor)
closeMenu()
deleteSlashText(); item.command(editor); closeMenu()
}
}, [editor, closeMenu, deleteSlashText, onInsertImage])
const filtered = slashCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()))
// All category names in order
const allCategories = Array.from(new Set(slashCommands.map(c => c.category || 'Basic blocks')))
// Filtered by text query, then optionally by active tab
const textFiltered = slashCommands.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 categories = filtered.reduce((acc, item) => {
const cat = item.category || 'Basic blocks'
@@ -377,6 +377,11 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
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
@@ -384,19 +389,27 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return }
if (e.key === 'Enter') { e.preventDefault(); if (filtered[selectedIndex]) handleSelect(filtered[selectedIndex]); return }
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return }
if (e.key === 'Tab') {
e.preventDefault()
// Cycle through categories with Tab
const catKeys = Object.keys(categories)
if (catKeys.length < 2) return
const currentCatIdx = activeCategory ? catKeys.indexOf(activeCategory) : -1
const nextCat = catKeys[(currentCatIdx + 1) % catKeys.length]
setActiveCategory(nextCat)
setSelectedIndex(0)
return
}
}
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu])
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, categories, activeCategory])
useEffect(() => {
if (!isOpen) return
const updatePosition = () => {
const { from } = editor.state.selection
const c = editor.view.coordsAtPos(from)
setCoords({ top: c.bottom + 8, left: c.left })
}
updatePosition()
const { from } = editor.state.selection
const c = editor.view.coordsAtPos(from)
setCoords({ top: c.bottom + 8, left: c.left })
}, [isOpen, editor, query])
useEffect(() => {
@@ -422,14 +435,38 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
if (!isOpen || filtered.length === 0) return null
let flatIndex = -1
const totalVisible = Object.keys(categories).length
return (
<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">
<button
className={cn('notion-slash-tab', !activeCategory && 'notion-slash-tab-active')}
onClick={() => { setActiveCategory(null); setSelectedIndex(0) }}
>
Tout
</button>
{allCategories.filter(cat => categories[cat]).map(cat => (
<button
key={cat}
className={cn('notion-slash-tab', activeCategory === cat && 'notion-slash-tab-active', cat === 'IA Note' && 'notion-slash-tab-ai')}
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">{categories[cat]?.length ?? 0}</span>
</button>
))}
</div>
)}
{/* Header hint */}
<div className="notion-slash-header">
<Sparkles className="w-3 h-3" />
<span>Blocs utilisez pour naviguer, Entrée pour insérer</span>
<span> naviguer · Entrée insérer · Tab changer de section</span>
</div>
{aiLoading && (
<div className="notion-slash-loading">
<Sparkles className="w-3.5 h-3.5 animate-spin" />
@@ -449,6 +486,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
return (
<button
key={item.title}
ref={isSelected ? selectedItemRef : null}
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(idx)}
@@ -471,3 +509,4 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
</div>
)
}