From 8e07594ba23339fffcf68c9159d6b5f53d1469a4 Mon Sep 17 00:00:00 2001 From: sepehr Date: Sat, 2 May 2026 22:06:30 +0200 Subject: [PATCH] feat: slash menu - category tabs, Tab key navigation, auto-scroll, description search --- memento-note/app/globals.css | 70 +++++++++++++++ memento-note/components/rich-text-editor.tsx | 89 ++++++++++++++------ 2 files changed, 134 insertions(+), 25 deletions(-) diff --git a/memento-note/app/globals.css b/memento-note/app/globals.css index 2a2aa52..4d395e3 100644 --- a/memento-note/app/globals.css +++ b/memento-note/app/globals.css @@ -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; diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index 2ce7ad6..80612b6 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -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(null) const [coords, setCoords] = useState({ top: 0, left: 0 }) const [aiLoading, setAiLoading] = useState(false) const menuRef = useRef(null) + const selectedItemRef = useRef(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) + // 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 (
e.stopPropagation()}> + {/* Category tabs */} + {!query && totalVisible > 1 && ( +
+ + {allCategories.filter(cat => categories[cat]).map(cat => ( + + ))} +
+ )} + {/* Header hint */}
- - Blocs — utilisez ↑↓ pour naviguer, Entrée pour insérer + ↑↓ naviguer · Entrée insérer · Tab changer de section
+ {aiLoading && (
@@ -449,6 +486,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI return (
) } +