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

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