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;
|
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 {
|
.notion-slash-section + .notion-slash-section {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|||||||
@@ -326,11 +326,15 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||||
const [coords, setCoords] = useState({ top: 0, left: 0 })
|
const [coords, setCoords] = useState({ top: 0, left: 0 })
|
||||||
const [aiLoading, setAiLoading] = useState(false)
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
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 deleteSlashText = useCallback(() => {
|
||||||
const { from, to } = editor.state.selection
|
const { from, to } = editor.state.selection
|
||||||
@@ -344,31 +348,27 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
|
|
||||||
const handleSelect = useCallback(async (item: SlashItem) => {
|
const handleSelect = useCallback(async (item: SlashItem) => {
|
||||||
if (item.isImage) {
|
if (item.isImage) {
|
||||||
deleteSlashText()
|
deleteSlashText(); closeMenu(); onInsertImage(editor)
|
||||||
closeMenu()
|
|
||||||
onInsertImage(editor)
|
|
||||||
} else if (item.isAi && item.aiOption) {
|
} else if (item.isAi && item.aiOption) {
|
||||||
deleteSlashText()
|
deleteSlashText(); closeMenu(); setAiLoading(true)
|
||||||
closeMenu()
|
|
||||||
setAiLoading(true)
|
|
||||||
try {
|
try {
|
||||||
const allText = editor.state.doc.textContent
|
const allText = editor.state.doc.textContent
|
||||||
if (!allText || allText.split(/\s+/).length < 5) return
|
if (!allText || allText.split(/\s+/).length < 5) return
|
||||||
const result = await aiReformulate(allText, item.aiOption)
|
const result = await aiReformulate(allText, item.aiOption)
|
||||||
editor.chain().focus().setContent(result).run()
|
editor.chain().focus().setContent(result).run()
|
||||||
} catch (err) {
|
} catch (err) { console.error('AI slash error:', err) }
|
||||||
console.error('AI slash error:', err)
|
finally { setAiLoading(false) }
|
||||||
} finally {
|
|
||||||
setAiLoading(false)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
deleteSlashText()
|
deleteSlashText(); item.command(editor); closeMenu()
|
||||||
item.command(editor)
|
|
||||||
closeMenu()
|
|
||||||
}
|
}
|
||||||
}, [editor, closeMenu, deleteSlashText, onInsertImage])
|
}, [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 categories = filtered.reduce((acc, item) => {
|
||||||
const cat = item.category || 'Basic blocks'
|
const cat = item.category || 'Basic blocks'
|
||||||
@@ -377,6 +377,11 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
return acc
|
return acc
|
||||||
}, {} as Record<string, SlashItem[]>)
|
}, {} as Record<string, SlashItem[]>)
|
||||||
|
|
||||||
|
// Auto-scroll selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
selectedItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||||
|
}, [selectedIndex])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isOpen) return
|
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 === '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 === 'Enter') { e.preventDefault(); if (filtered[selectedIndex]) handleSelect(filtered[selectedIndex]); return }
|
||||||
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); 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)
|
document.addEventListener('keydown', handleKeyDown, true)
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
||||||
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu])
|
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, categories, activeCategory])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
const updatePosition = () => {
|
const { from } = editor.state.selection
|
||||||
const { from } = editor.state.selection
|
const c = editor.view.coordsAtPos(from)
|
||||||
const c = editor.view.coordsAtPos(from)
|
setCoords({ top: c.bottom + 8, left: c.left })
|
||||||
setCoords({ top: c.bottom + 8, left: c.left })
|
|
||||||
}
|
|
||||||
updatePosition()
|
|
||||||
}, [isOpen, editor, query])
|
}, [isOpen, editor, query])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -422,14 +435,38 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
if (!isOpen || filtered.length === 0) return null
|
if (!isOpen || filtered.length === 0) return null
|
||||||
|
|
||||||
let flatIndex = -1
|
let flatIndex = -1
|
||||||
|
const totalVisible = Object.keys(categories).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={menuRef} className="notion-slash-menu" style={{ top: coords.top, left: coords.left }} onClick={(e) => e.stopPropagation()}>
|
<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 */}
|
{/* Header hint */}
|
||||||
<div className="notion-slash-header">
|
<div className="notion-slash-header">
|
||||||
<Sparkles className="w-3 h-3" />
|
<span>↑↓ naviguer · Entrée insérer · Tab changer de section</span>
|
||||||
<span>Blocs — utilisez ↑↓ pour naviguer, Entrée pour insérer</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aiLoading && (
|
{aiLoading && (
|
||||||
<div className="notion-slash-loading">
|
<div className="notion-slash-loading">
|
||||||
<Sparkles className="w-3.5 h-3.5 animate-spin" />
|
<Sparkles className="w-3.5 h-3.5 animate-spin" />
|
||||||
@@ -449,6 +486,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.title}
|
key={item.title}
|
||||||
|
ref={isSelected ? selectedItemRef : null}
|
||||||
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
|
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
|
||||||
onClick={() => handleSelect(item)}
|
onClick={() => handleSelect(item)}
|
||||||
onMouseEnter={() => setSelectedIndex(idx)}
|
onMouseEnter={() => setSelectedIndex(idx)}
|
||||||
@@ -471,3 +509,4 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user