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
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 45s
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user