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>
This commit is contained in:
@@ -71,6 +71,9 @@ interface AppCommandsConfig {
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
activeNoteHasIcon?: boolean
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
@@ -217,6 +220,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
onSetNoteIcon: config.onSetNoteIcon,
|
||||
onRemoveNoteIcon: config.onRemoveNoteIcon,
|
||||
activeNoteHasIcon: config.activeNoteHasIcon,
|
||||
})
|
||||
|
||||
useKeyboardNavigation({
|
||||
|
||||
@@ -146,6 +146,51 @@ describe('useCommandRegistry', () => {
|
||||
rerender(makeConfig())
|
||||
expect(findCommand(result.current, 'resolve-conflicts')!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('includes set-note-icon command in Note group', () => {
|
||||
const config = makeConfig({ onSetNoteIcon: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'set-note-icon')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('Note')
|
||||
expect(cmd!.label).toBe('Set Note Icon')
|
||||
})
|
||||
|
||||
it('set-note-icon is enabled when active note and callback exist', () => {
|
||||
const config = makeConfig({ onSetNoteIcon: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'set-note-icon')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('set-note-icon is disabled when no active note', () => {
|
||||
const config = makeConfig({ activeTabPath: null, onSetNoteIcon: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'set-note-icon')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('remove-note-icon is enabled when active note has icon', () => {
|
||||
const config = makeConfig({ onRemoveNoteIcon: vi.fn(), activeNoteHasIcon: true })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'remove-note-icon')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('remove-note-icon is disabled when active note has no icon', () => {
|
||||
const config = makeConfig({ onRemoveNoteIcon: vi.fn(), activeNoteHasIcon: false })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'remove-note-icon')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('set-note-icon executes callback', () => {
|
||||
const onSetNoteIcon = vi.fn()
|
||||
const config = makeConfig({ onSetNoteIcon })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
findCommand(result.current, 'set-note-icon')!.execute()
|
||||
expect(onSetNoteIcon).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pluralizeType', () => {
|
||||
|
||||
@@ -18,6 +18,8 @@ interface CommandRegistryConfig {
|
||||
activeTabPath: string | null
|
||||
entries: VaultEntry[]
|
||||
modifiedCount: number
|
||||
/** Whether the active note has an emoji icon set. */
|
||||
activeNoteHasIcon?: boolean
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
@@ -25,6 +27,8 @@ interface CommandRegistryConfig {
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
@@ -205,6 +209,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onReindexVault,
|
||||
onReloadVault,
|
||||
onRepairVault,
|
||||
onSetNoteIcon,
|
||||
onRemoveNoteIcon,
|
||||
activeNoteHasIcon,
|
||||
} = config
|
||||
|
||||
const hasActiveNote = activeTabPath !== null
|
||||
@@ -247,6 +254,18 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
|
||||
enabled: hasActiveNote && !!onSetNoteIcon,
|
||||
execute: () => onSetNoteIcon?.(),
|
||||
},
|
||||
{
|
||||
id: 'remove-note-icon', label: 'Remove Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
|
||||
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
|
||||
execute: () => onRemoveNoteIcon?.(),
|
||||
},
|
||||
|
||||
// Git
|
||||
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
|
||||
@@ -290,5 +309,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
mcpStatus, onInstallMcp,
|
||||
onEmptyTrash, trashedCount,
|
||||
onReindexVault, onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -14,9 +15,10 @@ export interface NoteSearchResult extends NoteSearchResultItem {
|
||||
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: e.title,
|
||||
title: `${emojiPrefix}${e.title}`,
|
||||
noteType,
|
||||
typeColor: noteType ? getTypeColor(e.isA, te?.color) : undefined,
|
||||
typeLightColor: noteType ? getTypeLightColor(e.isA, te?.color) : undefined,
|
||||
|
||||
Reference in New Issue
Block a user