From ddbbebbb675968112d39cc8839e0bbcff6f6e5ff Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 17 Mar 2026 22:42:55 +0100 Subject: [PATCH] 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) --- src/App.tsx | 27 +++++ src/components/Editor.css | 88 +++++++++++++- src/components/Editor.tsx | 7 ++ src/components/EditorContent.tsx | 26 +++++ src/components/EmojiPicker.tsx | 114 ++++++++++++++++++ src/components/NoteIcon.test.tsx | 78 +++++++++++++ src/components/NoteIcon.tsx | 102 +++++++++++++++++ src/components/NoteItem.tsx | 2 + src/components/SearchPanel.tsx | 6 +- src/hooks/useAppCommands.ts | 6 + src/hooks/useCommandRegistry.test.ts | 45 ++++++++ src/hooks/useCommandRegistry.ts | 20 ++++ src/hooks/useNoteSearch.ts | 4 +- src/utils/__tests__/emoji.test.ts | 39 +++++++ src/utils/emoji.ts | 53 +++++++++ tests/smoke/note-icon.spec.ts | 165 +++++++++++++++++++++++++++ 16 files changed, 779 insertions(+), 3 deletions(-) create mode 100644 src/components/EmojiPicker.tsx create mode 100644 src/components/NoteIcon.test.tsx create mode 100644 src/components/NoteIcon.tsx create mode 100644 src/utils/__tests__/emoji.test.ts create mode 100644 src/utils/emoji.ts create mode 100644 tests/smoke/note-icon.spec.ts diff --git a/src/App.tsx b/src/App.tsx index 00a3a973..5e0c2485 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} /> diff --git a/src/components/Editor.css b/src/components/Editor.css index 71dba97d..b6ef6e76 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -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; } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 07910b92..ce2f0bd7 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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) && } diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index bd341500..c73b6a23 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -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 /** 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 (
@@ -189,6 +207,14 @@ export function EditorContent({ {activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && ( breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} /> )} + {showTitleField && ( + + )} {showTitleField && ( void + onClose: () => void +} + +export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) { + const [search, setSearch] = useState('') + const [activeCategory, setActiveCategory] = useState(0) + const inputRef = useRef(null) + const containerRef = useRef(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 ( +
+
+ setSearch(e.target.value)} + data-testid="emoji-picker-search" + /> +
+ {!search.trim() && ( +
+ {EMOJI_CATEGORIES.map((cat, i) => ( + + ))} +
+ )} +
+ {filteredCategories.map((cat, ci) => { + const show = search.trim() || ci === activeCategory + if (!show) return null + return ( +
+ {!search.trim() && ( +
+ {cat.name} +
+ )} +
+ {cat.emojis.map(emoji => ( + + ))} +
+
+ ) + })} +
+
+ ) +} diff --git a/src/components/NoteIcon.test.tsx b/src/components/NoteIcon.test.tsx new file mode 100644 index 00000000..2bdc5b95 --- /dev/null +++ b/src/components/NoteIcon.test.tsx @@ -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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} 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( {}} />) + 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( {}} onRemoveIcon={() => {}} />) + expect(screen.queryByTestId('note-icon-add')).not.toBeInTheDocument() + }) + + it('disables icon click when not editable', () => { + render( {}} onRemoveIcon={() => {}} />) + const display = screen.getByTestId('note-icon-display') + expect(display).toBeDisabled() + }) + + it('opens picker on laputa:open-icon-picker event', () => { + render( {}} onRemoveIcon={() => {}} />) + act(() => { window.dispatchEvent(new CustomEvent('laputa:open-icon-picker')) }) + expect(screen.getByTestId('emoji-picker')).toBeInTheDocument() + }) +}) diff --git a/src/components/NoteIcon.tsx b/src/components/NoteIcon.tsx new file mode 100644 index 00000000..fff01b74 --- /dev/null +++ b/src/components/NoteIcon.tsx @@ -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 ( +
+ {hasIcon ? ( + + ) : ( + editable && ( + + ) + )} + {showRemove && hasIcon && ( +
+ + +
+ )} + {pickerOpen && ( + setPickerOpen(false)} + /> + )} +
+ ) +} diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index abbca745..b30be6b1 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -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>> = { Project: Wrench, @@ -124,6 +125,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
{noteStatus !== 'clean' && } + {entry.icon && isEmoji(entry.icon) && {entry.icon}} {entry.title}
diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index 29ede4a3..488747be 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -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({ >
- {result.title} + + {entry?.icon && isEmoji(entry.icon) && {entry.icon}} + {result.title} + {noteType && ( {noteType} )} diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 02c1ce03..bcde476b 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -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({ diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 00ca2dbe..d047a5cd 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -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', () => { diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 4352abf0..3968eec6 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -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, ]) } diff --git a/src/hooks/useNoteSearch.ts b/src/hooks/useNoteSearch.ts index 03a5644c..fee375f2 100644 --- a/src/hooks/useNoteSearch.ts +++ b/src/hooks/useNoteSearch.ts @@ -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): 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, diff --git a/src/utils/__tests__/emoji.test.ts b/src/utils/__tests__/emoji.test.ts new file mode 100644 index 00000000..91db78a9 --- /dev/null +++ b/src/utils/__tests__/emoji.test.ts @@ -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) + }) +}) diff --git a/src/utils/emoji.ts b/src/utils/emoji.ts new file mode 100644 index 00000000..11edc77b --- /dev/null +++ b/src/utils/emoji.ts @@ -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: ['โœ…', 'โŒ', 'โญ•', 'โ“', 'โ—', '๐Ÿ’ฏ', '๐Ÿ”ด', '๐ŸŸ ', '๐ŸŸก', '๐ŸŸข', '๐Ÿ”ต', '๐ŸŸฃ', 'โšซ', 'โšช', '๐ŸŸค', '๐Ÿ”ถ', '๐Ÿ”ท', '๐Ÿ”ธ', '๐Ÿ”น', 'โ–ถ๏ธ', 'โธ๏ธ', 'โน๏ธ', 'โบ๏ธ', 'โญ๏ธ', '๐Ÿ”€', '๐Ÿ”', '๐Ÿ”‚', '๐Ÿ”„', 'โžก๏ธ', 'โฌ…๏ธ'], + }, +] diff --git a/tests/smoke/note-icon.spec.ts b/tests/smoke/note-icon.spec.ts new file mode 100644 index 00000000..d0b209a9 --- /dev/null +++ b/tests/smoke/note-icon.spec.ts @@ -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) + } + }) +})