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