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:
lucaronin
2026-03-17 22:42:55 +01:00
parent 0897cc5d95
commit ddbbebbb67
16 changed files with 779 additions and 3 deletions

View File

@@ -23,6 +23,7 @@ import { useCommitFlow } from './hooks/useCommitFlow'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
import { isEmoji } from './utils/emoji'
import { useDialogs } from './hooks/useDialogs'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useGitHistory } from './hooks/useGitHistory'
@@ -231,6 +232,24 @@ function App() {
vault.reloadVault()
}, [vault])
const handleSetNoteIcon = useCallback(async (path: string, emoji: string) => {
await notes.handleUpdateFrontmatter(path, 'icon', emoji)
}, [notes])
const handleRemoveNoteIcon = useCallback(async (path: string) => {
await notes.handleDeleteProperty(path, 'icon')
}, [notes])
/** Command palette: open the emoji picker on the active note's NoteIcon. */
const handleSetNoteIconCommand = useCallback(() => {
window.dispatchEvent(new CustomEvent('laputa:open-icon-picker'))
}, [])
/** Command palette: remove the active note's emoji icon. */
const handleRemoveNoteIconCommand = useCallback(() => {
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
}, [notes.activeTabPath, handleRemoveNoteIcon])
const { triggerIncrementalIndex } = indexing
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
@@ -459,6 +478,12 @@ function App() {
onReindexVault: indexing.triggerFullReindex,
onReloadVault: vault.reloadVault,
onRepairVault: handleRepairVault,
onSetNoteIcon: handleSetNoteIconCommand,
onRemoveNoteIcon: handleRemoveNoteIconCommand,
activeNoteHasIcon: (() => {
const ae = vault.entries.find(e => e.path === notes.activeTabPath)
return !!(ae?.icon && isEmoji(ae.icon))
})(),
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
@@ -558,6 +583,8 @@ function App() {
onFileCreated={handleAgentFileCreated}
onFileModified={handleAgentFileModified}
onVaultChanged={handleAgentVaultChanged}
onSetNoteIcon={handleSetNoteIcon}
onRemoveNoteIcon={handleRemoveNoteIcon}
/>
</div>
</div>

View File

@@ -162,9 +162,95 @@
opacity: 1 !important;
}
/* --- Note Icon Area --- */
.note-icon-area {
padding: 16px 54px 0;
flex-shrink: 0;
}
.note-icon-button {
border: none;
background: transparent;
cursor: pointer;
padding: 0;
line-height: 1;
}
.note-icon-button:disabled {
cursor: default;
}
.note-icon-button--active {
font-size: 40px;
transition: transform 0.1s;
}
.note-icon-button--active:hover:not(:disabled) {
transform: scale(1.1);
}
.note-icon-button--add {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-faint, #999);
font-size: 13px;
opacity: 0;
transition: opacity 0.15s;
}
.note-icon-area:hover .note-icon-button--add,
.note-icon-button--add:focus-visible {
opacity: 1;
}
.note-icon-button__plus {
font-size: 16px;
font-weight: 300;
}
.note-icon-button__label {
font-size: 12px;
}
.note-icon-menu {
position: absolute;
left: 0;
top: calc(100% + 4px);
z-index: 50;
display: flex;
flex-direction: column;
min-width: 140px;
border-radius: 8px;
border: 1px solid var(--border-dialog, var(--border));
background: var(--popover);
box-shadow: 0 4px 16px var(--shadow-dialog, rgba(0,0,0,0.1));
padding: 4px;
}
.note-icon-menu__item {
border: none;
background: transparent;
cursor: pointer;
text-align: left;
padding: 6px 10px;
font-size: 13px;
color: var(--foreground);
border-radius: 4px;
transition: background 0.1s;
}
.note-icon-menu__item:hover {
background: var(--accent);
}
.note-icon-menu__item--danger {
color: var(--destructive, #ef4444);
}
/* --- Title Field --- */
.title-field {
padding: 16px 54px 0;
padding: 4px 54px 0;
flex-shrink: 0;
}

View File

@@ -74,6 +74,10 @@ interface EditorProps {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
/** Called when user sets an emoji icon on a note. */
onSetNoteIcon?: (path: string, emoji: string) => void
/** Called when user removes an emoji icon from a note. */
onRemoveNoteIcon?: (path: string) => void
}
function useEditorModeExclusion({
@@ -235,6 +239,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
isDarkTheme, onFileCreated, onFileModified, onVaultChanged,
onSetNoteIcon, onRemoveNoteIcon,
} = props
const {
@@ -300,6 +305,8 @@ export const Editor = memo(function Editor(props: EditorProps) {
isDarkTheme={isDarkTheme}
rawLatestContentRef={rawLatestContentRef}
onTitleChange={onTitleSync}
onSetNoteIcon={onSetNoteIcon}
onRemoveNoteIcon={onRemoveNoteIcon}
/>
}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}

View File

@@ -1,14 +1,17 @@
import type React from 'react'
import { useCallback } from 'react'
import type { VaultEntry, NoteStatus } from '../types'
import type { useCreateBlockNote } from '@blocknote/react'
import { DiffView } from './DiffView'
import { BreadcrumbBar } from './BreadcrumbBar'
import { TitleField } from './TitleField'
import { NoteIcon } from './NoteIcon'
import { TrashedNoteBanner } from './TrashedNoteBanner'
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
import { isEmoji } from '../utils/emoji'
interface Tab {
entry: VaultEntry
@@ -47,6 +50,10 @@ interface EditorContentProps {
rawLatestContentRef?: React.MutableRefObject<string | null>
/** Called when the user edits the dedicated title field. */
onTitleChange?: (path: string, newTitle: string) => void
/** Called when user sets or changes an emoji icon via the picker. */
onSetNoteIcon?: (path: string, emoji: string) => void
/** Called when user removes an emoji icon. */
onRemoveNoteIcon?: (path: string) => void
}
function EditorLoadingSkeleton() {
@@ -167,10 +174,21 @@ export function EditorContent({
rawMode, onToggleRaw, onRawContentChange, onSave,
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
onDeleteNote, rawLatestContentRef, onTitleChange,
onSetNoteIcon, onRemoveNoteIcon,
...breadcrumbProps
}: EditorContentProps) {
const isTrashed = activeTab?.entry.trashed ?? false
const showTitleField = activeTab && !diffMode && !rawMode
const entryIcon = activeTab?.entry.icon ?? null
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
const handleSetIcon = useCallback((emoji: string) => {
if (activeTab) onSetNoteIcon?.(activeTab.entry.path, emoji)
}, [activeTab, onSetNoteIcon])
const handleRemoveIcon = useCallback(() => {
if (activeTab) onRemoveNoteIcon?.(activeTab.entry.path)
}, [activeTab, onRemoveNoteIcon])
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
@@ -189,6 +207,14 @@ export function EditorContent({
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
)}
{showTitleField && (
<NoteIcon
icon={emojiIcon}
editable={!isTrashed}
onSetIcon={handleSetIcon}
onRemoveIcon={handleRemoveIcon}
/>
)}
{showTitleField && (
<TitleField
title={activeTab.entry.title}

View File

@@ -0,0 +1,114 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { EMOJI_CATEGORIES } from '../utils/emoji'
interface EmojiPickerProps {
onSelect: (emoji: string) => void
onClose: () => void
}
export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
const [search, setSearch] = useState('')
const [activeCategory, setActiveCategory] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setTimeout(() => inputRef.current?.focus(), 50)
}, [])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onClose()
}
}
window.addEventListener('keydown', handler, true)
return () => window.removeEventListener('keydown', handler, true)
}, [onClose])
// Close when clicking outside
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose()
}
}
// Delay adding the listener to avoid the click that opened the picker from closing it
const timer = setTimeout(() => document.addEventListener('mousedown', handler), 0)
return () => { clearTimeout(timer); document.removeEventListener('mousedown', handler) }
}, [onClose])
const handleSelect = useCallback((emoji: string) => {
onSelect(emoji)
onClose()
}, [onSelect, onClose])
const filteredCategories = search.trim()
? [{ name: 'Results', emojis: EMOJI_CATEGORIES.flatMap(c => c.emojis) }]
: EMOJI_CATEGORIES
return (
<div
ref={containerRef}
className="absolute z-50 w-[320px] rounded-lg border border-[var(--border-dialog)] bg-popover shadow-lg"
style={{ left: 54, top: 0 }}
data-testid="emoji-picker"
>
<div className="border-b border-border px-3 py-2">
<input
ref={inputRef}
type="text"
className="w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
placeholder="Search emoji..."
value={search}
onChange={e => setSearch(e.target.value)}
data-testid="emoji-picker-search"
/>
</div>
{!search.trim() && (
<div className="flex gap-1 border-b border-border px-3 py-1.5 overflow-x-auto">
{EMOJI_CATEGORIES.map((cat, i) => (
<button
key={cat.name}
className={`shrink-0 rounded px-1.5 py-0.5 text-[11px] transition-colors ${
i === activeCategory ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-secondary'
}`}
onClick={() => setActiveCategory(i)}
>
{cat.name}
</button>
))}
</div>
)}
<div className="max-h-[240px] overflow-y-auto p-2" data-testid="emoji-picker-grid">
{filteredCategories.map((cat, ci) => {
const show = search.trim() || ci === activeCategory
if (!show) return null
return (
<div key={cat.name}>
{!search.trim() && (
<div className="px-1 pb-1 pt-1.5 text-[11px] font-medium text-muted-foreground">
{cat.name}
</div>
)}
<div className="grid grid-cols-8 gap-0.5">
{cat.emojis.map(emoji => (
<button
key={emoji}
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
onClick={() => handleSelect(emoji)}
data-testid="emoji-option"
>
{emoji}
</button>
))}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { NoteIcon } from './NoteIcon'
describe('NoteIcon', () => {
it('shows add button when no icon is set', () => {
render(<NoteIcon icon={null} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
expect(screen.queryByTestId('note-icon-display')).not.toBeInTheDocument()
})
it('displays the emoji when icon is set', () => {
render(<NoteIcon icon="🎯" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
expect(screen.getByTestId('note-icon-display')).toHaveTextContent('🎯')
expect(screen.queryByTestId('note-icon-add')).not.toBeInTheDocument()
})
it('does not display Phosphor icon names as emoji', () => {
render(<NoteIcon icon="rocket" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
// Phosphor icon name is not an emoji → shows add button
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
})
it('opens emoji picker when add button is clicked', () => {
render(<NoteIcon icon={null} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
fireEvent.click(screen.getByTestId('note-icon-add'))
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
})
it('shows change/remove menu when existing icon is clicked', () => {
render(<NoteIcon icon="🔥" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
fireEvent.click(screen.getByTestId('note-icon-display'))
expect(screen.getByTestId('note-icon-menu')).toBeInTheDocument()
expect(screen.getByTestId('note-icon-change')).toBeInTheDocument()
expect(screen.getByTestId('note-icon-remove')).toBeInTheDocument()
})
it('calls onRemoveIcon when Remove is clicked', () => {
const onRemove = vi.fn()
render(<NoteIcon icon="🔥" onSetIcon={() => {}} onRemoveIcon={onRemove} />)
fireEvent.click(screen.getByTestId('note-icon-display'))
fireEvent.click(screen.getByTestId('note-icon-remove'))
expect(onRemove).toHaveBeenCalledOnce()
})
it('opens picker when Change is clicked from menu', () => {
render(<NoteIcon icon="🔥" onSetIcon={() => {}} onRemoveIcon={() => {}} />)
fireEvent.click(screen.getByTestId('note-icon-display'))
fireEvent.click(screen.getByTestId('note-icon-change'))
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
})
it('calls onSetIcon when emoji is selected from picker', () => {
const onSet = vi.fn()
render(<NoteIcon icon={null} onSetIcon={onSet} onRemoveIcon={() => {}} />)
fireEvent.click(screen.getByTestId('note-icon-add'))
const emojiButtons = screen.getAllByTestId('emoji-option')
fireEvent.click(emojiButtons[0])
expect(onSet).toHaveBeenCalledOnce()
})
it('hides add button when not editable', () => {
render(<NoteIcon icon={null} editable={false} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
expect(screen.queryByTestId('note-icon-add')).not.toBeInTheDocument()
})
it('disables icon click when not editable', () => {
render(<NoteIcon icon="🎯" editable={false} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
const display = screen.getByTestId('note-icon-display')
expect(display).toBeDisabled()
})
it('opens picker on laputa:open-icon-picker event', () => {
render(<NoteIcon icon={null} onSetIcon={() => {}} onRemoveIcon={() => {}} />)
act(() => { window.dispatchEvent(new CustomEvent('laputa:open-icon-picker')) })
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
})
})

102
src/components/NoteIcon.tsx Normal file
View File

@@ -0,0 +1,102 @@
import { useState, useCallback, useEffect } from 'react'
import { EmojiPicker } from './EmojiPicker'
import { isEmoji } from '../utils/emoji'
interface NoteIconProps {
icon: string | null
editable?: boolean
onSetIcon: (emoji: string) => void
onRemoveIcon: () => void
}
export function NoteIcon({ icon, editable = true, onSetIcon, onRemoveIcon }: NoteIconProps) {
const [pickerOpen, setPickerOpen] = useState(false)
const [showRemove, setShowRemove] = useState(false)
const hasIcon = icon && isEmoji(icon)
// Listen for command palette "Set Note Icon" event
useEffect(() => {
if (!editable) return
const handler = () => setPickerOpen(true)
window.addEventListener('laputa:open-icon-picker', handler)
return () => window.removeEventListener('laputa:open-icon-picker', handler)
}, [editable])
const openPicker = useCallback(() => {
if (!editable) return
if (hasIcon) {
setShowRemove(prev => !prev)
} else {
setPickerOpen(true)
}
}, [editable, hasIcon])
const handleSelect = useCallback((emoji: string) => {
onSetIcon(emoji)
setPickerOpen(false)
setShowRemove(false)
}, [onSetIcon])
const handleRemove = useCallback(() => {
onRemoveIcon()
setShowRemove(false)
}, [onRemoveIcon])
const handleChangePicker = useCallback(() => {
setShowRemove(false)
setPickerOpen(true)
}, [])
return (
<div className="note-icon-area" data-testid="note-icon-area" style={{ position: 'relative' }}>
{hasIcon ? (
<button
className="note-icon-button note-icon-button--active"
onClick={openPicker}
data-testid="note-icon-display"
title={editable ? 'Change or remove icon' : undefined}
disabled={!editable}
>
{icon}
</button>
) : (
editable && (
<button
className="note-icon-button note-icon-button--add"
onClick={openPicker}
data-testid="note-icon-add"
title="Add icon"
>
<span className="note-icon-button__plus">+</span>
<span className="note-icon-button__label">Add icon</span>
</button>
)
)}
{showRemove && hasIcon && (
<div className="note-icon-menu" data-testid="note-icon-menu">
<button
className="note-icon-menu__item"
onClick={handleChangePicker}
data-testid="note-icon-change"
>
Change icon
</button>
<button
className="note-icon-menu__item note-icon-menu__item--danger"
onClick={handleRemove}
data-testid="note-icon-remove"
>
Remove icon
</button>
</div>
)}
{pickerOpen && (
<EmojiPicker
onSelect={handleSelect}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
)
}

View File

@@ -8,6 +8,7 @@ import {
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
import { isEmoji } from '../utils/emoji'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
@@ -124,6 +125,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
<div className="pr-5">
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
{noteStatus !== 'clean' && <StatusDot noteStatus={noteStatus} />}
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
{entry.title}
<StateBadge archived={entry.archived} trashed={entry.trashed} />
</div>

View File

@@ -6,6 +6,7 @@ import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
import { formatSearchSubtitle } from '../utils/noteListHelpers'
import { getTypeIcon } from './NoteItem'
import { isEmoji } from '../utils/emoji'
interface SearchPanelProps {
open: boolean
@@ -210,7 +211,10 @@ function SearchContent({
>
<div className="flex items-center gap-2">
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{result.title}</span>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
{entry?.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
{result.title}
</span>
{noteType && (
<span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span>
)}

View File

@@ -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({

View File

@@ -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', () => {

View File

@@ -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,
])
}

View File

@@ -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,

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest'
import { isEmoji } from '../emoji'
describe('isEmoji', () => {
it('returns true for common emoji', () => {
expect(isEmoji('🎯')).toBe(true)
expect(isEmoji('🔥')).toBe(true)
expect(isEmoji('🚀')).toBe(true)
expect(isEmoji('❤️')).toBe(true)
expect(isEmoji('✨')).toBe(true)
})
it('returns false for Phosphor icon names', () => {
expect(isEmoji('cooking-pot')).toBe(false)
expect(isEmoji('file-text')).toBe(false)
expect(isEmoji('rocket')).toBe(false)
expect(isEmoji('star')).toBe(false)
})
it('returns false for empty string', () => {
expect(isEmoji('')).toBe(false)
})
it('returns false for regular text', () => {
expect(isEmoji('hello')).toBe(false)
expect(isEmoji('ABC')).toBe(false)
expect(isEmoji('123')).toBe(false)
})
it('handles compound emoji (ZWJ sequences)', () => {
expect(isEmoji('👨‍💻')).toBe(true)
expect(isEmoji('🧑‍🔬')).toBe(true)
})
it('returns false for multi-emoji strings', () => {
expect(isEmoji('🔥🚀')).toBe(false)
expect(isEmoji('hi 🎯')).toBe(false)
})
})

53
src/utils/emoji.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Detects whether a string is a single emoji (as opposed to a Phosphor icon name).
* Used to differentiate emoji note icons from kebab-case Phosphor icon names.
*/
export function isEmoji(value: string): boolean {
if (!value) return false
// Phosphor icon names are always lowercase ASCII with hyphens
if (/^[a-z][a-z0-9-]*$/.test(value)) return false
// Match a single emoji (including compound emoji with ZWJ, skin tones, variation selectors, flags)
// Uses Unicode segmentation: a single emoji can be base + modifiers/ZWJ sequences
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\ufe0f)(\u200d(\p{Emoji_Presentation}|\p{Emoji}\ufe0f)|\p{Emoji_Modifier})*$/u
return emojiRegex.test(value)
}
/** Curated emoji categories for the note icon picker. */
export const EMOJI_CATEGORIES: { name: string; emojis: string[] }[] = [
{
name: 'Smileys',
emojis: ['😀', '😃', '😄', '😁', '😆', '🥹', '😅', '🤣', '😂', '🙂', '😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😎', '🤓', '🧐', '🤔', '🤗', '🫡', '🤫', '🫠', '😶', '😑', '😬', '🙄', '😴'],
},
{
name: 'Hands & People',
emojis: ['👋', '🤚', '✋', '🖐️', '👌', '🤌', '✌️', '🤞', '🫰', '🤙', '👍', '👎', '👏', '🙌', '🫶', '🙏', '💪', '🧠', '👀', '👁️', '👤', '🧑‍💻', '🧑‍🎨', '🧑‍🔬', '🧑‍🚀', '🧑‍🏫', '🧑‍⚕️', '🧑‍🍳', '🏃', '🧘'],
},
{
name: 'Nature',
emojis: ['🌱', '🌿', '🍀', '🌵', '🌲', '🌳', '🌴', '🌸', '🌺', '🌻', '🌹', '💐', '🍂', '🍁', '🍃', '🌾', '🐝', '🦋', '🐛', '🐞', '🐦', '🦅', '🐺', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐸'],
},
{
name: 'Food & Drink',
emojis: ['🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐', '🍒', '🍑', '🥝', '🍅', '🥑', '🌽', '🥕', '🧅', '🍞', '🥐', '🧁', '🍰', '🎂', '🍪', '☕', '🍵', '🧃', '🥤', '🍺', '🍷', '🥂'],
},
{
name: 'Activities',
emojis: ['⚽', '🏀', '🏈', '⚾', '🎾', '🏐', '🎱', '🏓', '🎮', '🕹️', '🎲', '🧩', '♟️', '🎯', '🎳', '🎸', '🎹', '🎺', '🎨', '🖌️', '📷', '🎬', '🎭', '🎤', '🎧', '📚', '📝', '✏️', '🖊️', '📖'],
},
{
name: 'Travel & Places',
emojis: ['🏠', '🏡', '🏢', '🏗️', '🏰', '🏛️', '⛪', '🕌', '🗼', '🗽', '⛲', '🎪', '🚀', '✈️', '🚂', '🚗', '🚲', '⛵', '🏔️', '🌋', '🏖️', '🏜️', '🗺️', '🌍', '🌎', '🌏', '🧭', '⛺', '🎡', '🎢'],
},
{
name: 'Objects',
emojis: ['💡', '🔦', '🕯️', '📱', '💻', '⌨️', '🖥️', '🖨️', '📸', '🔭', '🔬', '🧪', '💊', '🩺', '🔑', '🗝️', '🔒', '🔓', '🧲', '⚙️', '🔧', '🔨', '⛏️', '🪛', '🧰', '📦', '📮', '✉️', '📩', '🏷️'],
},
{
name: 'Symbols',
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '💔', '❣️', '💕', '💝', '⭐', '🌟', '✨', '💫', '🔥', '💥', '🎉', '🎊', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖️', '📌', '📍', '🚩', '🏁'],
},
{
name: 'Flags & Signs',
emojis: ['✅', '❌', '⭕', '❓', '❗', '💯', '🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '⚫', '⚪', '🟤', '🔶', '🔷', '🔸', '🔹', '▶️', '⏸️', '⏹️', '⏺️', '⏭️', '🔀', '🔁', '🔂', '🔄', '➡️', '⬅️'],
},
]

View File

@@ -0,0 +1,165 @@
import { test, expect } from '@playwright/test'
import { openCommandPalette, findCommand, executeCommand, sendShortcut } from './helpers'
test.describe('Note icon emoji picker', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2500)
})
test('emoji picker opens from Add Icon button and selects emoji', async ({ page }) => {
// Open a note by clicking the first item in the note list
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
await noteItem.waitFor({ timeout: 5000 })
await noteItem.click()
await page.waitForTimeout(500)
// The note icon area should be visible
const iconArea = page.locator('[data-testid="note-icon-area"]')
await expect(iconArea).toBeVisible({ timeout: 3000 })
// Hover to reveal the "Add icon" button and click it
await iconArea.hover()
const addButton = page.locator('[data-testid="note-icon-add"]')
await expect(addButton).toBeVisible()
await addButton.click()
// Emoji picker should appear
const picker = page.locator('[data-testid="emoji-picker"]')
await expect(picker).toBeVisible({ timeout: 2000 })
// Click the first emoji
const emojiOption = picker.locator('[data-testid="emoji-option"]').first()
await emojiOption.click()
// Picker should close
await expect(picker).not.toBeVisible()
// The icon should now be displayed
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
await expect(iconDisplay).toBeVisible({ timeout: 2000 })
})
test('emoji icon can be removed via menu', async ({ page }) => {
// Open a note
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
await noteItem.waitFor({ timeout: 5000 })
await noteItem.click()
await page.waitForTimeout(500)
// Add an icon first
const iconArea = page.locator('[data-testid="note-icon-area"]')
await iconArea.hover()
await page.locator('[data-testid="note-icon-add"]').click()
await page.locator('[data-testid="emoji-option"]').first().click()
await page.waitForTimeout(300)
// Click the displayed icon to show the menu
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
await iconDisplay.click()
// Menu should show Remove option
const removeButton = page.locator('[data-testid="note-icon-remove"]')
await expect(removeButton).toBeVisible()
await removeButton.click()
// Icon should be removed, add button returns on hover
await expect(iconDisplay).not.toBeVisible()
await iconArea.hover()
await expect(page.locator('[data-testid="note-icon-add"]')).toBeVisible()
})
test('Cmd+K shows Set Note Icon command', async ({ page }) => {
// Open a note first
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
await noteItem.waitFor({ timeout: 5000 })
await noteItem.click()
await page.waitForTimeout(500)
// Open command palette and search for the icon command
await openCommandPalette(page)
const found = await findCommand(page, 'Set Note Icon')
expect(found).toBe(true)
})
test('Set Note Icon command opens emoji picker', async ({ page }) => {
// Open a note first
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
await noteItem.waitFor({ timeout: 5000 })
await noteItem.click()
await page.waitForTimeout(500)
// Use command palette to open emoji picker
await openCommandPalette(page)
await executeCommand(page, 'Set Note Icon')
await page.waitForTimeout(300)
// Emoji picker should be visible
const picker = page.locator('[data-testid="emoji-picker"]')
await expect(picker).toBeVisible({ timeout: 2000 })
// Select an emoji
await picker.locator('[data-testid="emoji-option"]').first().click()
// Icon should be displayed
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
await expect(iconDisplay).toBeVisible({ timeout: 2000 })
})
test('Escape closes the emoji picker', async ({ page }) => {
// Open a note
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
await noteItem.waitFor({ timeout: 5000 })
await noteItem.click()
await page.waitForTimeout(500)
// Open picker
const iconArea = page.locator('[data-testid="note-icon-area"]')
await iconArea.hover()
await page.locator('[data-testid="note-icon-add"]').click()
const picker = page.locator('[data-testid="emoji-picker"]')
await expect(picker).toBeVisible()
// Press Escape to close
await page.keyboard.press('Escape')
await expect(picker).not.toBeVisible()
})
test('emoji icon is shown in Quick Open (Cmd+P) results', async ({ page }) => {
// Open a note and set an icon
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
await noteItem.waitFor({ timeout: 5000 })
// Get the note title for later search
const noteTitle = await noteItem.locator('.font-medium, .text-sm').first().textContent()
await noteItem.click()
await page.waitForTimeout(500)
// Set an icon
const iconArea = page.locator('[data-testid="note-icon-area"]')
await iconArea.hover()
await page.locator('[data-testid="note-icon-add"]').click()
const firstEmoji = page.locator('[data-testid="emoji-option"]').first()
const emojiText = await firstEmoji.textContent()
await firstEmoji.click()
await page.waitForTimeout(500)
// Open Quick Open and search for the note
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput).toBeVisible()
if (noteTitle && emojiText) {
await searchInput.fill(noteTitle.trim().substring(0, 10))
await page.waitForTimeout(300)
// Verify the emoji appears in the search results
const results = page.locator('.py-1 .cursor-pointer .truncate')
const resultTexts = await results.allTextContents()
const hasEmoji = resultTexts.some(t => t.includes(emojiText))
expect(hasEmoji).toBe(true)
}
})
})