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:
27
src/App.tsx
27
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fuzzyMatch, bestSearchRank } from '../utils/fuzzyMatch'
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { getTypeIcon } from '../components/NoteItem'
|
||||
import type { NoteSearchResultItem } from '../components/NoteSearchList'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
|
||||
const DEFAULT_MAX_RESULTS = 20
|
||||
|
||||
@@ -14,9 +15,10 @@ export interface NoteSearchResult extends NoteSearchResultItem {
|
||||
function toResult(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>): NoteSearchResult {
|
||||
const noteType = e.isA || undefined
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
const emojiPrefix = e.icon && isEmoji(e.icon) ? `${e.icon} ` : ''
|
||||
return {
|
||||
entry: e,
|
||||
title: e.title,
|
||||
title: `${emojiPrefix}${e.title}`,
|
||||
noteType,
|
||||
typeColor: noteType ? getTypeColor(e.isA, te?.color) : undefined,
|
||||
typeLightColor: noteType ? getTypeLightColor(e.isA, te?.color) : undefined,
|
||||
|
||||
39
src/utils/__tests__/emoji.test.ts
Normal file
39
src/utils/__tests__/emoji.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isEmoji } from '../emoji'
|
||||
|
||||
describe('isEmoji', () => {
|
||||
it('returns true for common emoji', () => {
|
||||
expect(isEmoji('🎯')).toBe(true)
|
||||
expect(isEmoji('🔥')).toBe(true)
|
||||
expect(isEmoji('🚀')).toBe(true)
|
||||
expect(isEmoji('❤️')).toBe(true)
|
||||
expect(isEmoji('✨')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for Phosphor icon names', () => {
|
||||
expect(isEmoji('cooking-pot')).toBe(false)
|
||||
expect(isEmoji('file-text')).toBe(false)
|
||||
expect(isEmoji('rocket')).toBe(false)
|
||||
expect(isEmoji('star')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isEmoji('')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for regular text', () => {
|
||||
expect(isEmoji('hello')).toBe(false)
|
||||
expect(isEmoji('ABC')).toBe(false)
|
||||
expect(isEmoji('123')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles compound emoji (ZWJ sequences)', () => {
|
||||
expect(isEmoji('👨💻')).toBe(true)
|
||||
expect(isEmoji('🧑🔬')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for multi-emoji strings', () => {
|
||||
expect(isEmoji('🔥🚀')).toBe(false)
|
||||
expect(isEmoji('hi 🎯')).toBe(false)
|
||||
})
|
||||
})
|
||||
53
src/utils/emoji.ts
Normal file
53
src/utils/emoji.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Detects whether a string is a single emoji (as opposed to a Phosphor icon name).
|
||||
* Used to differentiate emoji note icons from kebab-case Phosphor icon names.
|
||||
*/
|
||||
export function isEmoji(value: string): boolean {
|
||||
if (!value) return false
|
||||
// Phosphor icon names are always lowercase ASCII with hyphens
|
||||
if (/^[a-z][a-z0-9-]*$/.test(value)) return false
|
||||
// Match a single emoji (including compound emoji with ZWJ, skin tones, variation selectors, flags)
|
||||
// Uses Unicode segmentation: a single emoji can be base + modifiers/ZWJ sequences
|
||||
const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\ufe0f)(\u200d(\p{Emoji_Presentation}|\p{Emoji}\ufe0f)|\p{Emoji_Modifier})*$/u
|
||||
return emojiRegex.test(value)
|
||||
}
|
||||
|
||||
/** Curated emoji categories for the note icon picker. */
|
||||
export const EMOJI_CATEGORIES: { name: string; emojis: string[] }[] = [
|
||||
{
|
||||
name: 'Smileys',
|
||||
emojis: ['😀', '😃', '😄', '😁', '😆', '🥹', '😅', '🤣', '😂', '🙂', '😉', '😊', '😇', '🥰', '😍', '🤩', '😘', '😎', '🤓', '🧐', '🤔', '🤗', '🫡', '🤫', '🫠', '😶', '😑', '😬', '🙄', '😴'],
|
||||
},
|
||||
{
|
||||
name: 'Hands & People',
|
||||
emojis: ['👋', '🤚', '✋', '🖐️', '👌', '🤌', '✌️', '🤞', '🫰', '🤙', '👍', '👎', '👏', '🙌', '🫶', '🙏', '💪', '🧠', '👀', '👁️', '👤', '🧑💻', '🧑🎨', '🧑🔬', '🧑🚀', '🧑🏫', '🧑⚕️', '🧑🍳', '🏃', '🧘'],
|
||||
},
|
||||
{
|
||||
name: 'Nature',
|
||||
emojis: ['🌱', '🌿', '🍀', '🌵', '🌲', '🌳', '🌴', '🌸', '🌺', '🌻', '🌹', '💐', '🍂', '🍁', '🍃', '🌾', '🐝', '🦋', '🐛', '🐞', '🐦', '🦅', '🐺', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐸'],
|
||||
},
|
||||
{
|
||||
name: 'Food & Drink',
|
||||
emojis: ['🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐', '🍒', '🍑', '🥝', '🍅', '🥑', '🌽', '🥕', '🧅', '🍞', '🥐', '🧁', '🍰', '🎂', '🍪', '☕', '🍵', '🧃', '🥤', '🍺', '🍷', '🥂'],
|
||||
},
|
||||
{
|
||||
name: 'Activities',
|
||||
emojis: ['⚽', '🏀', '🏈', '⚾', '🎾', '🏐', '🎱', '🏓', '🎮', '🕹️', '🎲', '🧩', '♟️', '🎯', '🎳', '🎸', '🎹', '🎺', '🎨', '🖌️', '📷', '🎬', '🎭', '🎤', '🎧', '📚', '📝', '✏️', '🖊️', '📖'],
|
||||
},
|
||||
{
|
||||
name: 'Travel & Places',
|
||||
emojis: ['🏠', '🏡', '🏢', '🏗️', '🏰', '🏛️', '⛪', '🕌', '🗼', '🗽', '⛲', '🎪', '🚀', '✈️', '🚂', '🚗', '🚲', '⛵', '🏔️', '🌋', '🏖️', '🏜️', '🗺️', '🌍', '🌎', '🌏', '🧭', '⛺', '🎡', '🎢'],
|
||||
},
|
||||
{
|
||||
name: 'Objects',
|
||||
emojis: ['💡', '🔦', '🕯️', '📱', '💻', '⌨️', '🖥️', '🖨️', '📸', '🔭', '🔬', '🧪', '💊', '🩺', '🔑', '🗝️', '🔒', '🔓', '🧲', '⚙️', '🔧', '🔨', '⛏️', '🪛', '🧰', '📦', '📮', '✉️', '📩', '🏷️'],
|
||||
},
|
||||
{
|
||||
name: 'Symbols',
|
||||
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '💔', '❣️', '💕', '💝', '⭐', '🌟', '✨', '💫', '🔥', '💥', '🎉', '🎊', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖️', '📌', '📍', '🚩', '🏁'],
|
||||
},
|
||||
{
|
||||
name: 'Flags & Signs',
|
||||
emojis: ['✅', '❌', '⭕', '❓', '❗', '💯', '🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '⚫', '⚪', '🟤', '🔶', '🔷', '🔸', '🔹', '▶️', '⏸️', '⏹️', '⏺️', '⏭️', '🔀', '🔁', '🔂', '🔄', '➡️', '⬅️'],
|
||||
},
|
||||
]
|
||||
165
tests/smoke/note-icon.spec.ts
Normal file
165
tests/smoke/note-icon.spec.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, findCommand, executeCommand, sendShortcut } from './helpers'
|
||||
|
||||
test.describe('Note icon emoji picker', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2500)
|
||||
})
|
||||
|
||||
test('emoji picker opens from Add Icon button and selects emoji', async ({ page }) => {
|
||||
// Open a note by clicking the first item in the note list
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// The note icon area should be visible
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await expect(iconArea).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Hover to reveal the "Add icon" button and click it
|
||||
await iconArea.hover()
|
||||
const addButton = page.locator('[data-testid="note-icon-add"]')
|
||||
await expect(addButton).toBeVisible()
|
||||
await addButton.click()
|
||||
|
||||
// Emoji picker should appear
|
||||
const picker = page.locator('[data-testid="emoji-picker"]')
|
||||
await expect(picker).toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Click the first emoji
|
||||
const emojiOption = picker.locator('[data-testid="emoji-option"]').first()
|
||||
await emojiOption.click()
|
||||
|
||||
// Picker should close
|
||||
await expect(picker).not.toBeVisible()
|
||||
|
||||
// The icon should now be displayed
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
await expect(iconDisplay).toBeVisible({ timeout: 2000 })
|
||||
})
|
||||
|
||||
test('emoji icon can be removed via menu', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Add an icon first
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-option"]').first().click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Click the displayed icon to show the menu
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
await iconDisplay.click()
|
||||
|
||||
// Menu should show Remove option
|
||||
const removeButton = page.locator('[data-testid="note-icon-remove"]')
|
||||
await expect(removeButton).toBeVisible()
|
||||
await removeButton.click()
|
||||
|
||||
// Icon should be removed, add button returns on hover
|
||||
await expect(iconDisplay).not.toBeVisible()
|
||||
await iconArea.hover()
|
||||
await expect(page.locator('[data-testid="note-icon-add"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cmd+K shows Set Note Icon command', async ({ page }) => {
|
||||
// Open a note first
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open command palette and search for the icon command
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'Set Note Icon')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('Set Note Icon command opens emoji picker', async ({ page }) => {
|
||||
// Open a note first
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Use command palette to open emoji picker
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Set Note Icon')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Emoji picker should be visible
|
||||
const picker = page.locator('[data-testid="emoji-picker"]')
|
||||
await expect(picker).toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Select an emoji
|
||||
await picker.locator('[data-testid="emoji-option"]').first().click()
|
||||
|
||||
// Icon should be displayed
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
await expect(iconDisplay).toBeVisible({ timeout: 2000 })
|
||||
})
|
||||
|
||||
test('Escape closes the emoji picker', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open picker
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
|
||||
const picker = page.locator('[data-testid="emoji-picker"]')
|
||||
await expect(picker).toBeVisible()
|
||||
|
||||
// Press Escape to close
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(picker).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('emoji icon is shown in Quick Open (Cmd+P) results', async ({ page }) => {
|
||||
// Open a note and set an icon
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
// Get the note title for later search
|
||||
const noteTitle = await noteItem.locator('.font-medium, .text-sm').first().textContent()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Set an icon
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const firstEmoji = page.locator('[data-testid="emoji-option"]').first()
|
||||
const emojiText = await firstEmoji.textContent()
|
||||
await firstEmoji.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open Quick Open and search for the note
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
|
||||
if (noteTitle && emojiText) {
|
||||
await searchInput.fill(noteTitle.trim().substring(0, 10))
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verify the emoji appears in the search results
|
||||
const results = page.locator('.py-1 .cursor-pointer .truncate')
|
||||
const resultTexts = await results.allTextContents()
|
||||
const hasEmoji = resultTexts.some(t => t.includes(emojiText))
|
||||
expect(hasEmoji).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user