feat: replace grouped autocomplete with flat list and type badge

Switch wiki-link autocomplete from BlockNote's default grouped suggestion
menu to a custom flat list sorted by relevance. Each item shows the note
type (with its accent color) discretely on the right side. Reduce
MAX_RESULTS from 20 to 10 for a cleaner, more focused list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-24 23:42:09 +01:00
parent 9a5b37f602
commit 7736d20b9f
5 changed files with 131 additions and 5 deletions

View File

@@ -16,6 +16,8 @@ import { useEditorTheme } from '../hooks/useTheme'
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, countWords } from '../utils/wikilinks'
import { preFilterWikilinks, deduplicateByPath, disambiguateTitles, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import { getTypeColor } from '../utils/typeColors'
import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
import './Editor.css'
import './EditorTheme.css'
@@ -142,7 +144,7 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
[entries]
)
const getWikilinkItems = useCallback(async (query: string) => {
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < MIN_QUERY_LENGTH) return []
const candidates = preFilterWikilinks(baseItems, query)
@@ -159,7 +161,12 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
},
}))
const filtered = filterSuggestionItems(items, query).slice(0, MAX_RESULTS)
return disambiguateTitles(deduplicateByPath(filtered))
const final = disambiguateTitles(deduplicateByPath(filtered))
return final.map(({ group, ...rest }) => ({
...rest,
noteType: group !== 'Note' ? group : undefined,
typeColor: group !== 'Note' ? getTypeColor(group) : undefined,
}))
}, [baseItems, editor])
return (
@@ -177,6 +184,8 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
<SuggestionMenuController
triggerCharacter="[["
getItems={getWikilinkItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
/>
</BlockNoteView>
</div>

View File

@@ -0,0 +1,58 @@
/* Wikilink autocomplete — flat suggestion menu */
.wikilink-menu {
background: var(--bg-primary, #fff);
border: 1px solid var(--border, rgba(0, 0, 0, 0.1));
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
max-height: 320px;
overflow-y: auto;
padding: 4px;
min-width: 260px;
max-width: 400px;
z-index: 9999;
}
.wikilink-menu__empty {
padding: 8px 12px;
font-size: 13px;
color: var(--text-muted, #999);
}
.wikilink-menu__item {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
}
.wikilink-menu__item:hover {
background: var(--muted, rgba(0, 0, 0, 0.04));
}
.wikilink-menu__item--selected {
background: var(--accent, rgba(21, 93, 255, 0.08));
}
.wikilink-menu__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary, #1a1a1a);
}
.wikilink-menu__type {
flex-shrink: 0;
font-size: 11px;
font-weight: 500;
opacity: 0.8;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,59 @@
import { useRef, useEffect } from 'react'
import './WikilinkSuggestionMenu.css'
export interface WikilinkSuggestionItem {
title: string
onItemClick: () => void
noteType?: string
typeColor?: string
aliases?: string[]
entryTitle?: string
path?: string
}
interface WikilinkSuggestionMenuProps {
items: WikilinkSuggestionItem[]
loadingState: 'loading-initial' | 'loading' | 'loaded'
selectedIndex: number | undefined
onItemClick?: (item: WikilinkSuggestionItem) => void
}
export function WikilinkSuggestionMenu({ items, selectedIndex, onItemClick }: WikilinkSuggestionMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (selectedIndex === undefined || !menuRef.current) return
const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined
el?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
if (items.length === 0) {
return (
<div className="wikilink-menu" ref={menuRef}>
<div className="wikilink-menu__empty">No results</div>
</div>
)
}
return (
<div className="wikilink-menu" ref={menuRef}>
{items.map((item, index) => (
<div
key={`${item.title}-${item.path ?? index}`}
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
onClick={() => {
item.onItemClick()
onItemClick?.(item)
}}
>
<span className="wikilink-menu__title">{item.title}</span>
{item.noteType && (
<span className="wikilink-menu__type" style={{ color: item.typeColor }}>
{item.noteType}
</span>
)}
</div>
))}
</div>
)
}