Files
tolaria/src/hooks/useNoteSearch.ts
lucaronin ddbbebbb67 feat: add emoji icon picker for notes stored in frontmatter
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>
2026-03-17 22:42:55 +01:00

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 }
}