Every note can now have an optional emoji icon (frontmatter `icon` field). The icon is displayed in the editor header, note list, search results, and Quick Open. Includes command palette commands "Set Note Icon" and "Remove Note Icon", plus full test coverage (Vitest + Playwright). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
82 lines
2.9 KiB
TypeScript
82 lines
2.9 KiB
TypeScript
import { useState, useMemo, useCallback, useEffect } from 'react'
|
|
import type { VaultEntry } from '../types'
|
|
import { fuzzyMatch, bestSearchRank } from '../utils/fuzzyMatch'
|
|
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
|
import { getTypeIcon } from '../components/NoteItem'
|
|
import type { NoteSearchResultItem } from '../components/NoteSearchList'
|
|
import { isEmoji } from '../utils/emoji'
|
|
|
|
const DEFAULT_MAX_RESULTS = 20
|
|
|
|
export interface NoteSearchResult extends NoteSearchResultItem {
|
|
entry: VaultEntry
|
|
}
|
|
|
|
function toResult(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>): NoteSearchResult {
|
|
const noteType = e.isA || undefined
|
|
const te = typeEntryMap[e.isA ?? '']
|
|
const emojiPrefix = e.icon && isEmoji(e.icon) ? `${e.icon} ` : ''
|
|
return {
|
|
entry: e,
|
|
title: `${emojiPrefix}${e.title}`,
|
|
noteType,
|
|
typeColor: noteType ? getTypeColor(e.isA, te?.color) : undefined,
|
|
typeLightColor: noteType ? getTypeLightColor(e.isA, te?.color) : undefined,
|
|
TypeIcon: noteType ? getTypeIcon(e.isA, te?.icon) : undefined,
|
|
}
|
|
}
|
|
|
|
/** Types excluded from note search results (internal infrastructure). */
|
|
const SEARCH_EXCLUDED_TYPES = new Set(['Config'])
|
|
|
|
export function useNoteSearch(entries: VaultEntry[], query: string, maxResults = DEFAULT_MAX_RESULTS) {
|
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
|
|
|
const searchableEntries = useMemo(
|
|
() => entries.filter((e) => !e.trashed && !SEARCH_EXCLUDED_TYPES.has(e.isA ?? '')),
|
|
[entries],
|
|
)
|
|
|
|
const results: NoteSearchResult[] = useMemo(() => {
|
|
const mapResult = (e: VaultEntry) => toResult(e, typeEntryMap)
|
|
if (!query.trim()) {
|
|
return [...searchableEntries]
|
|
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
|
|
.slice(0, maxResults)
|
|
.map(mapResult)
|
|
}
|
|
return searchableEntries
|
|
.map((e) => ({
|
|
entry: e,
|
|
...fuzzyMatch(query, e.title),
|
|
rank: bestSearchRank(query, e.title, e.aliases),
|
|
}))
|
|
.filter((r) => r.match)
|
|
.sort((a, b) => a.rank - b.rank || b.score - a.score)
|
|
.slice(0, maxResults)
|
|
.map((r) => mapResult(r.entry))
|
|
}, [searchableEntries, query, maxResults, typeEntryMap])
|
|
|
|
useEffect(() => {
|
|
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change
|
|
}, [query])
|
|
|
|
const selectedEntry = results[selectedIndex]?.entry ?? null
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent | KeyboardEvent) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setSelectedIndex((i) => Math.min(i + 1, results.length - 1))
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setSelectedIndex((i) => Math.max(i - 1, 0))
|
|
}
|
|
},
|
|
[results.length],
|
|
)
|
|
|
|
return { results, selectedIndex, setSelectedIndex, selectedEntry, handleKeyDown }
|
|
}
|