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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
114
src/components/EmojiPicker.tsx
Normal file
114
src/components/EmojiPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
src/components/NoteIcon.test.tsx
Normal file
78
src/components/NoteIcon.test.tsx
Normal 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
102
src/components/NoteIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user